Compare commits
6 Commits
5944c7b1b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 025a21d01f | |||
| 146a56cdff | |||
| 3006408447 | |||
| 48f73ef0d4 | |||
| b864e17921 | |||
| 1900db3caf |
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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() }
|
||||||
|
|||||||
@@ -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>&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>&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>&1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer && 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>&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 && 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>&1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; fi"/>
|
||||||
</exec>
|
</exec>
|
||||||
|
|
||||||
<!-- Подпись Go инсталляторов -->
|
<!-- Подпись Go инсталляторов -->
|
||||||
|
|||||||
+10
-7
@@ -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
@@ -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
@@ -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"""
|
||||||
|
|||||||
@@ -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
@@ -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">×</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
@@ -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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user