diff --git a/installer/go.mod b/installer/go.mod index 6140853..948c7d8 100644 --- a/installer/go.mod +++ b/installer/go.mod @@ -4,5 +4,9 @@ go 1.21 require ( github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect + golang.org/x/sys v0.28.0 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect ) diff --git a/installer/go.sum b/installer/go.sum index 11393c3..68751fc 100644 --- a/installer/go.sum +++ b/installer/go.sum @@ -1,4 +1,13 @@ github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0= github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/installer/main.go b/installer/main.go index b3becb7..20a047a 100644 --- a/installer/main.go +++ b/installer/main.go @@ -1,552 +1,324 @@ package main import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" + "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" + "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 adjacent zip)") - offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode") - shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut") + 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" + 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") + 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 isFlagSet(name string) bool { - found := false - flag.Visit(func(f *flag.Flag) { - if f.Name == name { - found = true - } - }) - return found +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 { - // 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 + 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 - } - // 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 + 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) - // also show dialog on Windows - if runtime.GOOS == "windows" { - dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() - } - os.Exit(1) + 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) 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 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, ~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 + 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 { - // 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 + 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) - // 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 + 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 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 + 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 "" + 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() + 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() + 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() } diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index cec38e5..db5a1e4 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -112,12 +112,6 @@ ui/** - - src/main/resources - - offline.zip - - @@ -263,39 +257,6 @@ - - - l4j-online-setup - package - launch4j - - ../../server/builds/ZernMC-Online-Setup-${project.version}.${hotfix}.exe - ../../server/builds/zernmclauncher.jar - ${project.basedir}/src/main/icons/zernmc.ico - gui - false - me.sashegdev.zernmc.launcher.installer.Installerfalse - lib/jre2121 - ${project.version}.${hotfix}${project.version}.${hotfix}ZernMC Online SetupCopyright (c) 2023-2026 ZernMC${project.version}.${hotfix}${project.version}.${hotfix}ZernMCZernMCzernmc-online-setupZernMC-Online-Setup.exe - - - - - l4j-offline-setup - package - launch4j - - ../../server/builds/ZernMC-Offline-Setup-${project.version}.${hotfix}.exe - ../../server/builds/zernmclauncher.jar - ${project.basedir}/src/main/icons/zernmc.ico - gui - false - me.sashegdev.zernmc.launcher.installer.Installerfalse - --offline - lib/jre2121 - ${project.version}.${hotfix}${project.version}.${hotfix}ZernMC Offline SetupCopyright (c) 2023-2026 ZernMC${project.version}.${hotfix}${project.version}.${hotfix}ZernMCZernMCzernmc-offline-setupZernMC-Offline-Setup.exe - - @@ -445,7 +406,7 @@ How to use: - + diff --git a/launcher/pom.xml b/launcher/pom.xml index b254d2f..1a6c23e 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -20,7 +20,7 @@ 1.1.1 - 4 + 5 21 21 UTF-8