package main import ( "archive/zip" "crypto/sha256" "embed" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/sqweek/dialog" ) //go:embed offline.zip var offlineFS embed.FS 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 embedded 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() isOnline := *onlineFlag && !*offlineFlag // GUI mode on Windows unless --cli hasCLI := false for _, a := range os.Args { if a=="--cli" { hasCLI=true } } if runtime.GOOS=="windows" && !hasCLI { // try GUI, fallback to console on error if err := runGUI(isOnline); err != nil { fmt.Printf("GUI failed %v, fallback console\n", err) runConsole(isOnline) } return } runConsole(isOnline) } func runConsole(isOnline bool) { 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") { createShortcut = askShortcut() } var err error if isOnline { err = installOnline(dir, createShortcut, nil) } else { zipPath := *offlineZipFlag if zipPath=="" { zipPath = findAdjacentOfflineZip() } if zipPath=="" { // try embedded err = installOfflineEmbedded(dir, createShortcut) } else { err = installOffline(dir, zipPath, createShortcut) } } if err != nil { fatal("%v", err) } fmt.Println("\nInstallation complete!") } func runGUI(isOnline bool) error { // Fallback to console dialogs if GUI not available (walk requires windows) // For now use sqweek/dialog wizard to mimic Inno bootstrap dir := *installDirFlag if dir == "" { // Welcome ok := dialog.Message("Добро пожаловать в ZernMC\n\nМастер установит ZernMC Launcher на ваш компьютер.\nОнлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив\n\nНажмите ОК чтобы выбрать папку C:\\ZernMC").Title("ZernMC Setup — Приветствие").YesNo() if !ok { return fmt.Errorf("cancelled") } d, err := dialog.Directory().Title("Выберите папку установки — ZernMC").Browse() if err != nil || d=="" { d = defaultInstallDir } if !strings.Contains(strings.ToLower(d), "zernmc") { d = filepath.Join(d, "ZernMC") } dir = d } if err := os.MkdirAll(dir, 0755); err != nil { dialog.Message("Ошибка создания папки: %v", err).Title("Ошибка").Error(); return err } createShortcut := *shortcutFlag if !isFlagSet("shortcut") { createShortcut = dialog.Message("Создать ярлык на рабочем столе?").Title("Ярлык").YesNo() } // Progress via console + dialog fmt.Printf("Установка в %s ...\n", dir) var err error if isOnline { err = installOnline(dir, createShortcut, func(cur, total int, name string){ fmt.Printf("[%d/%d] %s\n", cur, total, name) }) } else { zipPath := *offlineZipFlag if zipPath=="" { zipPath = findAdjacentOfflineZip() } if zipPath!="" { err = installOffline(dir, zipPath, createShortcut) } else { err = installOfflineEmbedded(dir, createShortcut) } } if err != nil { dialog.Message("Ошибка установки: %v", err).Title("Ошибка").Error(); return err } dialog.Message("Установка завершена!\n\nЗапустите с ярлыка или C:\\ZernMC\\zernmc.exe").Title("ZernMC Setup — Готово").Info() // launch? if dialog.Message("Запустить ZernMC сейчас?").Title("Готово").YesNo() { exec.Command(filepath.Join(dir, "zernmc.exe")).Start() } return nil } func isFlagSet(name string) bool { found:=false; flag.Visit(func(f *flag.Flag){ if f.Name==name { found=true } }); return found } func askInstallDir() string { if runtime.GOOS=="windows" { dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse() if err==nil && dir!="" { if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") } return dir } fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir) } 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 } return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo() } func fatal(format string, args ...interface{}) { msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg) if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() } os.Exit(1) } func installOnline(dir string, createShortcut bool, progress func(cur,total int, name string)) error { fmt.Println("Online installation — downloading JRE + launcher files...") 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 { 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 { return fmt.Errorf("unzip JRE: %w", err) } 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 { 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") } 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 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 } if progress!=nil { progress(i+1, len(meta.Files), rel) } 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) if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) } hash, _:=fileSHA256(dest) if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) } } if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) } 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 installOfflineEmbedded(dir string, createShortcut bool) error { fmt.Println("Offline embedded installation...") data, err:=offlineFS.ReadFile("offline.zip") if err!=nil { // try adjacent zp:=findAdjacentOfflineZip() if zp!="" { return installOffline(dir, zp, createShortcut) } return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err) } tmp:=filepath.Join(os.TempDir(), fmt.Sprintf("offline-%d.zip", time.Now().UnixNano())) if err:=os.WriteFile(tmp, data, 0644); err!=nil { return err } defer os.Remove(tmp) fmt.Println("Unpacking embedded (98 MB)...") if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) } 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") 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)...") if err:=unzip(zipPath, dir); err!=nil { return fmt.Errorf("unzip: %w", err) } 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") return nil } func downloadFile(url, dest string) error { 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) } 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() if err:=os.Rename(tmp, dest); err!=nil { 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) 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) { 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) 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] } 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 { 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\necho Uninstalling ZernMC from %s\ntimeout /t 2 >nul\ndel \"%%%%USERPROFILE%%%%\\Desktop\\ZernMC Launcher.lnk\" 2>nul\ndel \"%%%%USERPROFILE%%%%\\OneDrive\\Desktop\\ZernMC Launcher.lnk\" 2>nul\necho Data in %%%%USERPROFILE%%%%\\.zernmc will be kept.\n", 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() }