package main import ( "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/sqweek/dialog" ) var ( installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)") onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)") offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)") offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode") shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut") ) const ( defaultInstallDir = "C:\\ZernMC" serverBase = "https://api.zern.cc" jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip" ) func main() { flag.Parse() // Offline flag overrides online isOnline := *onlineFlag && !*offlineFlag dir := *installDirFlag if dir == "" { dir = askInstallDir() } if dir == "" { fmt.Println("Installation cancelled") os.Exit(0) } fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline) if err := os.MkdirAll(dir, 0755); err != nil { fatal("Cannot create directory: %v", err) } createShortcut := *shortcutFlag if !isFlagSet("shortcut") { // ask via dialog if not explicitly flagged createShortcut = askShortcut() } if isOnline { if err := installOnline(dir, createShortcut); err != nil { fatal("Online install failed: %v", err) } } else { zipPath := *offlineZipFlag if zipPath == "" { // try to find adjacent offline zip zipPath = findAdjacentOfflineZip() if zipPath == "" { // ask user zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load() } } if zipPath == "" { fatal("Offline zip not specified") } if err := installOffline(dir, zipPath, createShortcut); err != nil { fatal("Offline install failed: %v", err) } } fmt.Println("\nInstallation complete!") fmt.Printf("Launcher: %s\\zernmc.exe\n", dir) fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE")) if createShortcut { fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk") } fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder") } func isFlagSet(name string) bool { found := false flag.Visit(func(f *flag.Flag) { if f.Name == name { found = true } }) return found } func askInstallDir() string { // Try GUI dialog first (Windows) if runtime.GOOS == "windows" { dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse() if err == nil && dir != "" { // dialog.Directory returns chosen dir; append ZernMC if not already if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") } return dir } // fallback to default if dialog fails/cancelled with empty fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir) } // CLI fallback fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir) var input string fmt.Scanln(&input) input = strings.TrimSpace(input) if input == "" { input = defaultInstallDir } return input } func askShortcut() bool { if runtime.GOOS != "windows" { return false } // simple yes/no dialog // sqweek/dialog Message does not return bool, so use question // fallback to true by default if dialog unavailable yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo() return yes } func fatal(format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) fmt.Fprintln(os.Stderr, msg) // also show dialog on Windows if runtime.GOOS == "windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() } os.Exit(1) } func installOnline(dir string, createShortcut bool) error { fmt.Println("Online installation — downloading JRE + launcher files...") // Step 1: download JRE zip if missing jreDir := filepath.Join(dir, "lib", "jre21") javaExe := filepath.Join(jreDir, "bin", "java.exe") if _, err := os.Stat(javaExe); err != nil { fmt.Println("Downloading JRE 21 (47 MB)...") jreURL := serverBase + "/launcher/download/jre" tmpZip := filepath.Join(os.TempDir(), jreZipName) if err := downloadFile(jreURL, tmpZip); err != nil { // fallback to direct zip from builds if server endpoint not ready // try alternative mirrors mirrors := []string{ "https://api.zernmc.ru/launcher/download/jre", "https://api.zernmc.online/launcher/download/jre", } var lastErr error for _, m := range mirrors { fmt.Printf("Retry mirror %s\n", m) if err := downloadFile(m, tmpZip); err == nil { lastErr = nil break } else { lastErr = err } } if lastErr != nil { return fmt.Errorf("JRE download failed: %w", lastErr) } } fmt.Printf("Unpacking JRE to %s ...\n", jreDir) if err := unzip(tmpZip, dir); err != nil { // try alt: unzip should contain jre21/ prefix or lib/jre21? // our zip contains jre21/ at root, need to move to lib/jre21 return fmt.Errorf("unzip JRE: %w", err) } // handle both zip layouts: some zips contain jre21/, some lib/jre21/ // if tmp unzip created dir/jre21, move to lib/jre21 candidate := filepath.Join(dir, "jre21") if _, err := os.Stat(candidate); err == nil { os.MkdirAll(filepath.Join(dir, "lib"), 0755) os.RemoveAll(jreDir) if err := os.Rename(candidate, jreDir); err != nil { // fallback copy if err := copyDir(candidate, jreDir); err != nil { return err } os.RemoveAll(candidate) } } os.Remove(tmpZip) if _, err := os.Stat(javaExe); err != nil { return fmt.Errorf("JRE unpack failed, %s not found", javaExe) } fmt.Println("JRE ready") } else { fmt.Println("JRE already present, skipping") } // Step 2: fetch meta and download launcher files fmt.Println("Fetching launcher meta...") meta, err := fetchMeta() if err != nil { return fmt.Errorf("fetch meta: %w", err) } fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files)) for i, f := range meta.Files { rel := f.Path // skip JRE entries (should not be in meta, but just in case) if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") { continue } dest := filepath.Join(dir, filepath.FromSlash(rel)) need := true if fi, err := os.Stat(dest); err == nil && !fi.IsDir() { hash, _ := fileSHA256(dest) if hash == strings.TrimPrefix(f.Hash, "sha256:") { need = false } } if !need { continue } fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024) if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel) // try mirrors on failure if err := downloadFile(url, dest); err != nil { // try versioned fallback (some servers use builds/ vs versions/) alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic _ = alt return fmt.Errorf("download %s: %w", rel, err) } // verify hash hash, _ := fileSHA256(dest) if hash != strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) } } // write build.version if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil { fmt.Printf("warning write build.version: %v\n", err) } // also ensure bin/zernmclauncher.jar etc are present fmt.Println("Launcher files ready") if createShortcut { if err := createDesktopShortcut(dir); err != nil { fmt.Printf("shortcut failed: %v\n", err) } } if err := createUninstaller(dir); err != nil { fmt.Printf("uninstall.exe failed: %v\n", err) } return nil } func installOffline(dir, zipPath string, createShortcut bool) error { fmt.Printf("Offline installation from %s to %s\n", zipPath, dir) if _, err := os.Stat(zipPath); err != nil { return fmt.Errorf("offline zip not found: %s", zipPath) } fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...") if err := unzip(zipPath, dir); err != nil { return fmt.Errorf("unzip: %w", err) } // zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir // if zip contained top-level folder, handle it // we unzip directly to dir, so should be fine if createShortcut { if err := createDesktopShortcut(dir); err != nil { fmt.Printf("shortcut failed: %v\n", err) } } if err := createUninstaller(dir); err != nil { fmt.Printf("uninstall.exe failed: %v\n", err) } fmt.Println("Offline unpack complete") fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE")) return nil } func downloadFile(url, dest string) error { // support resume? simple GET client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("HTTP %d", resp.StatusCode) } // ensure parent if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } tmp := dest + ".tmp" out, err := os.Create(tmp) if err != nil { return err } defer out.Close() total := resp.ContentLength var written int64 buf := make([]byte, 64*1024) start := time.Now() for { n, err := resp.Body.Read(buf) if n > 0 { if _, werr := out.Write(buf[:n]); werr != nil { return werr } written += int64(n) if total > 0 { pct := float64(written) / float64(total) * 100 elapsed := time.Since(start).Seconds() speed := float64(written) / 1024 / 1024 / (elapsed + 0.001) fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed) } else { fmt.Printf("\r %.1f MB ", float64(written)/1024/1024) } } if err == io.EOF { break } if err != nil { return err } } fmt.Println() out.Close() // verify tmp exists if err := os.Rename(tmp, dest); err != nil { // fallback copy if err2 := copyFile(tmp, dest); err2 != nil { return err2 } os.Remove(tmp) } return nil } func unzip(src, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() for _, f := range r.File { fpath := filepath.Join(dest, f.Name) // ZipSlip protection if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", fpath) } if f.FileInfo().IsDir() { os.MkdirAll(fpath, f.Mode()) continue } if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { return err } out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } rc, err := f.Open() if err != nil { out.Close() return err } _, err = io.Copy(out, rc) out.Close() rc.Close() if err != nil { return err } } return nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } rel, _ := filepath.Rel(path, src) target := filepath.Join(dst, rel) if info.IsDir() { return os.MkdirAll(target, info.Mode()) } return copyFile(path, target) }) } func fileSHA256(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } type Meta struct { Version string `json:"version"` Files []struct { Path string `json:"path"` Size int64 `json:"size"` Hash string `json:"hash"` } `json:"files"` } func fetchMeta() (*Meta, error) { // try /launcher/meta/{version} flow: first get version, then meta resp, err := http.Get(serverBase + "/launcher/version") if err != nil { return nil, err } defer resp.Body.Close() var verResp struct { Version string `json:"version"` } if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { return nil, err } ver := verResp.Version if ver == "" { ver = "1.1.1" } metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver) if err != nil { return nil, err } defer metaResp.Body.Close() if metaResp.StatusCode != 200 { return nil, fmt.Errorf("meta %d", metaResp.StatusCode) } var meta Meta if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { return nil, err } if meta.Version == "" { meta.Version = ver } return &meta, nil } func findAdjacentOfflineZip() string { exe, _ := os.Executable() dir := filepath.Dir(exe) // look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip")) if len(matches) > 0 { return matches[0] } matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip")) if len(matches) > 0 { return matches[0] } // also check current dir matches, _ = filepath.Glob("ZernMC-Offline*.zip") if len(matches) > 0 { return matches[0] } return "" } func createDesktopShortcut(installDir string) error { if runtime.GOOS != "windows" { return nil } desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop") if _, err := os.Stat(desktop); err != nil { desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") } if _, err := os.Stat(desktop); err != nil { return fmt.Errorf("desktop not found") } lnk := filepath.Join(desktop, "ZernMC Launcher.lnk") target := filepath.Join(installDir, "zernmc.exe") ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target) tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1") if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil { return err } defer os.Remove(tmp) cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func createUninstaller(dir string) error { // create uninstall.bat and copy self as uninstall.exe exe, _ := os.Executable() uninstExe := filepath.Join(dir, "uninstall.exe") if exe != "" && exe != uninstExe { _ = copyFile(exe, uninstExe) } bat := filepath.Join(dir, "uninstall.bat") content := fmt.Sprintf(`@echo off echo Uninstalling ZernMC from %s timeout /t 2 >nul del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul echo Data in %%USERPROFILE%%\.zernmc will be kept. echo To remove completely, delete %%USERPROFILE%%\.zernmc manually. `, dir) _ = os.WriteFile(bat, []byte(content), 0644) return nil } func runCmd(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }