revert: Go absolute installers GUI bootstrap-Inno 1.1.1.5

- installer/main.go: Go absolute GUI wizard (Welcome->Dir->Progress->Finish) via dialog, bootstrap colors, embed offline.zip for offline 104M standalone, online 5.8M downloads JRE+meta full files
- go.mod: +walk/win for Inno-like wizard (Go 1.21, sys 0.28)
- pom hotfix 5, Go build: online small (empty offline.zip) + offline large (embed ZernMC-win-*.zip), both signed, Content-Disposition kept
This commit is contained in:
SashegDev
2026-09-01 15:29:12 +00:00
parent b864e17921
commit 48f73ef0d4
5 changed files with 301 additions and 555 deletions
+4
View File
@@ -4,5 +4,9 @@ go 1.21
require ( require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect 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 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
) )
+9
View File
@@ -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 h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= 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 h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= 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=
+286 -514
View File
@@ -1,552 +1,324 @@
package main package main
import ( import (
"archive/zip" "archive/zip"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "embed"
"encoding/json" "encoding/hex"
"flag" "encoding/json"
"fmt" "flag"
"io" "fmt"
"net/http" "io"
"os" "net/http"
"os/exec" "os"
"path/filepath" "os/exec"
"runtime" "path/filepath"
"strings" "runtime"
"time" "strings"
"time"
"github.com/sqweek/dialog" "github.com/sqweek/dialog"
) )
//go:embed offline.zip
var offlineFS embed.FS
var ( var (
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)") installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)") onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)") offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from embedded zip)")
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode") offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut") shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
) )
const ( const (
defaultInstallDir = "C:\\ZernMC" defaultInstallDir = "C:\\ZernMC"
serverBase = "https://api.zern.cc" serverBase = "https://api.zern.cc"
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip" jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
) )
func main() { func main() {
flag.Parse() flag.Parse()
isOnline := *onlineFlag && !*offlineFlag
// Offline flag overrides online // GUI mode on Windows unless --cli
isOnline := *onlineFlag && !*offlineFlag hasCLI := false
for _, a := range os.Args { if a=="--cli" { hasCLI=true } }
dir := *installDirFlag if runtime.GOOS=="windows" && !hasCLI {
if dir == "" { // try GUI, fallback to console on error
dir = askInstallDir() if err := runGUI(isOnline); err != nil {
} fmt.Printf("GUI failed %v, fallback console\n", err)
if dir == "" { runConsole(isOnline)
fmt.Println("Installation cancelled") }
os.Exit(0) return
} }
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline) runConsole(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 { func runConsole(isOnline bool) {
found := false dir := *installDirFlag
flag.Visit(func(f *flag.Flag) { if dir == "" { dir = askInstallDir() }
if f.Name == name { if dir == "" { fmt.Println("Installation cancelled"); os.Exit(0) }
found = true 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
return found 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 { func askInstallDir() string {
// Try GUI dialog first (Windows) if runtime.GOOS=="windows" {
if runtime.GOOS == "windows" { dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse() if err==nil && dir!="" {
if err == nil && dir != "" { if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") }
// dialog.Directory returns chosen dir; append ZernMC if not already return dir
if !strings.Contains(strings.ToLower(dir), "zernmc") { }
dir = filepath.Join(dir, "ZernMC") fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir)
} }
return dir fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
} var input string; fmt.Scanln(&input); input=strings.TrimSpace(input)
// fallback to default if dialog fails/cancelled with empty if input=="" { input=defaultInstallDir }
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir) return input
}
// 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 { func askShortcut() bool {
if runtime.GOOS != "windows" { if runtime.GOOS!="windows" { return false }
return false return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
}
// 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{}) { func fatal(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...) msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg)
fmt.Fprintln(os.Stderr, msg) if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() }
// also show dialog on Windows os.Exit(1)
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 {
func installOnline(dir string, createShortcut bool) error { fmt.Println("Online installation — downloading JRE + launcher files...")
fmt.Println("Online installation — downloading JRE + launcher files...") jreDir:=filepath.Join(dir, "lib", "jre21"); javaExe:=filepath.Join(jreDir, "bin", "java.exe")
// Step 1: download JRE zip if missing if _, err:=os.Stat(javaExe); err!=nil {
jreDir := filepath.Join(dir, "lib", "jre21") fmt.Println("Downloading JRE 21 (47 MB)...")
javaExe := filepath.Join(jreDir, "bin", "java.exe") jreURL:=serverBase+"/launcher/download/jre"; tmpZip:=filepath.Join(os.TempDir(), jreZipName)
if _, err := os.Stat(javaExe); err != nil { if err:=downloadFile(jreURL, tmpZip); err!=nil {
fmt.Println("Downloading JRE 21 (47 MB)...") mirrors:=[]string{"https://api.zernmc.ru/launcher/download/jre","https://api.zernmc.online/launcher/download/jre"}
jreURL := serverBase + "/launcher/download/jre" var lastErr error
tmpZip := filepath.Join(os.TempDir(), jreZipName) 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 err := downloadFile(jreURL, tmpZip); err != nil { if lastErr!=nil { return fmt.Errorf("JRE download failed: %w", lastErr) }
// fallback to direct zip from builds if server endpoint not ready }
// try alternative mirrors fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
mirrors := []string{ if err:=unzip(tmpZip, dir); err!=nil { return fmt.Errorf("unzip JRE: %w", err) }
"https://api.zernmc.ru/launcher/download/jre", candidate:=filepath.Join(dir, "jre21")
"https://api.zernmc.online/launcher/download/jre", 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)
var lastErr error if _, err:=os.Stat(javaExe); err!=nil { return fmt.Errorf("JRE unpack failed, %s not found", javaExe) }
for _, m := range mirrors { fmt.Println("JRE ready")
fmt.Printf("Retry mirror %s\n", m) } else { fmt.Println("JRE already present, skipping") }
if err := downloadFile(m, tmpZip); err == nil { fmt.Println("Fetching launcher meta...")
lastErr = nil meta, err:=fetchMeta()
break if err!=nil { return fmt.Errorf("fetch meta: %w", err) }
} else { fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
lastErr = err for i, f:=range meta.Files {
} rel:=f.Path
} if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") { continue }
if lastErr != nil { dest:=filepath.Join(dir, filepath.FromSlash(rel))
return fmt.Errorf("JRE download failed: %w", lastErr) need:=true
} if fi, err:=os.Stat(dest); err==nil && !fi.IsDir() {
} hash, _:=fileSHA256(dest)
fmt.Printf("Unpacking JRE to %s ...\n", jreDir) if hash==strings.TrimPrefix(f.Hash, "sha256:") { need=false }
if err := unzip(tmpZip, dir); err != nil { }
// try alt: unzip should contain jre21/ prefix or lib/jre21? if !need { continue }
// our zip contains jre21/ at root, need to move to lib/jre21 if progress!=nil { progress(i+1, len(meta.Files), rel) }
return fmt.Errorf("unzip JRE: %w", err) 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 }
// handle both zip layouts: some zips contain jre21/, some lib/jre21/ url:=fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
// if tmp unzip created dir/jre21, move to lib/jre21 if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) }
candidate := filepath.Join(dir, "jre21") hash, _:=fileSHA256(dest)
if _, err := os.Stat(candidate); err == nil { if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) }
os.MkdirAll(filepath.Join(dir, "lib"), 0755) }
os.RemoveAll(jreDir) if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) }
if err := os.Rename(candidate, jreDir); err != nil { fmt.Println("Launcher files ready")
// fallback copy if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err := copyDir(candidate, jreDir); err != nil { if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
return err return nil
} }
os.RemoveAll(candidate) func installOfflineEmbedded(dir string, createShortcut bool) error {
} fmt.Println("Offline embedded installation...")
} data, err:=offlineFS.ReadFile("offline.zip")
os.Remove(tmpZip) if err!=nil {
if _, err := os.Stat(javaExe); err != nil { // try adjacent
return fmt.Errorf("JRE unpack failed, %s not found", javaExe) zp:=findAdjacentOfflineZip()
} if zp!="" { return installOffline(dir, zp, createShortcut) }
fmt.Println("JRE ready") return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err)
} else { }
fmt.Println("JRE already present, skipping") 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)
// Step 2: fetch meta and download launcher files fmt.Println("Unpacking embedded (98 MB)...")
fmt.Println("Fetching launcher meta...") if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
meta, err := fetchMeta() if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err != nil { if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
return fmt.Errorf("fetch meta: %w", err) fmt.Println("Offline unpack complete")
} return nil
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 { func installOffline(dir, zipPath string, createShortcut bool) error {
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir) fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
if _, err := os.Stat(zipPath); err != nil { if _, err:=os.Stat(zipPath); err!=nil { return fmt.Errorf("offline zip not found: %s", zipPath) }
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) }
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...") if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err := unzip(zipPath, dir); err != nil { if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
return fmt.Errorf("unzip: %w", err) fmt.Println("Offline unpack complete")
} return nil
// 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 { func downloadFile(url, dest string) error {
// support resume? simple GET client:=&http.Client{Timeout: 120*time.Second}
client := &http.Client{Timeout: 120 * time.Second} resp, err:=client.Get(url)
resp, err := client.Get(url) if err!=nil { return err }
if err != nil { defer resp.Body.Close()
return err if resp.StatusCode!=200 { return fmt.Errorf("HTTP %d", resp.StatusCode) }
} if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
defer resp.Body.Close() tmp:=dest+".tmp"
if resp.StatusCode != 200 { out, err:=os.Create(tmp)
return fmt.Errorf("HTTP %d", resp.StatusCode) if err!=nil { return err }
} defer out.Close()
// ensure parent total:=resp.ContentLength
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { var written int64
return err buf:=make([]byte, 64*1024)
} start:=time.Now()
tmp := dest + ".tmp" for {
out, err := os.Create(tmp) n, err:=resp.Body.Read(buf)
if err != nil { if n>0 {
return err if _, werr:=out.Write(buf[:n]); werr!=nil { return werr }
} written+=int64(n)
defer out.Close() if total>0 {
pct:=float64(written)/float64(total)*100
total := resp.ContentLength elapsed:=time.Since(start).Seconds()
var written int64 speed:=float64(written)/1024/1024/(elapsed+0.001)
buf := make([]byte, 64*1024) fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
start := time.Now() } else { fmt.Printf("\r %.1f MB ", float64(written)/1024/1024) }
for { }
n, err := resp.Body.Read(buf) if err==io.EOF { break }
if n > 0 { if err!=nil { return err }
if _, werr := out.Write(buf[:n]); werr != nil { }
return werr fmt.Println()
} out.Close()
written += int64(n) if err:=os.Rename(tmp, dest); err!=nil { if err2:=copyFile(tmp, dest); err2!=nil { return err2 }; os.Remove(tmp) }
if total > 0 { return nil
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 { func unzip(src, dest string) error {
r, err := zip.OpenReader(src) r, err:=zip.OpenReader(src)
if err != nil { if err!=nil { return err }
return err defer r.Close()
} for _, f:=range r.File {
defer r.Close() fpath:=filepath.Join(dest, f.Name)
for _, f := range r.File { if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", fpath) }
fpath := filepath.Join(dest, f.Name) if f.FileInfo().IsDir() { os.MkdirAll(fpath, f.Mode()); continue }
// ZipSlip protection if err:=os.MkdirAll(filepath.Dir(fpath), 0755); err!=nil { return err }
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { out, err:=os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
return fmt.Errorf("illegal file path: %s", fpath) if err!=nil { return err }
} rc, err:=f.Open()
if f.FileInfo().IsDir() { if err!=nil { out.Close(); return err }
os.MkdirAll(fpath, f.Mode()) _, err=io.Copy(out, rc)
continue out.Close(); rc.Close()
} if err!=nil { return err }
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { }
return err return nil
}
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 copyFile(src, dst string) error { 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) }) }
in, err := os.Open(src) 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 }
if err != nil { type Meta struct { Version string `json:"version"`; Files []struct{ Path string `json:"path"`; Size int64 `json:"size"`; Hash string `json:"hash"` } `json:"files"` }
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) { func fetchMeta() (*Meta, error) {
// try /launcher/meta/{version} flow: first get version, then meta resp, err:=http.Get(serverBase+"/launcher/version")
resp, err := http.Get(serverBase + "/launcher/version") if err!=nil { return nil, err }
if err != nil { defer resp.Body.Close()
return nil, err var verResp struct{ Version string `json:"version"` }
} if err:=json.NewDecoder(resp.Body).Decode(&verResp); err!=nil { return nil, err }
defer resp.Body.Close() ver:=verResp.Version
var verResp struct { if ver=="" { ver="1.1.1" }
Version string `json:"version"` metaResp, err:=http.Get(serverBase+"/launcher/meta/"+ver)
} if err!=nil { return nil, err }
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { defer metaResp.Body.Close()
return nil, err if metaResp.StatusCode!=200 { return nil, fmt.Errorf("meta %d", metaResp.StatusCode) }
} var meta Meta
ver := verResp.Version if err:=json.NewDecoder(metaResp.Body).Decode(&meta); err!=nil { return nil, err }
if ver == "" { if meta.Version=="" { meta.Version=ver }
ver = "1.1.1" return &meta, nil
}
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 { func findAdjacentOfflineZip() string {
exe, _ := os.Executable() exe, _:=os.Executable(); dir:=filepath.Dir(exe)
dir := filepath.Dir(exe) matches, _:=filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby if len(matches)>0 { return matches[0] }
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip")) matches, _=filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
if len(matches) > 0 { if len(matches)>0 { return matches[0] }
return matches[0] matches, _=filepath.Glob("ZernMC-Offline*.zip")
} if len(matches)>0 { return matches[0] }
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip")) return ""
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 { func createDesktopShortcut(installDir string) error {
if runtime.GOOS != "windows" { if runtime.GOOS!="windows" { return nil }
return nil desktop:=filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
} if _, err:=os.Stat(desktop); err!=nil { desktop=filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") }
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop") if _, err:=os.Stat(desktop); err!=nil { return fmt.Errorf("desktop not found") }
if _, err := os.Stat(desktop); err != nil { lnk:=filepath.Join(desktop, "ZernMC Launcher.lnk")
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") 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)
if _, err := os.Stat(desktop); err != nil { tmp:=filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
return fmt.Errorf("desktop not found") if err:=os.WriteFile(tmp, []byte(ps), 0644); err!=nil { return err }
} defer os.Remove(tmp)
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk") cmd:=exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
target := filepath.Join(installDir, "zernmc.exe") cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr
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) return cmd.Run()
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 { func createUninstaller(dir string) error {
// create uninstall.bat and copy self as uninstall.exe exe, _:=os.Executable(); uninstExe:=filepath.Join(dir, "uninstall.exe")
exe, _ := os.Executable() if exe!="" && exe!=uninstExe { _=copyFile(exe, uninstExe) }
uninstExe := filepath.Join(dir, "uninstall.exe") bat:=filepath.Join(dir, "uninstall.bat")
if exe != "" && exe != uninstExe { 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)
_ = copyFile(exe, uninstExe) _=os.WriteFile(bat, []byte(content), 0644)
} return nil
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()
} }
func runCmd(name string, args ...string) error { cmd:=exec.Command(name, args...); cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr; return cmd.Run() }
+1 -40
View File
@@ -112,12 +112,6 @@
<include>ui/**</include> <include>ui/**</include>
</includes> </includes>
</resource> </resource>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>offline.zip</include>
</includes>
</resource>
</resources> </resources>
<plugins> <plugins>
<plugin> <plugin>
@@ -263,39 +257,6 @@
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
<!-- Online Setup GUI Bootstrap-Inno -->
<execution>
<id>l4j-online-setup</id>
<phase>package</phase>
<goals><goal>launch4j</goal></goals>
<configuration>
<outfile>../../server/builds/ZernMC-Online-Setup-${project.version}.${hotfix}.exe</outfile>
<jar>../../server/builds/zernmclauncher.jar</jar>
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath><mainClass>me.sashegdev.zernmc.launcher.installer.Installer</mainClass><addDependencies>false</addDependencies></classPath>
<jre><path>lib/jre21</path><minVersion>21</minVersion></jre>
<versionInfo><fileVersion>${project.version}.${hotfix}</fileVersion><txtFileVersion>${project.version}.${hotfix}</txtFileVersion><fileDescription>ZernMC Online Setup</fileDescription><copyright>Copyright (c) 2023-2026 ZernMC</copyright><productVersion>${project.version}.${hotfix}</productVersion><txtProductVersion>${project.version}.${hotfix}</txtProductVersion><productName>ZernMC</productName><companyName>ZernMC</companyName><internalName>zernmc-online-setup</internalName><originalFilename>ZernMC-Online-Setup.exe</originalFilename></versionInfo>
</configuration>
</execution>
<!-- Offline Setup GUI with embedded zip -->
<execution>
<id>l4j-offline-setup</id>
<phase>package</phase>
<goals><goal>launch4j</goal></goals>
<configuration>
<outfile>../../server/builds/ZernMC-Offline-Setup-${project.version}.${hotfix}.exe</outfile>
<jar>../../server/builds/zernmclauncher.jar</jar>
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath><mainClass>me.sashegdev.zernmc.launcher.installer.Installer</mainClass><addDependencies>false</addDependencies></classPath>
<cmdLine>--offline</cmdLine>
<jre><path>lib/jre21</path><minVersion>21</minVersion></jre>
<versionInfo><fileVersion>${project.version}.${hotfix}</fileVersion><txtFileVersion>${project.version}.${hotfix}</txtFileVersion><fileDescription>ZernMC Offline Setup</fileDescription><copyright>Copyright (c) 2023-2026 ZernMC</copyright><productVersion>${project.version}.${hotfix}</productVersion><txtProductVersion>${project.version}.${hotfix}</txtProductVersion><productName>ZernMC</productName><companyName>ZernMC</companyName><internalName>zernmc-offline-setup</internalName><originalFilename>ZernMC-Offline-Setup.exe</originalFilename></versionInfo>
</configuration>
</execution>
</executions> </executions>
</plugin> </plugin>
@@ -445,7 +406,7 @@ How to use:
<!-- Go online/offline инсталляторы (нативные, без JRE) --> <!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false"> <exec executable="bash" failonerror="false">
<arg value="-c"/> <arg value="-c"/>
<arg value="if command -v go >/dev/null 2>&amp;1; then echo 'Building Go installers...'; mkdir -p ../../server/builds; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../installer 2>&amp;1 || echo 'Go build skipped (no go)'; cp ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe 2>/dev/null || true; fi"/> <arg value="if command -v /usr/local/go/bin/go >/dev/null 2>&amp;1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go online build failed'; cd ../launcher/launcher; echo 'Building offline with embedded ZIP...'; cp ../../server/builds/ZernMC-win-${revision}.${hotfix}.zip ../../installer/offline.zip 2>/dev/null || cp ../../server/builds/ZernMC-win-1.1.1.4.zip ../../installer/offline.zip 2>/dev/null || true; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; fi"/>
</exec> </exec>
<!-- Подпись Go инсталляторов --> <!-- Подпись Go инсталляторов -->
+1 -1
View File
@@ -20,7 +20,7 @@
<properties> <properties>
<revision>1.1.1</revision> <revision>1.1.1</revision>
<hotfix>4</hotfix> <hotfix>5</hotfix>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>