Compare commits
12 Commits
bcd10cce06
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 025a21d01f | |||
| 146a56cdff | |||
| 3006408447 | |||
| 48f73ef0d4 | |||
| b864e17921 | |||
| 1900db3caf | |||
| 5944c7b1b6 | |||
| 19a1633ec4 | |||
| 75738ae127 | |||
| 5985887fb8 | |||
| 8d5bcca1d5 | |||
| af415157ac |
@@ -4,5 +4,9 @@ go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
|
||||
+286
-514
@@ -1,552 +1,324 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
//go:embed offline.zip
|
||||
var offlineFS embed.FS
|
||||
|
||||
var (
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from embedded zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Offline flag overrides online
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
dir = askInstallDir()
|
||||
}
|
||||
if dir == "" {
|
||||
fmt.Println("Installation cancelled")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fatal("Cannot create directory: %v", err)
|
||||
}
|
||||
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") {
|
||||
// ask via dialog if not explicitly flagged
|
||||
createShortcut = askShortcut()
|
||||
}
|
||||
|
||||
if isOnline {
|
||||
if err := installOnline(dir, createShortcut); err != nil {
|
||||
fatal("Online install failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath == "" {
|
||||
// try to find adjacent offline zip
|
||||
zipPath = findAdjacentOfflineZip()
|
||||
if zipPath == "" {
|
||||
// ask user
|
||||
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
|
||||
}
|
||||
}
|
||||
if zipPath == "" {
|
||||
fatal("Offline zip not specified")
|
||||
}
|
||||
if err := installOffline(dir, zipPath, createShortcut); err != nil {
|
||||
fatal("Offline install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nInstallation complete!")
|
||||
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
|
||||
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
|
||||
if createShortcut {
|
||||
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
|
||||
}
|
||||
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
|
||||
flag.Parse()
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
// GUI mode on Windows unless --cli
|
||||
hasCLI := false
|
||||
for _, a := range os.Args { if a=="--cli" { hasCLI=true } }
|
||||
if runtime.GOOS=="windows" && !hasCLI {
|
||||
// try GUI, fallback to console on error
|
||||
if err := runGUI(isOnline); err != nil {
|
||||
fmt.Printf("GUI failed %v, fallback console\n", err)
|
||||
runConsole(isOnline)
|
||||
}
|
||||
return
|
||||
}
|
||||
runConsole(isOnline)
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
func runConsole(isOnline bool) {
|
||||
dir := *installDirFlag
|
||||
if dir == "" { dir = askInstallDir() }
|
||||
if dir == "" { fmt.Println("Installation cancelled"); os.Exit(0) }
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil { fatal("Cannot create directory: %v", err) }
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") { createShortcut = askShortcut() }
|
||||
var err error
|
||||
if isOnline { err = installOnline(dir, createShortcut, nil) } else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
|
||||
if zipPath=="" {
|
||||
// try embedded
|
||||
err = installOfflineEmbedded(dir, createShortcut)
|
||||
} else { err = installOffline(dir, zipPath, createShortcut) }
|
||||
}
|
||||
if err != nil { fatal("%v", err) }
|
||||
fmt.Println("\nInstallation complete!")
|
||||
}
|
||||
|
||||
func runGUI(isOnline bool) error {
|
||||
// Fallback to console dialogs if GUI not available (walk requires windows)
|
||||
// For now use sqweek/dialog wizard to mimic Inno bootstrap
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
// Welcome
|
||||
ok := dialog.Message("Добро пожаловать в ZernMC\n\nМастер установит ZernMC Launcher на ваш компьютер.\nОнлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив\n\nНажмите ОК чтобы выбрать папку C:\\ZernMC").Title("ZernMC Setup — Приветствие").YesNo()
|
||||
if !ok { return fmt.Errorf("cancelled") }
|
||||
d, err := dialog.Directory().Title("Выберите папку установки — ZernMC").Browse()
|
||||
if err != nil || d=="" { d = defaultInstallDir }
|
||||
if !strings.Contains(strings.ToLower(d), "zernmc") { d = filepath.Join(d, "ZernMC") }
|
||||
dir = d
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil { dialog.Message("Ошибка создания папки: %v", err).Title("Ошибка").Error(); return err }
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") { createShortcut = dialog.Message("Создать ярлык на рабочем столе?").Title("Ярлык").YesNo() }
|
||||
// Progress via console + dialog
|
||||
fmt.Printf("Установка в %s ...\n", dir)
|
||||
var err error
|
||||
if isOnline {
|
||||
err = installOnline(dir, createShortcut, func(cur, total int, name string){
|
||||
fmt.Printf("[%d/%d] %s\n", cur, total, name)
|
||||
})
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
|
||||
if zipPath!="" { err = installOffline(dir, zipPath, createShortcut) } else { err = installOfflineEmbedded(dir, createShortcut) }
|
||||
}
|
||||
if err != nil { dialog.Message("Ошибка установки: %v", err).Title("Ошибка").Error(); return err }
|
||||
dialog.Message("Установка завершена!\n\nЗапустите с ярлыка или C:\\ZernMC\\zernmc.exe").Title("ZernMC Setup — Готово").Info()
|
||||
// launch?
|
||||
if dialog.Message("Запустить ZernMC сейчас?").Title("Готово").YesNo() {
|
||||
exec.Command(filepath.Join(dir, "zernmc.exe")).Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool { found:=false; flag.Visit(func(f *flag.Flag){ if f.Name==name { found=true } }); return found }
|
||||
func askInstallDir() string {
|
||||
// Try GUI dialog first (Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err == nil && dir != "" {
|
||||
// dialog.Directory returns chosen dir; append ZernMC if not already
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") {
|
||||
dir = filepath.Join(dir, "ZernMC")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// fallback to default if dialog fails/cancelled with empty
|
||||
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
// CLI fallback
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
input = defaultInstallDir
|
||||
}
|
||||
return input
|
||||
if runtime.GOOS=="windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err==nil && dir!="" {
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") }
|
||||
return dir
|
||||
}
|
||||
fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string; fmt.Scanln(&input); input=strings.TrimSpace(input)
|
||||
if input=="" { input=defaultInstallDir }
|
||||
return input
|
||||
}
|
||||
|
||||
func askShortcut() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// simple yes/no dialog
|
||||
// sqweek/dialog Message does not return bool, so use question
|
||||
// fallback to true by default if dialog unavailable
|
||||
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
return yes
|
||||
if runtime.GOOS!="windows" { return false }
|
||||
return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
}
|
||||
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
// also show dialog on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
|
||||
}
|
||||
os.Exit(1)
|
||||
msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg)
|
||||
if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() }
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func installOnline(dir string, createShortcut bool) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
// Step 1: download JRE zip if missing
|
||||
jreDir := filepath.Join(dir, "lib", "jre21")
|
||||
javaExe := filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL := serverBase + "/launcher/download/jre"
|
||||
tmpZip := filepath.Join(os.TempDir(), jreZipName)
|
||||
if err := downloadFile(jreURL, tmpZip); err != nil {
|
||||
// fallback to direct zip from builds if server endpoint not ready
|
||||
// try alternative mirrors
|
||||
mirrors := []string{
|
||||
"https://api.zernmc.ru/launcher/download/jre",
|
||||
"https://api.zernmc.online/launcher/download/jre",
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range mirrors {
|
||||
fmt.Printf("Retry mirror %s\n", m)
|
||||
if err := downloadFile(m, tmpZip); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("JRE download failed: %w", lastErr)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err := unzip(tmpZip, dir); err != nil {
|
||||
// try alt: unzip should contain jre21/ prefix or lib/jre21?
|
||||
// our zip contains jre21/ at root, need to move to lib/jre21
|
||||
return fmt.Errorf("unzip JRE: %w", err)
|
||||
}
|
||||
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
|
||||
// if tmp unzip created dir/jre21, move to lib/jre21
|
||||
candidate := filepath.Join(dir, "jre21")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
|
||||
os.RemoveAll(jreDir)
|
||||
if err := os.Rename(candidate, jreDir); err != nil {
|
||||
// fallback copy
|
||||
if err := copyDir(candidate, jreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
os.RemoveAll(candidate)
|
||||
}
|
||||
}
|
||||
os.Remove(tmpZip)
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
|
||||
}
|
||||
fmt.Println("JRE ready")
|
||||
} else {
|
||||
fmt.Println("JRE already present, skipping")
|
||||
}
|
||||
|
||||
// Step 2: fetch meta and download launcher files
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err := fetchMeta()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
|
||||
for i, f := range meta.Files {
|
||||
rel := f.Path
|
||||
// skip JRE entries (should not be in meta, but just in case)
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need := true
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
need = false
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
// try mirrors on failure
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
// try versioned fallback (some servers use builds/ vs versions/)
|
||||
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
|
||||
_ = alt
|
||||
return fmt.Errorf("download %s: %w", rel, err)
|
||||
}
|
||||
// verify hash
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// write build.version
|
||||
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
|
||||
fmt.Printf("warning write build.version: %v\n", err)
|
||||
}
|
||||
// also ensure bin/zernmclauncher.jar etc are present
|
||||
fmt.Println("Launcher files ready")
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
func installOnline(dir string, createShortcut bool, progress func(cur,total int, name string)) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
jreDir:=filepath.Join(dir, "lib", "jre21"); javaExe:=filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err:=os.Stat(javaExe); err!=nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL:=serverBase+"/launcher/download/jre"; tmpZip:=filepath.Join(os.TempDir(), jreZipName)
|
||||
if err:=downloadFile(jreURL, tmpZip); err!=nil {
|
||||
mirrors:=[]string{"https://api.zernmc.ru/launcher/download/jre","https://api.zernmc.online/launcher/download/jre"}
|
||||
var lastErr error
|
||||
for _, m:=range mirrors { fmt.Printf("Retry mirror %s\n", m); if err:=downloadFile(m, tmpZip); err==nil { lastErr=nil; break } else { lastErr=err } }
|
||||
if lastErr!=nil { return fmt.Errorf("JRE download failed: %w", lastErr) }
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err:=unzip(tmpZip, dir); err!=nil { return fmt.Errorf("unzip JRE: %w", err) }
|
||||
candidate:=filepath.Join(dir, "jre21")
|
||||
if _, err:=os.Stat(candidate); err==nil { os.MkdirAll(filepath.Join(dir, "lib"), 0755); os.RemoveAll(jreDir); if err:=os.Rename(candidate, jreDir); err!=nil { if err:=copyDir(candidate, jreDir); err!=nil { return err }; os.RemoveAll(candidate) } }
|
||||
os.Remove(tmpZip)
|
||||
if _, err:=os.Stat(javaExe); err!=nil { return fmt.Errorf("JRE unpack failed, %s not found", javaExe) }
|
||||
fmt.Println("JRE ready")
|
||||
} else { fmt.Println("JRE already present, skipping") }
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err:=fetchMeta()
|
||||
if err!=nil { return fmt.Errorf("fetch meta: %w", err) }
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
for i, f:=range meta.Files {
|
||||
rel:=f.Path
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") { continue }
|
||||
dest:=filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need:=true
|
||||
if fi, err:=os.Stat(dest); err==nil && !fi.IsDir() {
|
||||
hash, _:=fileSHA256(dest)
|
||||
if hash==strings.TrimPrefix(f.Hash, "sha256:") { need=false }
|
||||
}
|
||||
if !need { continue }
|
||||
if progress!=nil { progress(i+1, len(meta.Files), rel) }
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
|
||||
url:=fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) }
|
||||
hash, _:=fileSHA256(dest)
|
||||
if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) }
|
||||
}
|
||||
if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) }
|
||||
fmt.Println("Launcher files ready")
|
||||
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
|
||||
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
|
||||
return nil
|
||||
}
|
||||
func installOfflineEmbedded(dir string, createShortcut bool) error {
|
||||
fmt.Println("Offline embedded installation...")
|
||||
data, err:=offlineFS.ReadFile("offline.zip")
|
||||
if err!=nil {
|
||||
// try adjacent
|
||||
zp:=findAdjacentOfflineZip()
|
||||
if zp!="" { return installOffline(dir, zp, createShortcut) }
|
||||
return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err)
|
||||
}
|
||||
tmp:=filepath.Join(os.TempDir(), fmt.Sprintf("offline-%d.zip", time.Now().UnixNano()))
|
||||
if err:=os.WriteFile(tmp, data, 0644); err!=nil { return err }
|
||||
defer os.Remove(tmp)
|
||||
fmt.Println("Unpacking embedded (98 MB)...")
|
||||
if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
|
||||
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
|
||||
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
|
||||
fmt.Println("Offline unpack complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func installOffline(dir, zipPath string, createShortcut bool) error {
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return fmt.Errorf("offline zip not found: %s", zipPath)
|
||||
}
|
||||
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
|
||||
if err := unzip(zipPath, dir); err != nil {
|
||||
return fmt.Errorf("unzip: %w", err)
|
||||
}
|
||||
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
|
||||
// if zip contained top-level folder, handle it
|
||||
// we unzip directly to dir, so should be fine
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
fmt.Println("Offline unpack complete")
|
||||
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
|
||||
return nil
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err:=os.Stat(zipPath); err!=nil { return fmt.Errorf("offline zip not found: %s", zipPath) }
|
||||
fmt.Println("Unpacking (98 MB)...")
|
||||
if err:=unzip(zipPath, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
|
||||
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
|
||||
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
|
||||
fmt.Println("Offline unpack complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
// support resume? simple GET
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// ensure parent
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var written int64
|
||||
buf := make([]byte, 64*1024)
|
||||
start := time.Now()
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
if total > 0 {
|
||||
pct := float64(written) / float64(total) * 100
|
||||
elapsed := time.Since(start).Seconds()
|
||||
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else {
|
||||
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
// verify tmp exists
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// fallback copy
|
||||
if err2 := copyFile(tmp, dest); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
client:=&http.Client{Timeout: 120*time.Second}
|
||||
resp, err:=client.Get(url)
|
||||
if err!=nil { return err }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode!=200 { return fmt.Errorf("HTTP %d", resp.StatusCode) }
|
||||
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
|
||||
tmp:=dest+".tmp"
|
||||
out, err:=os.Create(tmp)
|
||||
if err!=nil { return err }
|
||||
defer out.Close()
|
||||
total:=resp.ContentLength
|
||||
var written int64
|
||||
buf:=make([]byte, 64*1024)
|
||||
start:=time.Now()
|
||||
for {
|
||||
n, err:=resp.Body.Read(buf)
|
||||
if n>0 {
|
||||
if _, werr:=out.Write(buf[:n]); werr!=nil { return werr }
|
||||
written+=int64(n)
|
||||
if total>0 {
|
||||
pct:=float64(written)/float64(total)*100
|
||||
elapsed:=time.Since(start).Seconds()
|
||||
speed:=float64(written)/1024/1024/(elapsed+0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else { fmt.Printf("\r %.1f MB ", float64(written)/1024/1024) }
|
||||
}
|
||||
if err==io.EOF { break }
|
||||
if err!=nil { return err }
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
if err:=os.Rename(tmp, dest); err!=nil { if err2:=copyFile(tmp, dest); err2!=nil { return err2 }; os.Remove(tmp) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
r, err:=zip.OpenReader(src)
|
||||
if err!=nil { return err }
|
||||
defer r.Close()
|
||||
for _, f:=range r.File {
|
||||
fpath:=filepath.Join(dest, f.Name)
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", fpath) }
|
||||
if f.FileInfo().IsDir() { os.MkdirAll(fpath, f.Mode()); continue }
|
||||
if err:=os.MkdirAll(filepath.Dir(fpath), 0755); err!=nil { return err }
|
||||
out, err:=os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err!=nil { return err }
|
||||
rc, err:=f.Open()
|
||||
if err!=nil { out.Close(); return err }
|
||||
_, err=io.Copy(out, rc)
|
||||
out.Close(); rc.Close()
|
||||
if err!=nil { return err }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(path, src)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error { in, err:=os.Open(src); if err!=nil { return err }; defer in.Close(); out, err:=os.Create(dst); if err!=nil { return err }; defer out.Close(); _, err=io.Copy(out, in); return err }
|
||||
func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err!=nil { return err }; rel, _:=filepath.Rel(path, src); target:=filepath.Join(dst, rel); if info.IsDir() { return os.MkdirAll(target, info.Mode()) }; return copyFile(path, target) }) }
|
||||
func fileSHA256(path string) (string, error) { f, err:=os.Open(path); if err!=nil { return "", err }; defer f.Close(); h:=sha256.New(); if _, err:=io.Copy(h, f); err!=nil { return "", err }; return hex.EncodeToString(h.Sum(nil)), nil }
|
||||
type Meta struct { Version string `json:"version"`; Files []struct{ Path string `json:"path"`; Size int64 `json:"size"`; Hash string `json:"hash"` } `json:"files"` }
|
||||
func fetchMeta() (*Meta, error) {
|
||||
// try /launcher/meta/{version} flow: first get version, then meta
|
||||
resp, err := http.Get(serverBase + "/launcher/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var verResp struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver := verResp.Version
|
||||
if ver == "" {
|
||||
ver = "1.1.1"
|
||||
}
|
||||
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Version == "" {
|
||||
meta.Version = ver
|
||||
}
|
||||
return &meta, nil
|
||||
resp, err:=http.Get(serverBase+"/launcher/version")
|
||||
if err!=nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
var verResp struct{ Version string `json:"version"` }
|
||||
if err:=json.NewDecoder(resp.Body).Decode(&verResp); err!=nil { return nil, err }
|
||||
ver:=verResp.Version
|
||||
if ver=="" { ver="1.1.1" }
|
||||
metaResp, err:=http.Get(serverBase+"/launcher/meta/"+ver)
|
||||
if err!=nil { return nil, err }
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode!=200 { return nil, fmt.Errorf("meta %d", metaResp.StatusCode) }
|
||||
var meta Meta
|
||||
if err:=json.NewDecoder(metaResp.Body).Decode(&meta); err!=nil { return nil, err }
|
||||
if meta.Version=="" { meta.Version=ver }
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findAdjacentOfflineZip() string {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
// also check current dir
|
||||
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
exe, _:=os.Executable(); dir:=filepath.Dir(exe)
|
||||
matches, _:=filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches)>0 { return matches[0] }
|
||||
matches, _=filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches)>0 { return matches[0] }
|
||||
matches, _=filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches)>0 { return matches[0] }
|
||||
return ""
|
||||
}
|
||||
|
||||
func createDesktopShortcut(installDir string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop")
|
||||
}
|
||||
if _, err := os.Stat(desktop); err != nil {
|
||||
return fmt.Errorf("desktop not found")
|
||||
}
|
||||
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk")
|
||||
target := filepath.Join(installDir, "zernmc.exe")
|
||||
ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
|
||||
tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
|
||||
if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
if runtime.GOOS!="windows" { return nil }
|
||||
desktop:=filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
|
||||
if _, err:=os.Stat(desktop); err!=nil { desktop=filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") }
|
||||
if _, err:=os.Stat(desktop); err!=nil { return fmt.Errorf("desktop not found") }
|
||||
lnk:=filepath.Join(desktop, "ZernMC Launcher.lnk")
|
||||
target:=filepath.Join(installDir, "zernmc.exe")
|
||||
ps:=fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
|
||||
tmp:=filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
|
||||
if err:=os.WriteFile(tmp, []byte(ps), 0644); err!=nil { return err }
|
||||
defer os.Remove(tmp)
|
||||
cmd:=exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
|
||||
cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createUninstaller(dir string) error {
|
||||
// create uninstall.bat and copy self as uninstall.exe
|
||||
exe, _ := os.Executable()
|
||||
uninstExe := filepath.Join(dir, "uninstall.exe")
|
||||
if exe != "" && exe != uninstExe {
|
||||
_ = copyFile(exe, uninstExe)
|
||||
}
|
||||
bat := filepath.Join(dir, "uninstall.bat")
|
||||
content := fmt.Sprintf(`@echo off
|
||||
echo Uninstalling ZernMC from %s
|
||||
timeout /t 2 >nul
|
||||
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
echo Data in %%USERPROFILE%%\.zernmc will be kept.
|
||||
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
|
||||
`, dir)
|
||||
_ = os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
exe, _:=os.Executable(); uninstExe:=filepath.Join(dir, "uninstall.exe")
|
||||
if exe!="" && exe!=uninstExe { _=copyFile(exe, uninstExe) }
|
||||
bat:=filepath.Join(dir, "uninstall.bat")
|
||||
content:=fmt.Sprintf("@echo off\necho Uninstalling ZernMC from %s\ntimeout /t 2 >nul\ndel \"%%%%USERPROFILE%%%%\\Desktop\\ZernMC Launcher.lnk\" 2>nul\ndel \"%%%%USERPROFILE%%%%\\OneDrive\\Desktop\\ZernMC Launcher.lnk\" 2>nul\necho Data in %%%%USERPROFILE%%%%\\.zernmc will be kept.\n", dir)
|
||||
_=os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
func runCmd(name string, args ...string) error { cmd:=exec.Command(name, args...); cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr; return cmd.Run() }
|
||||
|
||||
@@ -406,7 +406,7 @@ How to use:
|
||||
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
|
||||
<exec executable="bash" failonerror="false">
|
||||
<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>
|
||||
|
||||
<!-- Подпись Go инсталляторов -->
|
||||
|
||||
+10
-7
@@ -120,16 +120,19 @@ public class LaunchCommandBuilder {
|
||||
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;
|
||||
command.add("-Xmx" + ramMB + "M");
|
||||
command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
command.add("-XX:+UseG1GC");
|
||||
command.add("-XX:+UnlockExperimentalVMOptions");
|
||||
command.add("-XX:G1NewSizePercent=20");
|
||||
command.add("-XX:G1ReservePercent=20");
|
||||
command.add("-XX:MaxGCPauseMillis=50");
|
||||
command.add("-XX:G1HeapRegionSize=32M");
|
||||
if (!useSystemGc) {
|
||||
command.add("-XX:+UseG1GC");
|
||||
command.add("-XX:+UnlockExperimentalVMOptions");
|
||||
command.add("-XX:G1NewSizePercent=20");
|
||||
command.add("-XX:G1ReservePercent=20");
|
||||
command.add("-XX:MaxGCPauseMillis=50");
|
||||
command.add("-XX:G1HeapRegionSize=32M");
|
||||
}
|
||||
|
||||
// Append custom user JVM args
|
||||
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; }
|
||||
Path modsDir = inst.getPath().resolve("mods");
|
||||
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;
|
||||
if (enable) {
|
||||
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 {
|
||||
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; }
|
||||
}
|
||||
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));
|
||||
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
|
||||
<properties>
|
||||
<revision>1.1.1</revision>
|
||||
<hotfix>2</hotfix>
|
||||
<hotfix>5</hotfix>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<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
|
||||
manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
BUILDS_DIR = Path(__file__).parent / "builds"
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
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}")
|
||||
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."""
|
||||
@@ -1813,6 +1839,49 @@ async def download_jre(request: Request):
|
||||
return await send_file_async(pp, request, content_type="application/zip", cache=True)
|
||||
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}")
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
|
||||
@@ -1304,6 +1304,21 @@ ul { list-style: none; }
|
||||
.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) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
+147
-2
@@ -33,6 +33,12 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,11 +389,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<span>Online Setup (5.8М)</span>
|
||||
</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>
|
||||
<span>Offline ZIP (98М)</span>
|
||||
</a>
|
||||
@@ -400,6 +406,79 @@
|
||||
<div class="download-mirrors" id="mirrors">
|
||||
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
|
||||
</div>
|
||||
|
||||
<!-- Git widget (launcher repo) -->
|
||||
<div class="github-widget" id="github-widget" 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">
|
||||
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);text-decoration:none">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.51 2.87 8.33 6.84 9.68.5.09.68-.22.68-.48v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.1-1.5-1.1-1.5-.9-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.05a9.2 9.2 0 0 1 5 0c1.9-1.32 2.74-1.05 2.74-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.07.36.32.68.94.68 1.9v2.82c0 .26.18.58.69.48A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/></svg>
|
||||
sasheg/launcher
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--text-secondary);font-weight:500">git.swe.zernmc.ru</span>
|
||||
</a>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)" id="github-stats">
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> ветка <strong id="gh-branch" style="color:var(--text)">main</strong></span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <span id="gh-version">v1.1.1.2</span></span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11v2"/><path d="M17 11v2"/></svg> <span id="gh-commit">19a1633</span></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" class="btn btn-ghost" style="flex:1;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> Открыть в Gitea</a>
|
||||
</div>
|
||||
<!-- Languages bar like GitHub -->
|
||||
<div style="margin-top:4px">
|
||||
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border);gap:1px" id="lang-bar" title="Java 49.4% · Python 25.4% · JavaScript 12.7% · CSS 7.3% · HTML 3.5% · Go 1.7%">
|
||||
<div style="flex:49.4;background:#b07219" title="Java 49.4%"></div>
|
||||
<div style="flex:25.4;background:#3572A5" title="Python 25.4%"></div>
|
||||
<div style="flex:12.7;background:#f1e05a" title="JavaScript 12.7%"></div>
|
||||
<div style="flex:7.3;background:#563d7c" title="CSS 7.3%"></div>
|
||||
<div style="flex:3.5;background:#e34c26" title="HTML 3.5%"></div>
|
||||
<div style="flex:1.7;background:#00ADD8" title="Go 1.7%"></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:12px;color:var(--text-secondary)" id="lang-legend">
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#b07219;display:inline-block"></i> Java 49.4%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#3572A5;display:inline-block"></i> Python 25.4%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#f1e05a;display:inline-block"></i> JavaScript 12.7%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#563d7c;display:inline-block"></i> CSS 7.3%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#e34c26;display:inline-block"></i> HTML 3.5%</span>
|
||||
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#00ADD8;display:inline-block"></i> Go 1.7%</span>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:6px">32 120 строк · Java 15 858 · Python 8 172 · JS 4 083 · CSS 2 345 · HTML 1 110 · Go 552</p>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
|
||||
@@ -432,6 +511,72 @@
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+317
-6
@@ -111,6 +111,24 @@
|
||||
'preview.online': 'Онлайн',
|
||||
'preview.install.title': 'Установка сборки…',
|
||||
'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.sub': 'Последние чейнджлоги и обновления команды Zern.',
|
||||
'news.empty': 'Новостей пока нет.',
|
||||
@@ -225,6 +243,24 @@
|
||||
'preview.online': 'Online',
|
||||
'preview.install.title': 'Installing pack…',
|
||||
'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.sub': 'Latest changelogs and updates from the Zern team.',
|
||||
'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
|
||||
try {
|
||||
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);
|
||||
// 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){}
|
||||
}
|
||||
if (dlOffline) dlOffline.href = API + '/launcher/download/offline-setup';
|
||||
if (dlOffline && data.new_format && data.new_format.download_url) {
|
||||
dlOffline.href = API + data.new_format.download_url;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load launcher info', e);
|
||||
@@ -719,6 +751,281 @@
|
||||
if (el) el.textContent = new Date().getFullYear();
|
||||
}
|
||||
|
||||
async function loadGithubLanguages() {
|
||||
const bar = document.getElementById('lang-bar');
|
||||
const legend = document.getElementById('lang-legend');
|
||||
if (!bar || !legend) return;
|
||||
const colors = { Java:'#b07219', Python:'#3572A5', JavaScript:'#f1e05a', TypeScript:'#3178c6', CSS:'#563d7c', HTML:'#e34c26', Go:'#00ADD8', Rust:'#dea584', Shell:'#89e051', Dockerfile:'#384d54' };
|
||||
const tryFetch = async (url) => {
|
||||
const r = await fetch(url, {headers:{'Accept':'application/vnd.github.v3+json'}});
|
||||
if (!r.ok) throw new Error(url);
|
||||
return r.json();
|
||||
};
|
||||
try {
|
||||
let data = null;
|
||||
try { data = await tryFetch('https://git.swe.zernmc.ru/api/v1/repos/sasheg/launcher/languages'); } catch(e) {
|
||||
// Gitea fallback only, GitHub mirror removed
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const total = Object.values(data).reduce((a,b)=>a+b,0);
|
||||
if (!total) return;
|
||||
const entries = Object.entries(data).map(([lang, bytes])=>({lang, bytes, pct: bytes/total*100})).sort((a,b)=>b.bytes-a.bytes).slice(0,6);
|
||||
// update bar
|
||||
bar.innerHTML = '';
|
||||
bar.title = entries.map(e=>`${e.lang} ${e.pct.toFixed(1)}%`).join(' · ');
|
||||
legend.innerHTML = '';
|
||||
entries.forEach(e=>{
|
||||
const seg = document.createElement('div');
|
||||
seg.style.flex = String(e.pct);
|
||||
seg.style.background = colors[e.lang] || 'var(--accent)';
|
||||
seg.title = `${e.lang} ${e.pct.toFixed(1)}%`;
|
||||
bar.appendChild(seg);
|
||||
const item = document.createElement('span');
|
||||
item.style.display='flex'; item.style.alignItems='center'; item.style.gap='6px';
|
||||
item.innerHTML = `<i style="width:10px;height:10px;border-radius:50%;background:${colors[e.lang]||'var(--accent)'};display:inline-block"></i> ${e.lang} ${e.pct.toFixed(1)}%`;
|
||||
legend.appendChild(item);
|
||||
});
|
||||
} 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', () => {
|
||||
applyI18n();
|
||||
|
||||
@@ -730,11 +1037,15 @@
|
||||
loadNews();
|
||||
loadServerStatus();
|
||||
setInterval(loadServerStatus, 15000);
|
||||
loadGithubLanguages();
|
||||
initCarousel();
|
||||
initPreview();
|
||||
initNav();
|
||||
initParallax();
|
||||
initReveal();
|
||||
initYear();
|
||||
initAuth();
|
||||
// if already authed, preload cabinet data
|
||||
if (getAuth()) loadCabinetProfile();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user