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