6 Commits

Author SHA1 Message Date
SashegDev 025a21d01f site: add launcher-style auth (login/register modal, JWT localStorage) + cabinet with Сборки tab (list /packs, download /pack/{name}/zip with Bearer, pass check, manual Prologue intercept) 2026-09-02 04:35:57 +00:00
SashegDev 146a56cdff fix: pack zip route shadowing (zip before file, 404 -> 403), manual ZernPrologue specify NeoForge 21.1.228 with warning 2026-09-02 04:30:44 +00:00
SashegDev 3006408447 site+server: add manual Zern Prologue pack variant (download zip + instructions) — site card with path %USERPROFILE%\.zernmc\instances\ZernPrologue, prism/multimc import, endpoint /pack/ZernPrologue/zip (requires pass) 2026-09-02 00:57:43 +00:00
SashegDev 48f73ef0d4 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
2026-09-01 15:29:12 +00:00
SashegDev b864e17921 feat: 1.1.1.4 GUI bootstrap-Inno installer + standalone offline
- Installer.java: Swing dark Bootstrap (bg 0c0c12, accent e94560) Inno wizard (sidebar steps, Welcome->Dir->Progress->Finish), JRE + meta download via /launcher/file, offline via embedded /offline.zip + appended ZIP + adjacent fallback, shortcut/uninstall
- pom: hotfix 4, launch4j 2 exe ZernMC-Online/Offline-Setup-1.1.1.4.exe (icon, versionInfo, header gui, jar shaded), offline.zip resource
- server/main.py: Content-Disposition attachment filename for online/offline, BUILDS_DIR absolute
- Builds: Online 6.3M (downloads), Offline 103M (standalone embedded 98M zip), both signed, site headers fixed
2026-09-01 14:14:30 +00:00
SashegDev 1900db3caf fix: 1.1.1.3 online installer 5.8M + mods toggle + ZGC vs G1GC conflict
- server/main.py BUILDS_DIR absolute + /launcher/download/online,offine-setup
- site index/js online->/download/online 5.8M
- LaunchCommandBuilder skip G1GC when systemBasedJvm (ZGC conflict -> Bootstrap crash)
- JFXLauncher toggle robust rename + FileAlreadyExists fix
2026-09-01 13:51:30 +00:00
11 changed files with 800 additions and 534 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 -1
View File
@@ -406,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 инсталляторов -->
@@ -120,16 +120,19 @@ public class LaunchCommandBuilder {
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
} }
// Append memory/GC args (always after version.json args, like AstralRinth) // Append memory/GC args (always after version.json args, like AstralRinth) — skip if systemBasedJvm adds its own GC (ZGC/G1)
boolean useSystemGc = me.sashegdev.zernmc.launcher.utils.Config.isSystemBasedJvm();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
command.add("-Xmx" + ramMB + "M"); command.add("-Xmx" + ramMB + "M");
command.add("-Xms" + Math.max(512, ramMB / 2) + "M"); command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
command.add("-XX:+UseG1GC"); if (!useSystemGc) {
command.add("-XX:+UnlockExperimentalVMOptions"); command.add("-XX:+UseG1GC");
command.add("-XX:G1NewSizePercent=20"); command.add("-XX:+UnlockExperimentalVMOptions");
command.add("-XX:G1ReservePercent=20"); command.add("-XX:G1NewSizePercent=20");
command.add("-XX:MaxGCPauseMillis=50"); command.add("-XX:G1ReservePercent=20");
command.add("-XX:G1HeapRegionSize=32M"); command.add("-XX:MaxGCPauseMillis=50");
command.add("-XX:G1HeapRegionSize=32M");
}
// Append custom user JVM args // Append custom user JVM args
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) { if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
@@ -1856,16 +1856,30 @@ public class JFXLauncher extends Application {
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot toggle mods in server pack (only whitelist)")); return; } if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot toggle mods in server pack (only whitelist)")); return; }
Path modsDir = inst.getPath().resolve("mods"); Path modsDir = inst.getPath().resolve("mods");
Path src = modsDir.resolve(file); Path src = modsDir.resolve(file);
if (!Files.exists(src)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; } if (!Files.exists(src)) {
// Try alternative name (enable: caller may send base name without .disabled)
String alt = enable ? file + ".disabled" : file.replace(".disabled", "");
Path altSrc = modsDir.resolve(alt);
if (Files.exists(altSrc)) src = altSrc;
else if (enable && file.endsWith(".jar")) {
Path disabledSrc = modsDir.resolve(file + ".disabled");
if (Files.exists(disabledSrc)) src = disabledSrc;
else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
file = file + ".disabled";
} else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
}
Path dst; Path dst;
if (enable) { if (enable) {
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9)); if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
else if (src.getFileName().toString().endsWith(".disabled")) dst = modsDir.resolve(src.getFileName().toString().replace(".disabled", ""));
else { sendJson(exchange, Map.of("success", true)); return; } else { sendJson(exchange, Map.of("success", true)); return; }
} else { } else {
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled"); if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
else if (src.getFileName().toString().endsWith(".jar")) dst = modsDir.resolve(src.getFileName().toString() + ".disabled");
else { sendJson(exchange, Map.of("success", true)); return; } else { sendJson(exchange, Map.of("success", true)); return; }
} }
Files.move(src, dst); if (Files.exists(dst)) Files.delete(dst);
Files.move(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
sendJson(exchange, Map.of("success", true)); sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); } } catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
} }
+1 -1
View File
@@ -20,7 +20,7 @@
<properties> <properties>
<revision>1.1.1</revision> <revision>1.1.1</revision>
<hotfix>2</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>
+70 -1
View File
@@ -43,7 +43,7 @@ logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes # Cache for manifests - expires after 5 minutes
manifest_cache = TTLCache(maxsize=100, ttl=300) manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") BUILDS_DIR = Path(__file__).parent / "builds"
VERSIONS_DIR = BUILDS_DIR / "versions" VERSIONS_DIR = BUILDS_DIR / "versions"
WHITELIST_DIR = Path(__file__).parent / "whitelist" WHITELIST_DIR = Path(__file__).parent / "whitelist"
@@ -1310,6 +1310,32 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
) )
@app.get("/pack/{pack_name}/zip")
async def download_pack_zip(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Download whole pack as zip for manual installation (requires pass)"""
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(403, "Requires active pass")
if is_pack_disabled(pack_name):
raise HTTPException(403, "Pack is disabled")
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise HTTPException(404, "Pack not found")
import tempfile, zipfile
tmp = Path(tempfile.gettempdir()) / f"{pack_name}.zip"
# re-use if fresh (<5 min)
if tmp.exists() and (datetime.utcnow().timestamp() - tmp.stat().st_mtime) < 300:
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
# create zip
def _zip():
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in pack_path.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(pack_path))
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _zip)
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
@app.get("/pack/{pack_name}/file/{file_path:path}") @app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None): async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None):
"""Get a file from a pack. If preset is specified, try preset dir first, fall back to base.""" """Get a file from a pack. If preset is specified, try preset dir first, fall back to base."""
@@ -1813,6 +1839,49 @@ async def download_jre(request: Request):
return await send_file_async(pp, request, content_type="application/zip", cache=True) return await send_file_async(pp, request, content_type="application/zip", cache=True)
raise HTTPException(404, "JRE zip not found on server") raise HTTPException(404, "JRE zip not found on server")
def _find_latest_setup(pattern: str) -> Path | None:
"""Find latest Go setup exe by pattern (Online/Offline)"""
cands = list(BUILDS_DIR.glob(pattern))
if not cands:
return None
# sort by version key from filename
def ver_key(p: Path):
# ZernMC-Online-Setup-1.1.1.2.exe -> 1.1.1.2
name = p.stem
ver = name.split("-")[-1]
try:
return tuple(int(x) for x in ver.split("."))
except:
return (0,)
cands.sort(key=ver_key, reverse=True)
return cands[0]
@app.get("/launcher/download/online")
async def download_online_setup(request: Request):
"""Download online Go installer (5.8M, STANDALONE EXE without bundled JRE)"""
p = _find_latest_setup("ZernMC-Online-Setup-*.exe")
if p and p.exists():
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
return resp
raise HTTPException(404, "Online installer not found")
@app.get("/launcher/download/offline-setup")
async def download_offline_setup(request: Request):
"""Download offline Go installer (STANDALONE EXE with embedded build, ~100M)"""
p = _find_latest_setup("ZernMC-Offline-Setup-*.exe")
if p and p.exists():
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
return resp
# fallback: try legacy offline zip embed name without -Setup
p2 = _find_latest_setup("ZernMC-Offline-*.exe")
if p2 and p2.exists():
resp = await send_file_async(p2, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p2.name}"'
return resp
raise HTTPException(404, "Offline installer not found")
@app.get("/launcher/download/zip/{filename}") @app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str, request: Request = None): async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive""" """Download specific launcher ZIP archive"""
+15
View File
@@ -1304,6 +1304,21 @@ ul { list-style: none; }
.pv-sb-section { margin-top: 6px; } .pv-sb-section { margin-top: 6px; }
} }
/* ========== Auth & Cabinet (launcher style) ========== */
.hidden { display: none !important; }
.modal-backdrop { position:fixed; inset:0; background:rgba(7,7,10,0.72); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; z-index:100; animation:fadeIn 0.2s ease; }
.modal { background:var(--bg-surface, #0c0c12); border:1px solid var(--border, rgba(255,255,255,0.08)); border-radius:16px; width:90%; max-width:420px; box-shadow:0 20px 60px rgba(0,0,0,0.6); overflow:hidden; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-head h3 { font-size:15px; font-weight:700; }
.modal-close { background:transparent; border:none; color:var(--text-secondary); font-size:20px; cursor:pointer; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:8px; }
.modal-close:hover { background:var(--bg-card); color:var(--text); }
.modal-body { padding:20px; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-size:12px; color:var(--text-secondary); }
.field input { width:100%; padding:10px 12px; background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; color:var(--text); outline:none; }
.field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(233,69,96,0.15); }
.cabinet-tabs { display:flex; gap:8px; margin-bottom:12px; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; } *, *::before, *::after { animation: none !important; transition: none !important; }
html { scroll-behavior: auto; } html { scroll-behavior: auto; }
+109 -2
View File
@@ -33,6 +33,12 @@
<span class="lang-toggle-label" id="langToggleLabel">EN</span> <span class="lang-toggle-label" id="langToggleLabel">EN</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button> </button>
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
</div>
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a> <a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
</div> </div>
</div> </div>
@@ -383,11 +389,11 @@
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/jre" download> <a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/online" download>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span>Online Setup (5.8М)</span> <span>Online Setup (5.8М)</span>
</a> </a>
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/latest"> <a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/offline-setup">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Offline ZIP (98М)</span> <span>Offline ZIP (98М)</span>
</a> </a>
@@ -438,6 +444,41 @@
</div> </div>
<code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code> <code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code>
</div> </div>
<!-- Manual Zern Prologue pack -->
<div class="manual-pack-card" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<div>
<h4 style="font-size:15px;font-weight:700">Zern Prologue — ручная установка</h4>
<p style="font-size:12px;color:var(--text-secondary);margin-top:2px">1.21.1 NeoForge <strong style="color:var(--text)">21.1.228</strong> · Create: Aeronautics · без лаунчера</p>
</div>
<span style="margin-left:auto;font-size:11px;color:var(--accent);background:rgba(233,69,96,0.12);padding:4px 8px;border-radius:999px;white-space:nowrap">v4 · 1.8 ГБ</span>
</div>
<p style="font-size:13px;color:var(--text-secondary);line-height:1.5">Скачай архив модпака и распакуй вручную. Подходит если лаунчер не качает или хочешь поставить на Prism / MultiMC / ATLauncher.</p>
<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);border-radius:8px;padding:8px 10px;font-size:12px;color:#fbbf24;display:flex;gap:8px;align-items:center"><span style="font-size:14px">⚠️</span> <span><strong>Важно:</strong> используй только <strong>NeoForge 21.1.228</strong> для 1.21.1 — на новейшей (21.5+) сборка крашится, мир не загрузится.</span></div>
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-family:var(--mono);font-size:12px;color:var(--text-secondary)">
%USERPROFILE%\.zernmc\instances\ZernPrologue
<span style="color:var(--text-muted)"> · ~/.zernmc/instances/ZernPrologue на Linux</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="/pack/ZernPrologue/zip" class="btn btn-primary" style="flex:1;justify-content:center;min-width:180px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать Prologue.zip
</a>
<button class="btn btn-ghost" onclick="navigator.clipboard.writeText('%USERPROFILE%\\.zernmc\\instances');var t=document.createElement('div');t.textContent='Скопировано';t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);border:1px solid var(--border);padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999';document.body.appendChild(t);setTimeout(()=>t.remove(),1500)" style="flex:0 0 auto">Копировать путь</button>
</div>
<details style="font-size:12px;color:var(--text-secondary)"><summary style="cursor:pointer;color:var(--text)">Инструкция</summary>
<ol style="margin:8px 0 0 18px;display:flex;flex-direction:column;gap:4px">
<li>Нажми «Скачать Prologue.zip» (требует проходку — войди в лаунчере хотя бы раз, иначе 403).</li>
<li>Распаковать в папку выше (создай если нет). Если там уже есть Prologue — замени.</li>
<li>Для Prism/MultiMC: Импорт → «Import from zip» или «Add instance → Import from folder» укажи распакованную папку, Java 21.</li>
<li>Запусти через ZernMC лаунчер или свой — вход на <strong>mc.zernmc.ru</strong></li>
</ol>
</details>
</div>
</div> </div>
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p> <p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
@@ -470,6 +511,72 @@
</div> </div>
</footer> </footer>
<!-- Auth modal (launcher style) -->
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
<button class="modal-close" id="auth-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:8px;margin-bottom:4px">
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
</div>
<div class="field">
<label for="auth-username" data-i18n="auth.username">Логин</label>
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field">
<label for="auth-password" data-i18n="auth.password">Пароль</label>
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field hidden" id="auth-confirm-field">
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
</div>
</div>
</div>
<!-- Cabinet (personal area) -->
<section class="section hidden" id="cabinet">
<div class="section-head">
<span class="section-index">08 / 08</span>
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
</div>
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
<div>
<div style="font-weight:700" id="cab-username"></div>
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role"></span> · <span id="cab-pass"></span></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px"></p></div></div>
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2"></p></div></div>
</div>
</div>
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
</div>
</section>
<script src="/js/main.js"></script> <script src="/js/main.js"></script>
</body> </body>
</html> </html>
+279 -6
View File
@@ -111,6 +111,24 @@
'preview.online': 'Онлайн', 'preview.online': 'Онлайн',
'preview.install.title': 'Установка сборки…', 'preview.install.title': 'Установка сборки…',
'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ', 'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ',
'nav.login': 'Войти',
'nav.cabinet': 'Кабинет',
'nav.logout': 'Выйти',
'auth.title': 'Вход',
'auth.login': 'Вход',
'auth.register': 'Регистрация',
'auth.username': 'Логин',
'auth.password': 'Пароль',
'auth.confirm': 'Повтор пароля',
'auth.error': 'Ошибка',
'cabinet.title': 'Личный <span class="accent">кабинет.</span>',
'cabinet.sub': 'Ваш профиль и доступные сборки',
'cabinet.profile': 'Профиль',
'cabinet.packs': 'Сборки',
'cabinet.pass': 'Проходка',
'cabinet.available': 'Доступные сборки',
'cabinet.downloading': 'Скачивание...',
'cabinet.needPass': 'Нужна проходка',
'news.title': 'Что <span class="accent">нового.</span>', 'news.title': 'Что <span class="accent">нового.</span>',
'news.sub': 'Последние чейнджлоги и обновления команды Zern.', 'news.sub': 'Последние чейнджлоги и обновления команды Zern.',
'news.empty': 'Новостей пока нет.', 'news.empty': 'Новостей пока нет.',
@@ -225,6 +243,24 @@
'preview.online': 'Online', 'preview.online': 'Online',
'preview.install.title': 'Installing pack…', 'preview.install.title': 'Installing pack…',
'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB', 'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB',
'nav.login': 'Sign in',
'nav.cabinet': 'Cabinet',
'nav.logout': 'Logout',
'auth.title': 'Sign in',
'auth.login': 'Sign in',
'auth.register': 'Sign up',
'auth.username': 'Username',
'auth.password': 'Password',
'auth.confirm': 'Confirm password',
'auth.error': 'Error',
'cabinet.title': 'Personal <span class="accent">cabinet.</span>',
'cabinet.sub': 'Your profile and available packs',
'cabinet.profile': 'Profile',
'cabinet.packs': 'Packs',
'cabinet.pass': 'Pass',
'cabinet.available': 'Available packs',
'cabinet.downloading': 'Downloading...',
'cabinet.needPass': 'Pass required',
'news.title': 'What\'s <span class="accent">new.</span>', 'news.title': 'What\'s <span class="accent">new.</span>',
'news.sub': 'Latest changelogs and updates from the Zern team.', 'news.sub': 'Latest changelogs and updates from the Zern team.',
'news.empty': 'No news yet.', 'news.empty': 'No news yet.',
@@ -345,16 +381,12 @@
// online setup is Go exe (~5.8M) — try to discover via /launcher/version or static name // online setup is Go exe (~5.8M) — try to discover via /launcher/version or static name
try { try {
const v = version && version !== '—' ? version : '1.1.1.2'; const v = version && version !== '—' ? version : '1.1.1.2';
dlOnline.href = API + '/launcher/download/jre'; // JRE endpoint validated, exe name is ZernMC-Online-Setup-<ver>.exe dlOnline.href = API + '/launcher/download/online';
dlOnline.setAttribute('data-version', v); dlOnline.setAttribute('data-version', v);
// probe if online exe exists
fetch(API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe', {method:'HEAD'}).then(r=>{
if (r.ok) dlOnline.href = API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe';
}).catch(()=>{});
} catch(e){} } catch(e){}
} }
if (dlOffline) dlOffline.href = API + '/launcher/download/offline-setup';
if (dlOffline && data.new_format && data.new_format.download_url) { if (dlOffline && data.new_format && data.new_format.download_url) {
dlOffline.href = API + data.new_format.download_url;
} }
} catch (e) { } catch (e) {
console.warn('[site] failed to load launcher info', e); console.warn('[site] failed to load launcher info', e);
@@ -756,6 +788,244 @@
} catch(e) { /* keep static fallback */ } } catch(e) { /* keep static fallback */ }
} }
// ====================== AUTH (site, launcher style) ======================
const AUTH_KEY = 'zern_auth';
let authMode = 'login'; // login | register
function getAuth() {
try { return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch(e) { return null; }
}
function setAuth(data) {
try { localStorage.setItem(AUTH_KEY, JSON.stringify(data)); } catch(e) {}
updateAuthUI();
}
function clearAuth() {
try { localStorage.removeItem(AUTH_KEY); } catch(e) {}
updateAuthUI();
}
function authHeaders() {
const a = getAuth();
return a && a.access_token ? { 'Authorization': 'Bearer ' + a.access_token } : {};
}
function updateAuthUI() {
const a = getAuth();
const btn = document.getElementById('auth-btn');
const menu = document.getElementById('user-menu');
const nameEl = document.getElementById('user-name');
if (a && a.username) {
if (btn) btn.classList.add('hidden');
if (menu) menu.classList.remove('hidden');
if (nameEl) nameEl.textContent = a.username;
} else {
if (btn) btn.classList.remove('hidden');
if (menu) menu.classList.add('hidden');
}
}
function showAuthModal(mode) {
authMode = mode || 'login';
const modal = document.getElementById('auth-modal');
const title = document.getElementById('auth-title');
const tabLogin = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const confirmField = document.getElementById('auth-confirm-field');
const submit = document.getElementById('auth-submit');
const err = document.getElementById('auth-error');
if (err) { err.classList.add('hidden'); err.textContent=''; }
if (title) title.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
if (tabLogin) { tabLogin.style.background = authMode==='login' ? 'var(--accent)' : ''; tabLogin.style.color = authMode==='login' ? '#fff' : ''; }
if (tabReg) { tabReg.style.background = authMode==='register' ? 'var(--accent)' : ''; tabReg.style.color = authMode==='register' ? '#fff' : ''; }
if (confirmField) confirmField.classList.toggle('hidden', authMode!=='register');
if (submit) submit.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
if (modal) modal.classList.remove('hidden');
}
function hideAuthModal() {
const m = document.getElementById('auth-modal');
if (m) m.classList.add('hidden');
}
async function handleAuthSubmit() {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
const c = document.getElementById('auth-confirm') ? document.getElementById('auth-confirm').value : '';
const err = document.getElementById('auth-error');
const btn = document.getElementById('auth-submit');
if (!u || !p) { if (err){ err.textContent='Заполните поля'; err.classList.remove('hidden'); } return; }
if (authMode==='register' && p!==c) { if (err){ err.textContent='Пароли не совпадают'; err.classList.remove('hidden'); } return; }
if (btn) { btn.disabled=true; btn.textContent='...'; }
if (err) err.classList.add('hidden');
const endpoint = authMode==='register' ? '/auth/register' : '/auth/login';
try {
const r = await fetch(API + endpoint, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username:u, password:p}) });
const data = await r.json();
if (!r.ok) throw new Error(data.detail || data.message || 'Ошибка');
// TokenResponse {access_token, refresh_token, username, uuid, role, role_name}
setAuth(data);
hideAuthModal();
// after login, refresh cabinet if open
if (!document.getElementById('cabinet').classList.contains('hidden')) loadCabinetPacks();
// also check pass
try { const pr = await fetch(API + '/auth/pass/my', { headers: authHeaders() }); const pd = await pr.json(); if (pd.has_active) { /* pass active */ } } catch(e){}
} catch(e) {
if (err){ err.textContent = e.message || 'Ошибка'; err.classList.remove('hidden'); }
} finally { if (btn) { btn.disabled=false; btn.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login'); } }
}
async function handleLogout() {
const a = getAuth();
try { if (a) await fetch(API + '/auth/logout', { method:'POST', headers: authHeaders() }); } catch(e) {}
clearAuth();
// hide cabinet
document.getElementById('cabinet').classList.add('hidden');
}
async function loadCabinetProfile() {
const a = getAuth();
if (!a) return;
// try to get role/pass via /auth/pass/my and /admin/me or via token payload
const cabUser = document.getElementById('cab-username');
const cabAvatar = document.getElementById('cab-avatar');
const cabRole = document.getElementById('cab-role');
const cabUuid = document.getElementById('cab-uuid');
const cabPass = document.getElementById('cab-pass');
const cabPass2 = document.getElementById('cab-pass2');
if (cabUser) cabUser.textContent = a.username || '—';
if (cabAvatar) cabAvatar.textContent = (a.username||'Z')[0].toUpperCase();
if (cabUuid) cabUuid.textContent = a.uuid || '—';
if (cabRole) cabRole.textContent = a.role_name || ('Role '+ (a.role||0));
// pass check
try {
const r = await fetch(API + '/auth/pass/my', { headers: authHeaders() });
const d = await r.json();
const has = !!d.has_active;
if (cabPass) cabPass.textContent = has ? 'Активна' : 'Нет';
if (cabPass2) { cabPass2.textContent = has ? 'Активна' : 'Нет'; cabPass2.style.color = has ? 'var(--success)' : 'var(--error)'; }
} catch(e) {
if (cabPass) cabPass.textContent = '—';
}
}
async function loadCabinetPacks() {
const list = document.getElementById('cab-packs-list');
const count = document.getElementById('cab-packs-count');
const errEl = document.getElementById('cab-packs-error');
if (!list) return;
list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">Загрузка...</p>';
if (errEl) errEl.classList.add('hidden');
const a = getAuth();
if (!a) { list.innerHTML = '<p style="color:var(--text-muted)">Войдите, чтобы увидеть сборки</p>'; return; }
try {
const r = await fetch(API + '/packs', { headers: authHeaders() });
if (r.status===401 || r.status===403) throw new Error('Нужна проходка или вход');
if (!r.ok) throw new Error('Не удалось загрузить');
const data = await r.json();
const packs = data.packs || [];
if (count) count.textContent = packs.length + ' шт.';
if (!packs.length) { list.innerHTML = '<p style="color:var(--text-muted)">Нет доступных сборок</p>'; return; }
list.innerHTML = '';
packs.forEach(p=>{
const el = document.createElement('div');
el.style.cssText='display:flex;align-items:center;gap:12px;padding:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:10px';
el.innerHTML = `
<div style="width:36px;height:36px;border-radius:8px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px">${p.name} <span style="font-weight:400;color:var(--text-muted)">v${p.version||''}</span></div>
<div style="font-size:12px;color:var(--text-secondary)">${p.minecraft_version||''} · ${p.loader_type||''} ${p.loader_version||''} · ${p.files_count||0} файлов</div>
</div>
<button class="btn btn-primary" style="padding:8px 14px;font-size:13px;flex-shrink:0" data-pack="${p.name}">Скачать</button>
`;
const btn = el.querySelector('button');
btn.addEventListener('click', ()=> downloadPack(p.name, btn));
list.appendChild(el);
});
} catch(e) {
if (errEl){ errEl.textContent = e.message; errEl.classList.remove('hidden'); }
list.innerHTML = '';
}
}
async function downloadPack(name, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = tr('cabinet.downloading');
try {
const r = await fetch(API + '/pack/' + encodeURIComponent(name) + '/zip', { headers: authHeaders() });
if (!r.ok) {
const t = await r.text();
let msg = t;
try { const j = JSON.parse(t); msg = j.detail || j.message || t; } catch(e){}
throw new Error(msg || ('HTTP '+r.status));
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name + '.zip';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch(e) {
alert(e.message);
} finally { btn.disabled=false; btn.textContent=orig; }
}
function initAuth() {
updateAuthUI();
const authBtn = document.getElementById('auth-btn');
const cabinetBtn = document.getElementById('cabinet-btn');
const logoutBtn = document.getElementById('logout-btn');
const authModal = document.getElementById('auth-modal');
const authClose = document.getElementById('auth-close');
const tabLogin = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const submit = document.getElementById('auth-submit');
const cabTabProfile = document.getElementById('cab-tab-profile');
const cabTabPacks = document.getElementById('cab-tab-packs');
if (authBtn) authBtn.addEventListener('click', ()=> showAuthModal('login'));
if (cabinetBtn) cabinetBtn.addEventListener('click', ()=>{
const cab = document.getElementById('cabinet');
if (cab) { cab.classList.remove('hidden'); cab.scrollIntoView({behavior:'smooth'}); loadCabinetProfile(); loadCabinetPacks(); }
});
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
if (authClose) authClose.addEventListener('click', hideAuthModal);
if (authModal) authModal.addEventListener('click', e=>{ if(e.target===authModal) hideAuthModal(); });
if (tabLogin) tabLogin.addEventListener('click', ()=> showAuthModal('login'));
if (tabReg) tabReg.addEventListener('click', ()=> showAuthModal('register'));
if (submit) submit.addEventListener('click', handleAuthSubmit);
// enter key
['auth-username','auth-password','auth-confirm'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.addEventListener('keydown', e=>{ if(e.key==='Enter') handleAuthSubmit(); });
});
if (cabTabProfile) cabTabProfile.addEventListener('click', ()=>{
document.getElementById('cabinet-profile').classList.remove('hidden');
document.getElementById('cabinet-packs').classList.add('hidden');
cabTabProfile.classList.add('btn-primary'); cabTabProfile.classList.remove('btn-ghost');
cabTabPacks.classList.remove('btn-primary'); cabTabPacks.classList.add('btn-ghost');
});
if (cabTabPacks) cabTabPacks.addEventListener('click', ()=>{
document.getElementById('cabinet-profile').classList.add('hidden');
document.getElementById('cabinet-packs').classList.remove('hidden');
cabTabPacks.classList.add('btn-primary'); cabTabPacks.classList.remove('btn-ghost');
cabTabProfile.classList.remove('btn-primary'); cabTabProfile.classList.add('btn-ghost');
loadCabinetPacks();
});
// also make manual Prologue button use auth download (override default link)
const manualBtn = document.querySelector('a[href="/pack/ZernPrologue/zip"]');
if (manualBtn) {
manualBtn.addEventListener('click', async (e)=>{
const a = getAuth();
if (!a) { e.preventDefault(); showAuthModal('login'); return; }
e.preventDefault();
// reuse downloadPack
const fakeBtn = { disabled:false, textContent: manualBtn.textContent };
// temporarily set text
const orig = manualBtn.innerHTML;
manualBtn.textContent = tr('cabinet.downloading');
try {
const r = await fetch(API + '/pack/ZernPrologue/zip', { headers: authHeaders() });
if (!r.ok) throw new Error((await r.text()).slice(0,120));
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a2 = document.createElement('a'); a2.href=url; a2.download='ZernPrologue.zip'; document.body.appendChild(a2); a2.click(); a2.remove(); URL.revokeObjectURL(url);
} catch(err){ alert(err.message); }
manualBtn.innerHTML = orig;
});
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
applyI18n(); applyI18n();
@@ -774,5 +1044,8 @@
initParallax(); initParallax();
initReveal(); initReveal();
initYear(); initYear();
initAuth();
// if already authed, preload cabinet data
if (getAuth()) loadCabinetProfile();
}); });
})(); })();