14 Commits

Author SHA1 Message Date
SashegDev 025a21d01f site: add launcher-style auth (login/register modal, JWT localStorage) + cabinet with Сборки tab (list /packs, download /pack/{name}/zip with Bearer, pass check, manual Prologue intercept) 2026-09-02 04:35:57 +00:00
SashegDev 146a56cdff fix: pack zip route shadowing (zip before file, 404 -> 403), manual ZernPrologue specify NeoForge 21.1.228 with warning 2026-09-02 04:30:44 +00:00
SashegDev 3006408447 site+server: add manual Zern Prologue pack variant (download zip + instructions) — site card with path %USERPROFILE%\.zernmc\instances\ZernPrologue, prism/multimc import, endpoint /pack/ZernPrologue/zip (requires pass) 2026-09-02 00:57:43 +00:00
SashegDev 48f73ef0d4 revert: Go absolute installers GUI bootstrap-Inno 1.1.1.5
- installer/main.go: Go absolute GUI wizard (Welcome->Dir->Progress->Finish) via dialog, bootstrap colors, embed offline.zip for offline 104M standalone, online 5.8M downloads JRE+meta full files
- go.mod: +walk/win for Inno-like wizard (Go 1.21, sys 0.28)
- pom hotfix 5, Go build: online small (empty offline.zip) + offline large (embed ZernMC-win-*.zip), both signed, Content-Disposition kept
2026-09-01 15:29:12 +00:00
SashegDev b864e17921 feat: 1.1.1.4 GUI bootstrap-Inno installer + standalone offline
- Installer.java: Swing dark Bootstrap (bg 0c0c12, accent e94560) Inno wizard (sidebar steps, Welcome->Dir->Progress->Finish), JRE + meta download via /launcher/file, offline via embedded /offline.zip + appended ZIP + adjacent fallback, shortcut/uninstall
- pom: hotfix 4, launch4j 2 exe ZernMC-Online/Offline-Setup-1.1.1.4.exe (icon, versionInfo, header gui, jar shaded), offline.zip resource
- server/main.py: Content-Disposition attachment filename for online/offline, BUILDS_DIR absolute
- Builds: Online 6.3M (downloads), Offline 103M (standalone embedded 98M zip), both signed, site headers fixed
2026-09-01 14:14:30 +00:00
SashegDev 1900db3caf fix: 1.1.1.3 online installer 5.8M + mods toggle + ZGC vs G1GC conflict
- server/main.py BUILDS_DIR absolute + /launcher/download/online,offine-setup
- site index/js online->/download/online 5.8M
- LaunchCommandBuilder skip G1GC when systemBasedJvm (ZGC conflict -> Bootstrap crash)
- JFXLauncher toggle robust rename + FileAlreadyExists fix
2026-09-01 13:51:30 +00:00
SashegDev 5944c7b1b6 site: switch git widget branch ui -> main (19a1633) for main merge 2026-09-01 06:49:40 +00:00
SashegDev 19a1633ec4 Merge ui -> main: 1.1.1.2 hybrid Go installer + hollow diamonds + custom packs + mods + onboarding + git widget 2026-09-01 06:49:26 +00:00
SashegDev 75738ae127 site: remove GitHub mirror button from git widget, keep only Gitea (languages fetch now Gitea-only) 2026-09-01 06:40:20 +00:00
SashegDev 5985887fb8 site: add GitHub-like language bar (Java 49.4% Python 25.4% JS 12.7% CSS 7.3% HTML 3.5% Go 1.7% — 32k loc) + live fetch from GitHub/Gitea API 2026-09-01 06:39:06 +00:00
SashegDev 8d5bcca1d5 site: add launcher git widget (Gitea sasheg/launcher ui/bcd10cc v1.1.1.2 + GitHub mirror, clone cmd) 2026-09-01 06:36:05 +00:00
SashegDev bcd10cce06 feat: 1.1.1.2 hybrid Go installer (online 5.8M/offline 98M, выбор C:\ZernMC + ярлык ZernMC Launcher.lnk, uninstall.exe, DataDir ~/.zernmc) — hollow-ромбы, custom-сборки, моды с правами, ПКМ без Reload, sticky Играть, онбординг Config persist + /launcher/download/jre 2026-09-01 06:35:28 +00:00
SashegDev 592de204b6 site: unify section indices, show current lang, smarter highlight carousel
- Section badges are now consistently 'N / 07' everywhere (was mixing 06/07)
  and get a localized title tooltip explaining 'Section N of 7'.
- Language toggle now shows the CURRENT language (RU/EN) instead of the
  next one, so the state is obvious before clicking.
- Carousel: manual navigation (arrows/dots) holds the slide 22s before
  resuming, idle auto-advance returns to 6.5s; video slides no longer loop
  and only advance when playback actually ends.
2026-09-01 06:35:28 +00:00
SashegDev af415157ac Merge pull request 'Слияние ui -> main' (#1) from ui into main
Reviewed-on: #1
2026-08-20 13:54:02 +00:00
17 changed files with 2067 additions and 94 deletions
+24 -16
View File
@@ -21,15 +21,21 @@
## Состав дистрибутива ## Состав дистрибутива
В архиве `ZernMC-win-<версия>.zip`: **Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
- Качает `JRE 47М``lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
- Локальные файлы `~/.zernmc` не трогает
- `zernmc.exe`основная версия с GUI (JavaFX) **Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
- `zernmc-cli.exe` — консольная версия (TUI)
- `zernmc-cli-jfx.exe` — консоль + JavaFX UI
- `lib/jre21` — встроенный JRE 21
- `lib/javafx` — модули JavaFX 23.0.1
Bootstrap-модуль встроен прямо в `.exe`, отдельный jar рядом не нужен. - `zernmc.exe` — GUI (JavaFX)
- `zernmc-cli.exe` — TUI
- `zernmc-cli-jfx.exe` — консоль + JavaFX
- `lib/jre21` — JRE 21 (только оффлайн)
- `lib/javafx` — JavaFX 23.0.1
Bootstrap встроен в `.exe`.
## Чего пока нет в лаунчере ## Чего пока нет в лаунчере
@@ -86,17 +92,19 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j
## Как скачать и запустить ## Как скачать и запустить
Скачать последнюю версию можно по ссылкам: **Скачать лаунчер (выбери один):**
- **Онлайн-установщик (рекомендуется)** — `ZernMC-Online-Setup.exe` (~5.7М, Go, без Java): скачай → выбери папку установки (по умолчанию `C:\ZernMC`) → [x] Создать ярлык ZernMC Launcher на рабочем столе → Установить. Первый запуск докачает JRE 47М + файлы лаунчера 50М. Данные сборок `~/.zernmc` остаются в профиле пользователя. Деинсталляция — `C:\ZernMC\uninstall.exe` или удаление папки.
- **Оффлайн ZIP** — `ZernMC-win-<версия>.zip` (98М, с JRE): скачай → выбери папку куда распаковать (диалог в `ZernMC-Offline-Setup.exe` или вручную) → [x] Ярлык → готово. Для air-gapped.
**Скачать лаунчер:** **Зеркала (оба варианта):**
- https://api.zernmc.ru/launcher/download/latest - https://api.zern.cc/launcher/download/jre — JRE для онлайн
- https://api.zernmc.online/launcher/download/latest - https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP
**Инструкция:** **Инструкция (оба):**
1. Скачайте zip-архив 1. Скачай online exe **или** offline zip
2. Распакуйте в удобную папку (например `C:\ZernMC`) 2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима) 3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
4. Войдите в аккаунт и нажмите **«Начать игру»** 4. Войди в аккаунт и нажми **«Начать игру»**
### Установка первой сборки ### Установка первой сборки
+12
View File
@@ -0,0 +1,12 @@
module zernmc-installer
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
)
+13
View File
@@ -0,0 +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=
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"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"
)
//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 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"
)
func main() {
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 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 {
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 }
return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
}
func fatal(format string, args ...interface{}) {
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, 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)...")
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 {
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)
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 fetchMeta() (*Meta, error) {
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)
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()
}
func createUninstaller(dir string) error {
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() }
+38
View File
@@ -402,6 +402,44 @@ How to use:
basedir="../../server/builds" basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt" includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/> excludes="build.version,*.jar"/>
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false">
<arg value="-c"/>
<arg value="if command -v /usr/local/go/bin/go >/dev/null 2>&amp;1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go online build failed'; cd ../launcher/launcher; echo 'Building offline with embedded ZIP...'; cp ../../server/builds/ZernMC-win-${revision}.${hotfix}.zip ../../installer/offline.zip 2>/dev/null || cp ../../server/builds/ZernMC-win-1.1.1.4.zip ../../installer/offline.zip 2>/dev/null || true; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; fi"/>
</exec>
<!-- Подпись Go инсталляторов -->
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe"/>
</exec>
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe"/>
</exec>
</target> </target>
</configuration> </configuration>
</execution> </execution>
@@ -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()) {
@@ -447,7 +447,7 @@ public class JFXLauncher extends Application {
startServer(); startServer();
WebView webView = new WebView(); WebView webView = new WebView();
webView.setContextMenuEnabled(false); webView.setContextMenuEnabled(true);
WebEngine engine = webView.getEngine(); WebEngine engine = webView.getEngine();
engine.setJavaScriptEnabled(true); engine.setJavaScriptEnabled(true);
@@ -508,6 +508,9 @@ public class JFXLauncher extends Application {
" req.open('POST', '/api/open-url', false);" + " req.open('POST', '/api/open-url', false);" +
" req.send(JSON.stringify({url: target.href}));" + " req.send(JSON.stringify({url: target.href}));" +
" }" + " }" +
"}, true);" +
"document.addEventListener('contextmenu', function(e) {" +
" e.preventDefault();" +
"}, true);" "}, true);"
); );
} }
@@ -607,6 +610,12 @@ public class JFXLauncher extends Application {
server.createContext("/api/playtime/stats", this::handlePlaytimeStats); server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/api/whitelist/mods", this::handleWhitelistMods); server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
server.createContext("/api/mods/toggle", this::handleModsToggle);
server.createContext("/api/mods/delete", this::handleModsDelete);
server.createContext("/api/pack/open-folder", this::handlePackOpenFolder);
server.createContext("/api/pack/settings", this::handlePackSettings);
server.createContext("/api/pack/delete", this::handlePackDelete);
server.createContext("/api/filemanager", this::handleFileManager);
server.createContext("/api/admin", this::handleAdmin); server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
@@ -1250,14 +1259,55 @@ public class JFXLauncher extends Application {
Path dir = instance.getPath(); Path dir = instance.getPath();
Map<String, Object> info = new HashMap<>(); Map<String, Object> info = new HashMap<>();
int modsCount = 0; List<Map<String, Object>> modsList = new ArrayList<>();
Path modsDir = dir.resolve("mods"); Path modsDir = dir.resolve("mods");
if (Files.exists(modsDir)) { if (Files.exists(modsDir)) {
try (var files = Files.list(modsDir)) { try (var files = Files.list(modsDir)) {
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count(); files.forEach(p -> {
} String fn = p.getFileName().toString();
boolean isJar = fn.endsWith(".jar");
boolean isDisabled = fn.endsWith(".jar.disabled");
if (!isJar && !isDisabled) return;
String display = isDisabled ? fn.substring(0, fn.length()-9) : fn;
Map<String, Object> m = new HashMap<>();
m.put("fileName", fn);
// try parse mod metadata
String modId = null, modName = null, modVer = null;
try (var zf = new java.util.jar.JarFile(p.toFile())) {
var e1 = zf.getEntry("fabric.mod.json");
if (e1 != null) {
String json = new String(zf.getInputStream(e1).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var jo = new org.json.JSONObject(json);
modId = jo.optString("id", null);
modName = jo.optString("name", null);
modVer = jo.optString("version", null);
} else {
var e2 = zf.getEntry("META-INF/mods.toml");
if (e2 == null) e2 = zf.getEntry("META-INF/neoforge.mods.toml");
if (e2 == null) e2 = zf.getEntry("mods.toml");
if (e2 != null) {
String toml = new String(zf.getInputStream(e2).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var mid = java.util.regex.Pattern.compile("modId\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (mid.find()) modId = mid.group(1);
var dname = java.util.regex.Pattern.compile("displayName\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (dname.find()) modName = dname.group(1);
var ver = java.util.regex.Pattern.compile("version\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (ver.find()) modVer = ver.group(1);
}
}
} catch (Exception ignored) {}
m.put("displayName", modName != null ? modName : (modId != null ? modId : display));
m.put("modId", modId != null ? modId : display.replace(".jar",""));
m.put("version", modVer != null ? modVer : "?");
m.put("enabled", !isDisabled);
try { m.put("size", Files.size(p)); } catch (Exception e) { m.put("size", 0L); }
modsList.add(m);
});
} catch (Exception ignored) {}
modsList.sort((a,b) -> String.valueOf(a.get("displayName")).compareToIgnoreCase(String.valueOf(b.get("displayName"))));
} }
info.put("modsCount", modsCount); info.put("modsCount", modsList.size());
info.put("mods", modsList);
List<String> screenshots = new ArrayList<>(); List<String> screenshots = new ArrayList<>();
Path screenshotsDir = dir.resolve("screenshots"); Path screenshotsDir = dir.resolve("screenshots");
@@ -1327,6 +1377,12 @@ public class JFXLauncher extends Application {
if (body.containsKey("systemBasedJvm")) { if (body.containsKey("systemBasedJvm")) {
Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm"))); Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm")));
} }
if (body.containsKey("onboardedVersion")) {
Config.setOnboardedVersion(body.get("onboardedVersion"));
}
if (body.containsKey("completedOnboardBlocks")) {
Config.setCompletedOnboardBlocks(body.get("completedOnboardBlocks"));
}
Map<String, Object> res = new HashMap<>(); Map<String, Object> res = new HashMap<>();
res.put("success", true); res.put("success", true);
res.put("maxMemory", Config.getMaxMemory()); res.put("maxMemory", Config.getMaxMemory());
@@ -1343,6 +1399,8 @@ public class JFXLauncher extends Application {
data.put("javaPath", Config.getJavaPath()); data.put("javaPath", Config.getJavaPath());
data.put("locale", Config.getLocale()); data.put("locale", Config.getLocale());
data.put("systemBasedJvm", Config.isSystemBasedJvm()); data.put("systemBasedJvm", Config.isSystemBasedJvm());
data.put("onboardedVersion", Config.getOnboardedVersion());
data.put("completedOnboardBlocks", Config.getCompletedOnboardBlocks());
data.put("cpuCores", Config.getSystemCpuCores()); data.put("cpuCores", Config.getSystemCpuCores());
data.put("totalRamMB", Config.getSystemTotalRamMB()); data.put("totalRamMB", Config.getSystemTotalRamMB());
data.put("systemJvmFlags", Config.getSystemJvmFlags()); data.put("systemJvmFlags", Config.getSystemJvmFlags());
@@ -1785,6 +1843,162 @@ public class JFXLauncher extends Application {
} }
} }
private void handleModsToggle(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
String enStr = body.get("enable");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
boolean enable = enStr == null || enStr.equals("true");
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); 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 src = modsDir.resolve(file);
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; }
}
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())); }
}
private void handleModsDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot delete mods in server pack")); return; }
Path target = inst.getPath().resolve("mods").resolve(file);
if (!Files.exists(target)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
Files.delete(target);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackOpenFolder(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
Path dir = inst.getPath();
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().open(dir.toFile());
} else {
String os = System.getProperty("os.name").toLowerCase();
String cmd = os.contains("win") ? "explorer" : os.contains("mac") ? "open" : "xdg-open";
new ProcessBuilder(cmd, dir.toString()).start();
}
sendJson(exchange, Map.of("success", true));
} catch (Exception e) {
// fallback xdg
try { new ProcessBuilder("xdg-open", dir.toString()).start(); sendJson(exchange, Map.of("success", true)); }
catch (Exception e2) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackSettings(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String newName = body.get("newName");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
if (name == null) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot edit server pack")); return; }
boolean renamed = false;
if (newName != null && !newName.isBlank() && !newName.equals(name)) {
if (newName.matches(".*[\\\\/:*?\"<>|].*") || newName.length() > 32) { sendJson(exchange, Map.of("success", false, "error", "Invalid name")); return; }
if (InstanceManager.getInstance(newName) != null) { sendJson(exchange, Map.of("success", false, "error", "Name already exists")); return; }
Path src = inst.getPath();
Path dst = src.getParent().resolve(newName);
Files.move(src, dst);
// need to update instance path internally - recreate
inst = InstanceManager.getInstance(newName);
renamed = true;
}
Instance target = renamed ? InstanceManager.getInstance(newName != null ? newName : name) : inst;
if (loader != null && !loader.isBlank()) {
target.setLoaderType(loader);
if (loaderVersion != null) target.setLoaderVersion(loaderVersion);
// if loader changed, reinstall loader libs
if (!"vanilla".equalsIgnoreCase(loader) && loaderVersion != null && !loaderVersion.isBlank()) {
// trigger install in background? For now just save meta; user will reinstall manually via manage
}
}
sendJson(exchange, Map.of("success", true, "renamed", renamed));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
boolean ok = InstanceManager.deleteInstance(name);
sendJson(exchange, Map.of("success", ok, "error", ok ? "" : "Delete failed"));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handleFileManager(HttpExchange exchange) {
try {
String os = System.getProperty("os.name").toLowerCase();
String fm;
if (os.contains("win")) fm = "Explorer";
else if (os.contains("mac")) fm = "Finder";
else {
String desktop = System.getenv("XDG_CURRENT_DESKTOP");
if (desktop == null) desktop = System.getenv("DESKTOP_SESSION");
if (desktop != null) {
String d = desktop.toLowerCase();
if (d.contains("gnome")) fm = "Nautilus";
else if (d.contains("kde")) fm = "Dolphin";
else if (d.contains("xfce")) fm = "Thunar";
else fm = "File Manager ("+desktop+")";
} else fm = "File Manager";
}
sendJson(exchange, Map.of("success", true, "data", Map.of("name", fm, "os", os)));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
// ====================== ADMIN ====================== // ====================== ADMIN ======================
private void handleAdmin(HttpExchange exchange) { private void handleAdmin(HttpExchange exchange) {
@@ -22,6 +22,8 @@ public class Config {
private static volatile boolean ramManuallySet = false; private static volatile boolean ramManuallySet = false;
private static volatile String locale = "en"; private static volatile String locale = "en";
private static volatile boolean systemBasedJvm = false; private static volatile boolean systemBasedJvm = false;
private static volatile String onboardedVersion = "";
private static volatile String completedOnboardBlocks = "";
static { static {
load(); load();
@@ -61,6 +63,8 @@ public class Config {
javaPath = props.getProperty("javaPath", "java"); javaPath = props.getProperty("javaPath", "java");
locale = props.getProperty("locale", "en"); locale = props.getProperty("locale", "en");
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false")); systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
onboardedVersion = props.getProperty("onboardedVersion", "");
completedOnboardBlocks = props.getProperty("completedOnboardBlocks", "");
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage()); System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
@@ -79,6 +83,8 @@ public class Config {
props.setProperty("javaPath", javaPath); props.setProperty("javaPath", javaPath);
props.setProperty("locale", locale); props.setProperty("locale", locale);
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm)); props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
props.setProperty("onboardedVersion", onboardedVersion);
props.setProperty("completedOnboardBlocks", completedOnboardBlocks);
try (var os = Files.newOutputStream(CONFIG_FILE)) { try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration"); props.store(os, "ZernMC Launcher Configuration");
@@ -266,4 +272,9 @@ public class Config {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB"; return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
} }
public static String getOnboardedVersion() { return onboardedVersion; }
public static void setOnboardedVersion(String v) { onboardedVersion = v != null ? v : ""; save(); }
public static String getCompletedOnboardBlocks() { return completedOnboardBlocks; }
public static void setCompletedOnboardBlocks(String s) { completedOnboardBlocks = s != null ? s : ""; save(); }
} }
+41 -5
View File
@@ -245,6 +245,16 @@
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div> <div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div> </div>
</div> </div>
<div id="local-mods-section" class="mods-section hidden">
<div class="section-header">
<span data-i18n="mods.title">Mods</span>
<span id="mods-count" class="mods-count"></span>
<input type="text" id="mods-search" class="mods-search" placeholder="Search mods..." data-i18n-placeholder="mods.search" oninput="app.filterLocalMods()">
</div>
<div id="local-mods-list" class="mods-list">
<div class="mods-loading" data-i18n="mods.loading">Loading...</div>
</div>
</div>
</div> </div>
</div> </div>
@@ -519,6 +529,15 @@
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button> <button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div> </div>
</div> </div>
<div class="setting-card" id="onboard-settings-card">
<div class="setting-info">
<h4 data-i18n="onboard.showAgain">Show tutorial again</h4>
<p data-i18n="onboard.infoDesc">You can replay the tutorial in Settings.</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="replay-onboard-btn" onclick="app.replayOnboarding()"><span data-i18n="onboard.showAgain">Show tutorial again</span></button>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@@ -570,7 +589,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="modal-tabs"> <div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button> <button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button> <button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span></button>
</div> </div>
<div id="tab-zernmc" class="modal-tab-content active"> <div id="tab-zernmc" class="modal-tab-content active">
@@ -590,11 +609,28 @@
</div> </div>
<div id="tab-custom" class="modal-tab-content"> <div id="tab-custom" class="modal-tab-content">
<div class="disabled-tab"> <div class="field">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <label data-i18n="install.custom.name">Pack Name</label>
<h3 data-i18n="install.custom.unavailable">Not available yet</h3> <input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
</div> </div>
<div class="field">
<label data-i18n="install.custom.mcVersion">Minecraft Version</label>
<select id="mc-version-select"><option>Loading...</option></select>
</div>
<div class="field">
<label data-i18n="install.custom.loader">Loader</label>
<select id="loader-select">
<option value="vanilla">Vanilla</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
<div class="field hidden" id="loader-ver-field">
<label data-i18n="install.custom.loaderVersion">Loader Version</label>
<select id="loader-ver-select"><option data-i18n="select.selectMCFirst">Select MC version first</option></select>
</div>
<button id="install-custom-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div> </div>
<div id="install-progress" class="install-progress hidden"> <div id="install-progress" class="install-progress hidden">
+665 -36
View File
@@ -66,6 +66,10 @@ const LOCALES = {
'install.serverPack.label': 'Server Pack', 'install.serverPack.label': 'Server Pack',
'install.localName.label': 'Local Name', 'install.localName.label': 'Local Name',
'install.downloadBtn': 'Download & Install', 'install.downloadBtn': 'Download & Install',
'install.custom.name': 'Pack Name',
'install.custom.mcVersion': 'Minecraft Version',
'install.custom.loader': 'Loader',
'install.custom.loaderVersion': 'Loader Version',
'install.custom.unavailable': 'Not available yet', 'install.custom.unavailable': 'Not available yet',
'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.', 'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.',
'install.progress.installing': 'Installing...', 'install.progress.installing': 'Installing...',
@@ -164,6 +168,18 @@ const LOCALES = {
'whitelist.done': 'Mods installed!', 'whitelist.done': 'Mods installed!',
'whitelist.selectNone': 'Select mods first', 'whitelist.selectNone': 'Select mods first',
'whitelist.installError': 'Failed to install mod', 'whitelist.installError': 'Failed to install mod',
'mods.title': 'Mods',
'mods.loading': 'Loading mods...',
'mods.empty': 'No mods installed',
'mods.search': 'Search mods...',
'mods.enabled': 'Enabled',
'mods.disabled': 'Disabled',
'mods.delete': 'Delete',
'mods.enable': 'Enable',
'mods.disable': 'Disable',
'mods.noResults': 'No mods found',
'mods.deleteConfirm': 'Delete {name}?',
'mods.count': '{count} mods',
'nav.admin': 'Admin', 'nav.admin': 'Admin',
'admin.title': 'Admin Panel', 'admin.title': 'Admin Panel',
'admin.clients': 'Clients', 'admin.clients': 'Clients',
@@ -211,6 +227,42 @@ const LOCALES = {
'install.preset.label': 'Preset (optional)', 'install.preset.label': 'Preset (optional)',
'install.preset.default': 'Default', 'install.preset.default': 'Default',
'pack.disabled': 'Disabled', 'pack.disabled': 'Disabled',
'ctx.openFolder': 'Open in files',
'ctx.settings': 'Settings',
'ctx.delete': 'Delete',
'ctx.deleteConfirm': 'Delete pack {name}? This will delete all files.',
'ctx.openLogs': 'Open logs',
'ctx.name': 'Name',
'ctx.loader': 'Loader',
'ctx.loaderVersion': 'Loader version',
'ctx.save': 'Save',
'ctx.cancel': 'Cancel',
'ctx.filemanager': 'File manager',
'onboard.title.install': 'Where to install packs',
'onboard.desc.install': 'Click + in sidebar or Install pack — choose server or custom.',
'onboard.title.pass': 'Where to activate pass',
'onboard.desc.pass': 'Pass unlocks server packs. Activate in Settings → Activate pass.',
'onboard.title.settings': 'Where settings are',
'onboard.desc.settings': 'Gear at bottom — RAM, resolution, JVM, Java, language, network.',
'onboard.title.manage': 'How to manage a pack',
'onboard.desc.manage': 'Click to open card. Right-click for Open in files / Settings / Delete. Card has Play, Update, delete.',
'onboard.title.mods': 'Mods in pack',
'onboard.desc.mods': 'Server packs: toggle only additional mods. Custom: toggle/delete any mods, search on top.',
'onboard.title.logs': 'Where game logs are',
'onboard.desc.logs': 'Settings → Game Log — live output. Or right-click → Open in files → logs folder.',
'onboard.next': 'Next',
'onboard.back': 'Back',
'onboard.skip': 'Skip',
'onboard.done': 'Got it',
'onboard.progress': 'Step {current} of {total}',
'onboard.showAgain': 'Show tutorial again',
'onboard.neverShow': "Don't show again",
'onboard.confirmTitle': 'Skip tutorial?',
'onboard.confirmDesc': 'Are you sure you want to skip the tutorial?',
'onboard.yes': 'Yes',
'onboard.no': 'No',
'onboard.infoTitle': 'Tutorial',
'onboard.infoDesc': 'You can replay the tutorial in Settings.',
}, },
ru: { ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -277,6 +329,10 @@ const LOCALES = {
'install.serverPack.label': 'Серверная сборка', 'install.serverPack.label': 'Серверная сборка',
'install.localName.label': 'Локальное имя', 'install.localName.label': 'Локальное имя',
'install.downloadBtn': 'Скачать и установить', 'install.downloadBtn': 'Скачать и установить',
'install.custom.name': 'Название сборки',
'install.custom.mcVersion': 'Версия Minecraft',
'install.custom.loader': 'Загрузчик',
'install.custom.loaderVersion': 'Версия загрузчика',
'install.custom.unavailable': 'Пока недоступно', 'install.custom.unavailable': 'Пока недоступно',
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".', 'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
'install.progress.installing': 'Установка...', 'install.progress.installing': 'Установка...',
@@ -375,6 +431,18 @@ const LOCALES = {
'whitelist.done': 'Моды установлены!', 'whitelist.done': 'Моды установлены!',
'whitelist.selectNone': 'Выберите моды', 'whitelist.selectNone': 'Выберите моды',
'whitelist.installError': 'Ошибка установки мода', 'whitelist.installError': 'Ошибка установки мода',
'mods.title': 'Моды',
'mods.loading': 'Загрузка модов...',
'mods.empty': 'Модов не установлено',
'mods.search': 'Поиск модов...',
'mods.enabled': 'Включен',
'mods.disabled': 'Отключен',
'mods.delete': 'Удалить',
'mods.enable': 'Включить',
'mods.disable': 'Выключить',
'mods.noResults': 'Ничего не найдено',
'mods.deleteConfirm': 'Удалить {name}?',
'mods.count': '{count} модов',
'nav.admin': 'Админка', 'nav.admin': 'Админка',
'admin.title': 'Панель администратора', 'admin.title': 'Панель администратора',
'admin.clients': 'Клиенты', 'admin.clients': 'Клиенты',
@@ -422,6 +490,42 @@ const LOCALES = {
'install.preset.label': 'Пресет (опционально)', 'install.preset.label': 'Пресет (опционально)',
'install.preset.default': 'Стандартный', 'install.preset.default': 'Стандартный',
'pack.disabled': 'Отключена', 'pack.disabled': 'Отключена',
'ctx.openFolder': 'Открыть в файлах',
'ctx.settings': 'Настройки',
'ctx.delete': 'Удалить',
'ctx.deleteConfirm': 'Удалить сборку {name}? Это удалит все файлы.',
'ctx.openLogs': 'Открыть логи',
'ctx.name': 'Название',
'ctx.loader': 'Загрузчик',
'ctx.loaderVersion': 'Версия загрузчика',
'ctx.save': 'Сохранить',
'ctx.cancel': 'Отмена',
'ctx.filemanager': 'Файловый менеджер',
'onboard.title.install': 'Где устанавливать сборки',
'onboard.desc.install': 'Нажми + в боковой панели или кнопку «Установить сборку» — выбери серверную или свою кастомную.',
'onboard.title.pass': 'Где активировать проходку',
'onboard.desc.pass': 'Проходка даёт доступ к серверным сборкам. Активируй её в Настройках → Активировать проходку или через серверные сборки.',
'onboard.title.settings': 'Где настройки',
'onboard.desc.settings': 'Шестерёнка внизу — память, разрешение, JVM, Java, язык, сеть. Настрой под своё железо.',
'onboard.title.manage': 'Как управлять сборкой',
'onboard.desc.manage': 'Клик — открыть карточку. ПКМ — открыть в файлах / настройки / удалить. В карточке — Играть, Обновить, удалить, описание.',
'onboard.title.mods': 'Моды в сборке',
'onboard.desc.mods': 'В серверных — управляй только дополнительными модами. В своих — включай/выключай и удаляй любые моды, поиск сверху.',
'onboard.title.logs': 'Где логи игры',
'onboard.desc.logs': 'Настройки → Лог игры — смотри вывод в реальном времени. Или ПКМ → Открыть в файлах → папка logs.',
'onboard.next': 'Далее',
'onboard.back': 'Назад',
'onboard.skip': 'Пропустить',
'onboard.done': 'Понятно',
'onboard.progress': 'Шаг {current} из {total}',
'onboard.showAgain': 'Показать обучение снова',
'onboard.neverShow': 'Не показывать снова',
'onboard.confirmTitle': 'Точно пропустить туториал?',
'onboard.confirmDesc': 'Вы уверены, что хотите пропустить обучение?',
'onboard.yes': 'Да',
'onboard.no': 'Нет',
'onboard.infoTitle': 'Туториал',
'onboard.infoDesc': 'Туториал можно будет пройти в настройках.',
} }
}; };
@@ -485,49 +589,157 @@ class ZernMCLauncher {
} }
} }
// ==================== BACKGROUND ==================== // ==================== BACKGROUND — hollow diamonds ====================
initBg() { initBg() {
const c = document.getElementById('bg-canvas'); const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d'); const ctx = c.getContext('2d');
let t = 0, mode = 'login', modePulse = 0; const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
let mode = 'login'; // login=blue, register=orange
let modeLerp = 0; // 0=login, 1=register
let exitProgress = 0; // 0..1 fly-out
let exitActive = false;
let rafId = 0;
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.setWaveMode = m => { mode = m; modePulse = 1; }; const PALETTES = {
login: {
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; }; far: { r: 59, g: 130, b: 246, a: 0.18 },
window.addEventListener('resize', resize); mid: { r: 59, g: 130, b: 246, a: 0.32 },
near: { r: 96, g: 165, b: 255, a: 0.42 }
const draw = () => { },
ctx.clearRect(0, 0, c.width, c.height); register: {
const w = c.width, h = c.height; far: { r: 249, g: 115, b: 22, a: 0.18 },
modePulse += (0 - modePulse) * 0.03; mid: { r: 249, g: 115, b: 22, a: 0.34 },
near: { r: 255, g: 162, b: 77, a: 0.44 }
const baseAlpha = 0.04 + modePulse * 0.03;
const amp = 20 + modePulse * 10;
const freq = 0.008 + modePulse * 0.003;
const speed = 0.008;
const lines = 5;
for (let i = 0; i < lines; i++) {
const yOff = (h / (lines + 1)) * (i + 1);
const alpha = baseAlpha * (1 - i * 0.12);
ctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
const y = yOff + wave + wave2;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
ctx.lineWidth = 1.2;
ctx.stroke();
} }
t++;
}; };
const anim = () => { draw(); requestAnimationFrame(anim); }; const diamonds = [];
const COUNT = 44;
const seed = () => Math.random();
const lerp = (a,b,t) => a + (b-a)*t;
const lerpColor = (ca, cb, t) => ({
r: lerp(ca.r, cb.r, t),
g: lerp(ca.g, cb.g, t),
b: lerp(ca.b, cb.b, t),
a: lerp(ca.a, cb.a, t)
});
const rebuild = () => {
diamonds.length = 0;
const w = c.width / dpr, h = c.height / dpr;
// distribute with jitter, keep margins so diamonds not clipped
for (let i = 0; i < COUNT; i++) {
const layerRoll = seed();
let layer, size;
if (layerRoll < 0.35) { layer = 'far'; size = 12 + seed()*6; }
else if (layerRoll < 0.70) { layer = 'mid'; size = 22 + seed()*10; }
else { layer = 'near'; size = 36 + seed()*16; }
// avoid center where login form sits (roughly 360x420 centered)
let x, y, attempts = 0;
do {
x = seed() * (w - size*1.4) + size*0.7;
y = seed() * (h - size*1.4) + size*0.7;
attempts++;
} while (attempts < 5 && Math.abs(x - w/2) < 190 && Math.abs(y - h/2) < 220);
diamonds.push({
x, y, ox: x, oy: y,
size,
layer,
rot: 45 * Math.PI/180,
driftX: (seed()-0.5)*0.6,
driftY: 0.15 + seed()*0.45,
phase: seed()*Math.PI*2,
depthMul: layer==='far'?0.15 : layer==='mid'?0.45 : 1.0
});
}
};
const resize = () => {
const w = window.innerWidth, h = window.innerHeight;
c.width = w * dpr; c.height = h * dpr;
c.style.width = w + 'px'; c.style.height = h + 'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
rebuild();
};
window.addEventListener('resize', resize);
// public API
this.setDiamondMode = (m) => { mode = m==='register'?'register':'login'; };
// keep compat
this.setWaveMode = this.setDiamondMode;
this.animateDiamondExit = () => {
if (reduceMotion || exitActive) return Promise.resolve();
exitActive = true;
exitProgress = 0;
return new Promise(res => {
const start = performance.now();
const dur = 900;
const ease = t => t<0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
const step = (now) => {
const p = Math.min(1, (now - start)/dur);
exitProgress = ease(p);
if (p < 1) requestAnimationFrame(step);
else { exitActive = false; res(); }
};
requestAnimationFrame(step);
});
};
let tick = 0;
const draw = () => {
const w = c.width / dpr, h = c.height / dpr;
ctx.clearRect(0, 0, w, h);
// lerp palette between modes
const target = mode === 'register' ? 1 : 0;
modeLerp += (target - modeLerp) * 0.06;
const palFar = lerpColor(PALETTES.login.far, PALETTES.register.far, modeLerp);
const palMid = lerpColor(PALETTES.login.mid, PALETTES.register.mid, modeLerp);
const palNear = lerpColor(PALETTES.login.near, PALETTES.register.near, modeLerp);
// exit parallax offset (vh in px)
const farExit = reduceMotion ? 0 : exitProgress * (h*0.40);
const midExit = reduceMotion ? 0 : exitProgress * (h*0.70);
const nearExit = reduceMotion ? 0 : exitProgress * (h*1.10);
for (const d of diamonds) {
const pal = d.layer==='far'? palFar : d.layer==='mid'? palMid : palNear;
// idle drift
const t = tick * 0.001 + d.phase;
const dx = Math.sin(t * 0.6 + d.ox*0.01) * d.driftX;
const dyBase = reduceMotion ? 0 : (tick * 0.015 * d.depthMul * d.driftY);
// exit offset + breathing scale
const exitOff = d.layer==='far'? farExit : d.layer==='mid'? midExit : nearExit;
const breath = reduceMotion ? 1 : 1 + Math.sin(t*0.8)*0.03;
const x = d.ox + dx;
let y = (d.oy + dyBase - exitOff) % (h + d.size*2);
if (y < -d.size) y += h + d.size*2;
const s = d.size * breath * (exitActive && d.layer==='near' ? (1 + exitProgress*0.06) : 1);
const alpha = pal.a * (exitActive ? (1 - exitProgress*0.7) : 1);
if (alpha < 0.02) continue;
ctx.save();
ctx.translate(x, y);
ctx.rotate(d.rot);
ctx.strokeStyle = `rgba(${Math.round(pal.r)},${Math.round(pal.g)},${Math.round(pal.b)},${alpha})`;
ctx.lineWidth = d.layer==='far'? 1 : d.layer==='mid'? 1.1 : 1.4;
// hollow diamond: centered square
ctx.strokeRect(-s/2, -s/2, s, s);
ctx.restore();
}
tick += 16;
rafId = requestAnimationFrame(draw);
};
// pause when hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelAnimationFrame(rafId);
else draw();
});
resize(); resize();
anim(); draw();
} }
// ==================== API ==================== // ==================== API ====================
@@ -631,6 +843,22 @@ class ZernMCLauncher {
if (r.success) { if (r.success) {
this.state.account = r.data; this.state.account = r.data;
// hollow diamond exit — parallax fly-out
if (this.animateDiamondExit) await this.animateDiamondExit();
const loginScreen = document.getElementById('login-screen');
const loginContainer = document.getElementById('login-container');
if (loginContainer && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
loginContainer.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
loginContainer.style.transform = 'translateY(-110vh) scale(0.92)';
loginContainer.style.opacity = '0';
loginContainer.style.filter = 'blur(6px)';
await new Promise(res => setTimeout(res, 900));
loginContainer.style.transition = '';
loginContainer.style.transform = '';
loginContainer.style.opacity = '';
loginContainer.style.filter = '';
}
if (loginScreen) loginScreen.classList.add('hidden');
this.enterMain(); this.enterMain();
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome'; const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
this.toast(tr(key, null, {username: r.data.username}), 'success'); this.toast(tr(key, null, {username: r.data.username}), 'success');
@@ -639,6 +867,21 @@ class ZernMCLauncher {
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) }); var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) { if (reg.success) {
this.state.account = reg.data; this.state.account = reg.data;
if (this.animateDiamondExit) await this.animateDiamondExit();
const loginScreen2 = document.getElementById('login-screen');
const loginContainer2 = document.getElementById('login-container');
if (loginContainer2 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
loginContainer2.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
loginContainer2.style.transform = 'translateY(-110vh) scale(0.92)';
loginContainer2.style.opacity = '0';
loginContainer2.style.filter = 'blur(6px)';
await new Promise(res => setTimeout(res, 900));
loginContainer2.style.transition = '';
loginContainer2.style.transform = '';
loginContainer2.style.opacity = '';
loginContainer2.style.filter = '';
}
if (loginScreen2) loginScreen2.classList.add('hidden');
this.enterMain(); this.enterMain();
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success'); this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
return; return;
@@ -728,7 +971,8 @@ class ZernMCLauncher {
container.style.transform = ''; container.style.transform = '';
container.style.opacity = ''; container.style.opacity = '';
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login'); if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
}, 150); }, 150);
} }
@@ -739,6 +983,8 @@ class ZernMCLauncher {
} }
showLogin() { showLogin() {
const c = document.getElementById('login-container');
if (c) { c.style.transition=''; c.style.transform=''; c.style.opacity=''; c.style.filter=''; }
document.getElementById('login-screen').classList.remove('hidden'); document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden'); document.getElementById('main-screen').classList.add('hidden');
} }
@@ -771,6 +1017,7 @@ class ZernMCLauncher {
this.startFriendStatusHeartbeat(); this.startFriendStatusHeartbeat();
this.enhanceSelects(); this.enhanceSelects();
this.updateAdminNav(); this.updateAdminNav();
setTimeout(()=> this.checkOnboarding(), 600);
} }
async loadServerPacksList() { async loadServerPacksList() {
@@ -1107,6 +1354,7 @@ class ZernMCLauncher {
`; `;
if (!isDisabled) { if (!isDisabled) {
el.addEventListener('click', () => this.selectPack(inst)); el.addEventListener('click', () => this.selectPack(inst));
el.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCtxMenu(inst, e.clientX, e.clientY); });
} }
targetList.appendChild(el); targetList.appendChild(el);
}); });
@@ -1262,6 +1510,9 @@ class ZernMCLauncher {
} else { } else {
whitelistSection.classList.add('hidden'); whitelistSection.classList.add('hidden');
} }
// Local mods for any pack (server = readOnly except whitelist)
this.loadLocalMods(inst.name, inst.isServerPack);
} }
async loadPackInfo(name) { async loadPackInfo(name) {
@@ -1330,6 +1581,83 @@ class ZernMCLauncher {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>'; btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
this.toast(t('whitelist.done'), 'success'); this.toast(t('whitelist.done'), 'success');
// refresh local mods
this.loadLocalMods(instanceName, true);
}
async loadLocalMods(instanceName, isServerPack) {
const section = document.getElementById('local-mods-section');
const listEl = document.getElementById('local-mods-list');
const countEl = document.getElementById('mods-count');
if (!section || !listEl) return;
section.classList.remove('hidden');
listEl.innerHTML = '<div class="mods-loading">' + t('mods.loading') + '</div>';
const r = await this.req('/pack-info?name=' + encodeURIComponent(instanceName));
if (!r.success || !r.data) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
return;
}
const mods = r.data.mods || [];
// cache for filtering
this._localModsCache = mods;
this._localModsIsServer = !!isServerPack;
this._localModsInstance = instanceName;
if (countEl) countEl.textContent = tr('mods.count', null, {count: String(mods.length)});
this.renderLocalMods(mods, isServerPack);
}
renderLocalMods(mods, isServerPack) {
const listEl = document.getElementById('local-mods-list');
if (!listEl) return;
if (!mods || mods.length === 0) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
return;
}
const q = (document.getElementById('mods-search')?.value || '').toLowerCase();
const filtered = q ? mods.filter(m => (m.displayName||m.fileName||'').toLowerCase().includes(q) || (m.modId||'').toLowerCase().includes(q)) : mods;
if (filtered.length === 0) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.noResults') + '</div>';
return;
}
let html = '';
filtered.forEach(m => {
const sizeStr = m.size > 1048576 ? (m.size/1048576).toFixed(1)+' MB' : Math.ceil(m.size/1024)+' KB';
const enabled = m.enabled !== false;
const badge = enabled ? t('mods.enabled') : t('mods.disabled');
const badgeCls = enabled ? 'mod-badge-enabled' : 'mod-badge-disabled';
const canToggle = !isServerPack; // server: read-only for local mods
const canDelete = !isServerPack; // server: only whitelist mods can be managed
html += '<div class="mod-item' + (enabled?'':' mod-disabled') + '">'
+ '<div class="mod-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg></div>'
+ '<div class="mod-info"><div class="mod-name">' + this.esc(m.displayName || m.fileName) + '</div><div class="mod-meta">' + this.esc(m.modId || m.fileName) + ' · ' + this.esc(m.version || '?') + ' · ' + sizeStr + '</div></div>'
+ '<span class="mod-badge ' + badgeCls + '">' + badge + '</span>'
+ (canToggle ? '<button class="btn-secondary btn-sm" onclick="app.toggleLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\',' + !enabled + ')">' + (enabled? t('mods.disable'):t('mods.enable')) + '</button>' : '')
+ (canDelete ? '<button class="btn-secondary btn-sm btn-danger" onclick="app.deleteLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\')">' + t('mods.delete') + '</button>' : '')
+ '</div>';
});
listEl.innerHTML = html;
}
filterLocalMods() {
if (!this._localModsCache) return;
this.renderLocalMods(this._localModsCache, this._localModsIsServer);
}
async toggleLocalMod(fileName, enable) {
const inst = this._localModsInstance;
if (!inst) return;
const r = await this.req('/mods/toggle', { method:'POST', body: JSON.stringify({instance: inst, file: fileName, enable: enable}) });
if (r.success) { this.toast(enable? t('mods.enabled'):t('mods.disabled'), 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
else this.toast(r.error||'Error', 'error');
}
async deleteLocalMod(fileName) {
const inst = this._localModsInstance;
if (!inst) return;
if (!confirm(tr('mods.deleteConfirm', null, {name: fileName}))) return;
const r = await this.req('/mods/delete', { method:'POST', body: JSON.stringify({instance: inst, file: fileName}) });
if (r.success) { this.toast(t('mods.delete')+': '+fileName, 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
else this.toast(r.error||'Error', 'error');
} }
// ==================== ADMIN ==================== // ==================== ADMIN ====================
@@ -2595,12 +2923,313 @@ class ZernMCLauncher {
} }
} }
replayOnboarding() {
localStorage.removeItem('zern_onboardBlocks');
localStorage.removeItem('zern_onboardVersion');
// force show all blocks
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
this.showOnboarding(BLOCKS, curVer, []);
}
esc(s) { esc(s) {
if (!s) return ''; if (!s) return '';
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
} }
// ==================== CTX MENU (PKM) ====================
async showCtxMenu(inst, x, y) {
this.hideCtxMenu();
const isServer = !!inst.isServerPack;
// fetch fm name once
let fmName = this._fmName;
if (!fmName) {
try { const r = await this.req('/filemanager'); if (r.success) fmName = r.data?.name || 'File Manager'; } catch(e) {}
this._fmName = fmName || 'File Manager';
fmName = this._fmName;
}
const menu = document.createElement('div');
menu.id = 'ctx-menu';
menu.className = 'ctx-menu';
menu.innerHTML = `
<button class="ctx-item" data-act="open"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> ${t('ctx.openFolder')} <span style="margin-left:auto;opacity:0.5;font-size:11px">${this.esc(fmName)}</span></button>
<button class="ctx-item" data-act="settings"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg> ${t('ctx.settings')}</button>
<div class="ctx-sep"></div>
<button class="ctx-item danger" data-act="delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('ctx.delete')}</button>
`;
const backdrop = document.createElement('div');
backdrop.id = 'ctx-backdrop';
backdrop.className = 'ctx-backdrop';
backdrop.addEventListener('click', () => this.hideCtxMenu());
backdrop.addEventListener('contextmenu', (e)=>{ e.preventDefault(); this.hideCtxMenu(); });
document.body.appendChild(backdrop);
document.body.appendChild(menu);
// position with flip
const pad = 8;
let mx = x, my = y;
const rect = menu.getBoundingClientRect();
if (mx + rect.width + pad > window.innerWidth) mx = window.innerWidth - rect.width - pad;
if (my + rect.height + pad > window.innerHeight) my = window.innerHeight - rect.height - pad;
menu.style.left = mx + 'px';
menu.style.top = my + 'px';
menu.querySelector('[data-act="open"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackFolder(inst.name); });
menu.querySelector('[data-act="settings"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackSettings(inst); });
menu.querySelector('[data-act="delete"]').addEventListener('click', () => { this.hideCtxMenu(); this.deletePack(inst.name); });
const onKey = (e)=>{ if(e.key==='Escape') this.hideCtxMenu(); };
document.addEventListener('keydown', onKey, {once:true});
this._ctxKeyHandler = onKey;
}
hideCtxMenu() {
document.getElementById('ctx-menu')?.remove();
document.getElementById('ctx-backdrop')?.remove();
if (this._ctxKeyHandler) { document.removeEventListener('keydown', this._ctxKeyHandler); this._ctxKeyHandler=null; }
}
async openPackFolder(name) {
const r = await this.req('/pack/open-folder', { method:'POST', body: JSON.stringify({name}) });
if (!r.success) this.toast(r.error||'Failed to open folder','error');
}
openPackSettings(inst) {
const isServer = !!inst.isServerPack;
let modal = document.getElementById('pack-settings-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'pack-settings-modal';
modal.className = 'modal-backdrop';
modal.innerHTML = `
<div class="modal">
<div class="modal-head"><h3>${t('ctx.settings')} ${this.esc(inst.name)}</h3><button class="modal-close" id="ps-close">&times;</button></div>
<div class="modal-body pack-settings-form">
<div class="field"><label>${t('ctx.name')}</label><input type="text" id="ps-name" value="${this.esc(inst.name)}" ${isServer?'disabled':''}></div>
<div class="field"><label>${t('ctx.loader')}</label>
<select id="ps-loader" ${isServer?'disabled':''}>
<option value="vanilla" ${inst.loaderType==='vanilla'?'selected':''}>Vanilla</option>
<option value="fabric" ${inst.loaderType==='fabric'?'selected':''}>Fabric</option>
<option value="forge" ${inst.loaderType==='forge'?'selected':''}>Forge</option>
<option value="neoforge" ${inst.loaderType==='neoforge'?'selected':''}>NeoForge</option>
</select>
</div>
<div class="field ${inst.loaderType==='vanilla'?'hidden':''}" id="ps-loader-ver-field"><label>${t('ctx.loaderVersion')}</label><input type="text" id="ps-loader-ver" value="${this.esc(inst.loaderVersion||'')}" placeholder="0.16.0" ${isServer?'disabled':''}></div>
${isServer?'<p style="font-size:12px;color:var(--text-muted)">'+t('mods.disabled')+' — server pack is read-only</p>':''}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px">
<button class="btn-secondary" id="ps-cancel">${t('ctx.cancel')}</button>
<button class="btn-primary" id="ps-save" ${isServer?'disabled':''}>${t('ctx.save')}</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e=>{ if(e.target===modal) modal.remove(); });
document.getElementById('ps-close').addEventListener('click', ()=>modal.remove());
document.getElementById('ps-cancel').addEventListener('click', ()=>modal.remove());
const loaderSel = document.getElementById('ps-loader');
loaderSel?.addEventListener('change', e=>{
const f=document.getElementById('ps-loader-ver-field');
if(e.target.value==='vanilla') f.classList.add('hidden'); else f.classList.remove('hidden');
});
document.getElementById('ps-save').addEventListener('click', async ()=>{
const newName = document.getElementById('ps-name').value.trim();
const loader = document.getElementById('ps-loader').value;
const loaderVer = document.getElementById('ps-loader-ver').value.trim();
const body = {name: inst.name};
if (newName && newName!==inst.name) body.newName=newName;
if (loader) body.loader=loader;
if (loaderVer) body.loaderVersion=loaderVer;
const r = await this.req('/pack/settings', {method:'POST', body: JSON.stringify(body)});
if (r.success) { modal.remove(); this.toast(t('ctx.save')+' ✓','success'); await this.loadInstances(); if(newName) this.state.selectedPack=null; }
else this.toast(r.error||'Error','error');
});
}
async deletePack(name) {
if (!confirm(tr('ctx.deleteConfirm', null, {name}))) return;
const r = await this.req('/pack/delete', {method:'POST', body: JSON.stringify({name})});
if (r.success) { this.toast(t('ctx.delete')+': '+name,'success'); if(this.state.selectedPack?.name===name) this.showEmptyState(); await this.loadInstances(); }
else this.toast(r.error||'Error','error');
}
// ==================== ONBOARDING ====================
async checkOnboarding() {
// show only on first login or version diff, highlight-only — persist via Config + localStorage
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
let storedVer = localStorage.getItem('zern_onboardVersion') || '';
let completed = (localStorage.getItem('zern_onboardBlocks') || '').split(',').filter(Boolean);
try {
const r = await this.req('/settings');
if (r.success && r.data) {
const srvVer = r.data.onboardedVersion || '';
const srvBlocks = (r.data.completedOnboardBlocks || '').split(',').filter(Boolean);
if (!storedVer && srvVer) { storedVer = srvVer; localStorage.setItem('zern_onboardVersion', srvVer); }
if (completed.length===0 && srvBlocks.length) { completed = srvBlocks; localStorage.setItem('zern_onboardBlocks', srvBlocks.join(',')); }
// if local newer, sync to server
if (storedVer && !srvVer) { this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: storedVer, completedOnboardBlocks: completed.join(',')})}); }
}
} catch(e){}
const firstLogin = !storedVer; // no onboard yet
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
let toShow;
if (firstLogin) toShow = BLOCKS;
else {
// version diff: show only blocks added after storedVer
const isNewer = (a,b)=> a.localeCompare(b, undefined, {numeric:true})>0;
toShow = BLOCKS.filter(b=> !completed.includes(b.id) && isNewer(b.addedIn, storedVer));
if (toShow.length===0 && storedVer !== curVer) {
// version bump but all blocks already completed -> no show
localStorage.setItem('zern_onboardVersion', curVer);
return;
}
}
if (toShow.length===0) return;
this.showOnboarding(toShow, curVer, completed);
}
showOnboarding(blocks, curVer, completed) {
let idx = 0;
const overlay = document.createElement('div');
overlay.className = 'onboard-overlay';
overlay.id = 'onboard-overlay';
document.body.appendChild(overlay);
const card = document.createElement('div');
card.className = 'onboard-card';
card.id = 'onboard-card';
document.body.appendChild(card);
const spot = document.createElement('div');
spot.className = 'onboard-spot';
spot.id = 'onboard-spot';
const label = document.createElement('div');
label.className = 'onboard-spot-label';
label.id = 'onboard-spot-label';
document.body.appendChild(spot);
document.body.appendChild(label);
const resolveTarget = (b) => {
const sels = Array.isArray(b.sel) ? b.sel : [b.sel];
for (const s of sels) {
for (const el of document.querySelectorAll(s)) {
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
const visible = r.width>0 && r.height>0 && style.display!=='none' && style.visibility!=='hidden' && el.offsetParent!==null;
if (!visible) continue;
if (el.closest('.hidden')) continue;
// check collapsed accordion
if (el.closest('.pack-list-collapse') && el.closest('.section-collapsible.collapsed')) continue;
return el;
}
}
return null;
};
const ensureView = async (b) => {
if (b.requiresView==='settings' || b.id==='pass' || b.id==='logs') {
this.switchView('settings');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
} else if (b.id==='install' || b.id==='manage' || b.id==='mods') {
this.switchView('packs');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
}
};
const persist = (vers, blocksArr) => {
const v = vers || curVer;
const b = blocksArr.join(',');
localStorage.setItem('zern_onboardBlocks', b);
localStorage.setItem('zern_onboardVersion', v);
// persist to backend Config so WebView localStorage volatility doesn't resurrect tutorial
this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: v, completedOnboardBlocks: b})}).catch(()=>{});
};
const goNext = async () => {
if (document.getElementById('onboard-never')?.checked) {
const all = [...new Set(blocks.map(x=>x.id).concat(completed))];
persist(curVer, all);
closeAll(); return;
}
if (idx===blocks.length-1) {
const newCompleted = [...new Set([...completed, ...blocks.map(x=>x.id)])];
persist(curVer, newCompleted);
closeAll();
} else { idx++; await render(); }
};
const goBack = async () => { if(idx>0){ idx--; await render(); } };
const showConfirm = (onYes) => {
let m = document.createElement('div');
m.className='modal-backdrop'; m.style.zIndex='410';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.confirmTitle')||'Точно пропустить туториал?'}</h3><button class="modal-close" id="c-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.confirmDesc')||'Вы уверены?'}</p><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px"><button class="btn-secondary btn-sm" id="c-no">${t('onboard.no')||'Нет'}</button><button class="btn-primary btn-sm" id="c-yes">${t('onboard.yes')||'Да'}</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#c-close').onclick=close;
m.querySelector('#c-no').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
m.querySelector('#c-yes').onclick=()=>{ close(); onYes(); };
};
const showInfo = () => {
let m=document.createElement('div'); m.className='modal-backdrop'; m.style.zIndex='411';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.infoTitle')||'Туториал'}</h3><button class="modal-close" id="i-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.infoDesc')||'Туториал можно будет пройти в настройках.'}</p><div style="display:flex;justify-content:flex-end;margin-top:16px"><button class="btn-primary btn-sm" id="i-ok">OK</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#i-close').onclick=close;
m.querySelector('#i-ok').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
};
const requestClose = () => {
showConfirm(()=>{ closeAll(); showInfo(); });
};
const render = async () => {
const b = blocks[idx];
await ensureView(b);
card.innerHTML = `
<div class="onboard-progress">${tr('onboard.progress', null, {current:String(idx+1), total:String(blocks.length)})}</div>
<h3>${t(b.titleKey)}</h3>
<p>${t(b.descKey)}</p>
<div class="onboard-actions">
<button class="btn-secondary btn-sm" id="onboard-skip">${t('onboard.skip')}</button>
${idx>0?'<button class="btn-secondary btn-sm" id="onboard-back">'+t('onboard.back')+'</button>':''}
<button class="btn-primary btn-sm" id="onboard-next">${idx===blocks.length-1? t('onboard.done'): t('onboard.next')}</button>
</div>
<label style="display:flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--text-muted)"><input type="checkbox" id="onboard-never"> ${t('onboard.neverShow')}</label>
`;
const el = resolveTarget(b);
if (el) {
const r = el.getBoundingClientRect();
spot.style.left = (r.left-4)+'px';
spot.style.top = (r.top-4)+'px';
spot.style.width = (r.width+8)+'px';
spot.style.height = (r.height+8)+'px';
spot.style.display='block';
label.textContent = t(b.titleKey);
label.style.left = (r.left + r.width/2)+'px';
label.style.top = (r.top)+'px';
label.style.display='block';
el.scrollIntoView({block:'center', behavior:'smooth'});
} else {
spot.style.display='none'; label.style.display='none';
}
document.getElementById('onboard-skip').addEventListener('click', requestClose);
document.getElementById('onboard-next').addEventListener('click', goNext);
const backBtn=document.getElementById('onboard-back');
if(backBtn) backBtn.addEventListener('click', goBack);
};
const closeAll = ()=>{
overlay.remove(); card.remove(); spot.remove(); label.remove();
document.removeEventListener('keydown', escHandler);
};
spot.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
label.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
const escHandler = (e)=>{ if(e.key==='Escape') requestClose(); };
document.addEventListener('keydown', escHandler);
overlay.addEventListener('click', (e)=>{ if(e.target===overlay) requestClose(); });
render();
}
} }
function renderFriendItem(f) { function renderFriendItem(f) {
+53 -3
View File
@@ -29,8 +29,10 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, * { -webkit-user-select: none; user-select: none; } html, body { -webkit-user-select: none; user-select: none; }
input, textarea { -webkit-user-select: text; user-select: text; } .pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
.pack-entry { -webkit-touch-callout: default; }
html { font-size: 14px; } html { font-size: 14px; }
@@ -45,8 +47,11 @@ body {
#bg-canvas { #bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%; position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.08; pointer-events: none; z-index: 0; opacity: 0.14; pointer-events: none;
} }
#login-screen.exiting { pointer-events: none; }
.login-container.exit { will-change: transform, opacity, filter; }
#bg-canvas.bg-exit { will-change: transform; }
#app { position: relative; z-index: 1; height: 100vh; display: flex; } #app { position: relative; z-index: 1; height: 100vh; display: flex; }
@@ -331,6 +336,9 @@ body {
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; } .view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
.view.active { display: flex; } .view.active { display: flex; }
#view-packs { overflow: hidden; }
#view-packs .pack-detail { flex: 1; overflow-y: auto; min-height: 0; }
#view-packs .play-bar { position: sticky; bottom: 0; z-index: 5; flex-shrink: 0; margin-top: 12px; }
.view-header { .view-header {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
@@ -983,3 +991,45 @@ body {
transition: border-color 150ms ease, color 150ms ease; transition: border-color 150ms ease, color 150ms ease;
} }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } .btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
/* ========== LOCAL MODS ========== */
.mods-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
.mods-section .section-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11px; font-weight:600; text-transform:uppercase; color:var(--text-muted); padding:4px; margin-bottom:8px; letter-spacing:0.5px; }
.mods-count { font-size:11px; color:var(--text-secondary); margin-left:auto; }
.mods-search { margin-left:auto; min-width:160px; padding:6px 10px; border-radius:var(--radius-sm); background:var(--bg-surface); border:1px solid var(--border-light); color:var(--text); font-size:12px; outline:none; }
.mods-search:focus { border-color:var(--accent); }
.mods-list { display:flex; flex-direction:column; gap:4px; }
.mods-loading { text-align:center; padding:20px; color:var(--text-muted); font-size:12px; }
.mod-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); background:var(--bg-card); border:1px solid var(--border); transition:var(--transition); }
.mod-item:hover { background:var(--bg-card-hover); }
.mod-item.mod-disabled { opacity:0.6; }
.mod-icon { width:32px; height:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; background:var(--bg-inset); color:var(--text-muted); flex-shrink:0; }
.mod-info { flex:1; min-width:0; }
.mod-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-badge { font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; flex-shrink:0; }
.mod-badge-enabled { background:rgba(74,222,128,0.15); color:var(--success); }
.mod-badge-disabled { background:rgba(248,113,113,0.12); color:var(--error); }
/* ========== CONTEXT MENU (PKM) ========== */
.ctx-menu { position:fixed; z-index:300; min-width:200px; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); box-shadow:0 12px 32px rgba(0,0,0,0.5); padding:4px; display:flex; flex-direction:column; gap:2px; animation:ctxIn 0.12s ease; }
@keyframes ctxIn { from { opacity:0; transform:translateY(4px) scale(0.98); } to { opacity:1; transform:none; } }
.ctx-item { display:flex; align-items:center; gap:10px; padding:8px 12px; border-radius:4px; font-size:13px; color:var(--text-secondary); cursor:pointer; transition:var(--transition); border:none; background:transparent; font-family:var(--font); text-align:left; width:100%; }
.ctx-item:hover { background:var(--bg-card); color:var(--text); }
.ctx-item.danger { color:var(--error); }
.ctx-item.danger:hover { background:rgba(248,113,113,0.08); }
.ctx-sep { height:1px; background:var(--border); margin:2px 0; }
.ctx-backdrop { position:fixed; inset:0; z-index:299; }
/* ========== ONBOARDING SPOTLIGHT ========== */
.onboard-overlay { position:fixed; inset:0; z-index:400; background:rgba(7,7,10,0.72); backdrop-filter:blur(2px); animation:fadeIn 0.2s ease; }
.onboard-card { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:405; max-width:420px; width:90%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.6); animation:floatIn 0.25s ease; }
.onboard-card h3 { font-size:18px; font-weight:700; margin-bottom:8px; }
.onboard-card p { font-size:13px; color:var(--text-secondary); line-height:1.6; margin-bottom:16px; }
.onboard-progress { font-size:11px; color:var(--text-muted); letter-spacing:0.5px; text-transform:uppercase; margin-bottom:12px; }
.onboard-actions { display:flex; gap:8px; justify-content:flex-end; }
.onboard-spot { position:fixed; z-index:401; border:2px solid var(--accent); border-radius:var(--radius-sm); box-shadow:0 0 0 9999px rgba(7,7,10,0.55), 0 0 24px var(--accent-glow); pointer-events:auto; cursor:pointer; transition:all 0.35s ease; }
.onboard-spot-label { position:fixed; z-index:402; background:var(--accent); color:#fff; font-size:12px; font-weight:600; padding:6px 10px; border-radius:999px; pointer-events:auto; cursor:pointer; transform:translate(-50%, -100%); margin-top:-8px; white-space:nowrap; }
/* ========== PACK SETTINGS MODAL ========== */
.pack-settings-form { display:flex; flex-direction:column; gap:16px; }
+2 -2
View File
@@ -19,8 +19,8 @@
</modules> </modules>
<properties> <properties>
<revision>1.1.0</revision> <revision>1.1.1</revision>
<hotfix>0</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>
+85 -1
View File
@@ -43,7 +43,7 @@ logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes # Cache for manifests - expires after 5 minutes
manifest_cache = TTLCache(maxsize=100, ttl=300) manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") BUILDS_DIR = Path(__file__).parent / "builds"
VERSIONS_DIR = BUILDS_DIR / "versions" VERSIONS_DIR = BUILDS_DIR / "versions"
WHITELIST_DIR = Path(__file__).parent / "whitelist" WHITELIST_DIR = Path(__file__).parent / "whitelist"
@@ -1310,6 +1310,32 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
) )
@app.get("/pack/{pack_name}/zip")
async def download_pack_zip(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Download whole pack as zip for manual installation (requires pass)"""
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(403, "Requires active pass")
if is_pack_disabled(pack_name):
raise HTTPException(403, "Pack is disabled")
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise HTTPException(404, "Pack not found")
import tempfile, zipfile
tmp = Path(tempfile.gettempdir()) / f"{pack_name}.zip"
# re-use if fresh (<5 min)
if tmp.exists() and (datetime.utcnow().timestamp() - tmp.stat().st_mtime) < 300:
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
# create zip
def _zip():
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in pack_path.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(pack_path))
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _zip)
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
@app.get("/pack/{pack_name}/file/{file_path:path}") @app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None): async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None):
"""Get a file from a pack. If preset is specified, try preset dir first, fall back to base.""" """Get a file from a pack. If preset is specified, try preset dir first, fall back to base."""
@@ -1798,6 +1824,64 @@ async def download_launcher_exe(request: Request = None):
) )
@app.get("/launcher/download/jre")
async def download_jre(request: Request):
"""Download JRE zip for online installer (47M, Range supported)"""
candidates = [
BUILDS_DIR / ".." / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip", # /root/launcher/OpenJDK...
Path(__file__).parent.parent / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip",
Path("/root/launcher/OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"),
BUILDS_DIR / "jre.zip",
]
for p in candidates:
pp = p.resolve()
if pp.exists() and pp.is_file():
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}") @app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str, request: Request = None): async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive""" """Download specific launcher ZIP archive"""
+15
View File
@@ -1304,6 +1304,21 @@ ul { list-style: none; }
.pv-sb-section { margin-top: 6px; } .pv-sb-section { margin-top: 6px; }
} }
/* ========== Auth & Cabinet (launcher style) ========== */
.hidden { display: none !important; }
.modal-backdrop { position:fixed; inset:0; background:rgba(7,7,10,0.72); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; z-index:100; animation:fadeIn 0.2s ease; }
.modal { background:var(--bg-surface, #0c0c12); border:1px solid var(--border, rgba(255,255,255,0.08)); border-radius:16px; width:90%; max-width:420px; box-shadow:0 20px 60px rgba(0,0,0,0.6); overflow:hidden; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-head h3 { font-size:15px; font-weight:700; }
.modal-close { background:transparent; border:none; color:var(--text-secondary); font-size:20px; cursor:pointer; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:8px; }
.modal-close:hover { background:var(--bg-card); color:var(--text); }
.modal-body { padding:20px; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-size:12px; color:var(--text-secondary); }
.field input { width:100%; padding:10px 12px; background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; color:var(--text); outline:none; }
.field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(233,69,96,0.15); }
.cabinet-tabs { display:flex; gap:8px; margin-bottom:12px; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; } *, *::before, *::after { animation: none !important; transition: none !important; }
html { scroll-behavior: auto; } html { scroll-behavior: auto; }
+161 -5
View File
@@ -33,6 +33,12 @@
<span class="lang-toggle-label" id="langToggleLabel">EN</span> <span class="lang-toggle-label" id="langToggleLabel">EN</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button> </button>
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
</div>
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a> <a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
</div> </div>
</div> </div>
@@ -70,7 +76,7 @@
<section class="section server" id="server"> <section class="section server" id="server">
<div class="section-head"> <div class="section-head">
<span class="section-index">01 / 06</span> <span class="section-index">01 / 07</span>
<h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2> <h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2>
<p class="section-sub" data-i18n="server.sub">ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.</p> <p class="section-sub" data-i18n="server.sub">ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.</p>
</div> </div>
@@ -96,7 +102,7 @@
<section class="section highlights" id="highlights"> <section class="section highlights" id="highlights">
<div class="section-head"> <div class="section-head">
<span class="section-index">02 / 06</span> <span class="section-index">02 / 07</span>
<h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2> <h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2>
<p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p> <p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p>
</div> </div>
@@ -111,7 +117,7 @@
<section class="section features" id="features"> <section class="section features" id="features">
<div class="section-head"> <div class="section-head">
<span class="section-index">03 / 06</span> <span class="section-index">03 / 07</span>
<h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2> <h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2>
<p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p> <p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p>
</div> </div>
@@ -382,13 +388,97 @@
<p class="download-desc">Windows · <span id="download-size"></span></p> <p class="download-desc">Windows · <span id="download-size"></span></p>
</div> </div>
</div> </div>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg> <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/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>
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:10px;text-align:center">Online — выбери папку (по умолчанию C:\ZernMC) + ярлык ZernMC Launcher на рабочем столе · Offline — выбери куда распаковать · Данные в %USERPROFILE%\.zernmc</p>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest" style="display:none">
<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 data-i18n="download.latest">Скачать актуальную версию</span> <span data-i18n="download.latest">Скачать актуальную версию</span>
</a> </a>
<div class="download-mirrors" id="mirrors"> <div class="download-mirrors" id="mirrors">
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span> <span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
</div> </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> </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>
@@ -421,6 +511,72 @@
</div> </div>
</footer> </footer>
<!-- Auth modal (launcher style) -->
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
<button class="modal-close" id="auth-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:8px;margin-bottom:4px">
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
</div>
<div class="field">
<label for="auth-username" data-i18n="auth.username">Логин</label>
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field">
<label for="auth-password" data-i18n="auth.password">Пароль</label>
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field hidden" id="auth-confirm-field">
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
</div>
</div>
</div>
<!-- Cabinet (personal area) -->
<section class="section hidden" id="cabinet">
<div class="section-head">
<span class="section-index">08 / 08</span>
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
</div>
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
<div>
<div style="font-weight:700" id="cab-username"></div>
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role"></span> · <span id="cab-pass"></span></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px"></p></div></div>
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2"></p></div></div>
</div>
</div>
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
</div>
</section>
<script src="/js/main.js"></script> <script src="/js/main.js"></script>
</body> </body>
</html> </html>
+390 -14
View File
@@ -13,6 +13,7 @@
'nav.download': 'Скачать', 'nav.download': 'Скачать',
'nav.news': 'Новости', 'nav.news': 'Новости',
'nav.play': 'Играть сейчас', 'nav.play': 'Играть сейчас',
'section.of': 'Раздел {n} из {m}',
'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР', 'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР',
'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>', 'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>',
'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.', 'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.',
@@ -110,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': 'Новостей пока нет.',
@@ -126,6 +145,7 @@
'nav.download': 'Download', 'nav.download': 'Download',
'nav.news': 'News', 'nav.news': 'News',
'nav.play': 'Play now', 'nav.play': 'Play now',
'section.of': 'Section {n} of {m}',
'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER', 'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER',
'hero.title': 'Awaken from<br><span class="hero-title-accent">the cold.</span>', 'hero.title': 'Awaken from<br><span class="hero-title-accent">the cold.</span>',
'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.', 'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.',
@@ -223,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.',
@@ -253,7 +291,13 @@
document.documentElement.lang = currentLang; document.documentElement.lang = currentLang;
const toggle = document.getElementById('langToggleLabel'); const toggle = document.getElementById('langToggleLabel');
if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU'; if (toggle) toggle.textContent = currentLang === 'ru' ? 'RU' : 'EN';
// Section indices: add a tooltip explaining what "02 / 07" means.
document.querySelectorAll('.section-index').forEach((el) => {
const m = (el.textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/);
if (m) el.setAttribute('title', tr('section.of').replace('{n}', m[1]).replace('{m}', m[2]));
});
document.querySelectorAll('[data-i18n]').forEach((el) => { document.querySelectorAll('[data-i18n]').forEach((el) => {
el.textContent = tr(el.dataset.i18n); el.textContent = tr(el.dataset.i18n);
@@ -315,6 +359,8 @@
const dlVersion = document.getElementById('download-version'); const dlVersion = document.getElementById('download-version');
const dlSize = document.getElementById('download-size'); const dlSize = document.getElementById('download-size');
const dlBtn = document.getElementById('download-btn'); const dlBtn = document.getElementById('download-btn');
const dlOnline = document.getElementById('download-online-btn');
const dlOffline = document.getElementById('download-offline-btn');
const pvVersion = document.getElementById('preview-version'); const pvVersion = document.getElementById('preview-version');
if (heroVersion) heroVersion.textContent = version; if (heroVersion) heroVersion.textContent = version;
@@ -330,6 +376,18 @@
if (dlBtn && data.new_format && data.new_format.download_url) { if (dlBtn && data.new_format && data.new_format.download_url) {
dlBtn.href = API + data.new_format.download_url; dlBtn.href = API + data.new_format.download_url;
} }
// Online/Offline hybrid (Go, 1.1.3)
if (dlOnline) {
// 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/online';
dlOnline.setAttribute('data-version', v);
} catch(e){}
}
if (dlOffline) dlOffline.href = API + '/launcher/download/offline-setup';
if (dlOffline && data.new_format && 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);
} }
@@ -423,6 +481,10 @@
const next = document.getElementById('carouselNext'); const next = document.getElementById('carouselNext');
if (!track) return; if (!track) return;
const AUTO_MS = 6500; // auto-advance when idle
const MANUAL_MS = 22000; // hold much longer after manual navigation
const videos = [];
HIGHLIGHTS.forEach((h, i) => { HIGHLIGHTS.forEach((h, i) => {
const slide = document.createElement('div'); const slide = document.createElement('div');
slide.className = 'carousel-slide'; slide.className = 'carousel-slide';
@@ -431,11 +493,10 @@
const vid = document.createElement('video'); const vid = document.createElement('video');
vid.src = '/highlights/' + h.file; vid.src = '/highlights/' + h.file;
vid.controls = true; vid.controls = true;
vid.autoplay = true;
vid.loop = true;
vid.muted = true; vid.muted = true;
vid.playsInline = true; vid.playsInline = true;
slide.appendChild(vid); slide.appendChild(vid);
videos[i] = vid;
} else { } else {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = '/highlights/' + h.file; img.src = '/highlights/' + h.file;
@@ -456,38 +517,74 @@
const dot = document.createElement('span'); const dot = document.createElement('span');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : ''); dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => goto(i)); dot.addEventListener('click', () => goto(i, true));
dotsWrap.appendChild(dot); dotsWrap.appendChild(dot);
}); });
let index = 0; let index = 0;
let timer = null; let timer = null;
function goto(i) { function schedule(delay) {
stop();
timer = setInterval(() => nextSlide(false), delay);
}
function isVideoAt(i) {
const h = HIGHLIGHTS[i];
return h && h.type === 'video';
}
function goto(i, manual) {
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length; index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
track.style.transform = 'translateX(-' + index * 100 + '%)'; track.style.transform = 'translateX(-' + index * 100 + '%)';
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => { dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
d.classList.toggle('active', k === index); d.classList.toggle('active', k === index);
}); });
// Only the visible video plays; others pause to save bandwidth.
videos.forEach((vid, k) => {
if (vid) {
if (k === index) {
if (vid.paused) {
vid.currentTime = 0;
const p = vid.play();
if (p && p.catch) p.catch(() => {});
}
} else {
vid.pause();
}
}
});
if (isVideoAt(index)) {
// A video slide always advances on its 'ended' event, never on a timer.
stop();
return;
}
// Image slides: manual navigation holds much longer, idle auto-advance restores.
schedule(manual ? MANUAL_MS : AUTO_MS);
} }
function nextSlide() { goto(index + 1); } function nextSlide(manual) { goto(index + 1, !!manual); }
function prevSlide() { goto(index - 1); } function prevSlide() { goto(index - 1, true); }
function start() { function start() { goto(index, false); }
stop();
timer = setInterval(nextSlide, 6500);
}
function stop() { function stop() {
if (timer) { clearInterval(timer); timer = null; } if (timer) { clearInterval(timer); timer = null; }
} }
if (prev) prev.addEventListener('click', () => { prevSlide(); start(); }); // A video slide advances only once its playback actually finished.
if (next) next.addEventListener('click', () => { nextSlide(); start(); }); videos.forEach((vid) => {
if (!vid) return;
vid.addEventListener('ended', () => nextSlide(false));
});
if (prev) prev.addEventListener('click', () => { prevSlide(); });
if (next) next.addEventListener('click', () => { nextSlide(true); });
track.addEventListener('mouseenter', stop); track.addEventListener('mouseenter', stop);
track.addEventListener('mouseleave', start); track.addEventListener('mouseleave', start);
start(); goto(0, false);
} }
function stripMarkup(text) { function stripMarkup(text) {
@@ -654,6 +751,281 @@
if (el) el.textContent = new Date().getFullYear(); 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', () => { document.addEventListener('DOMContentLoaded', () => {
applyI18n(); applyI18n();
@@ -665,11 +1037,15 @@
loadNews(); loadNews();
loadServerStatus(); loadServerStatus();
setInterval(loadServerStatus, 15000); setInterval(loadServerStatus, 15000);
loadGithubLanguages();
initCarousel(); initCarousel();
initPreview(); initPreview();
initNav(); initNav();
initParallax(); initParallax();
initReveal(); initReveal();
initYear(); initYear();
initAuth();
// if already authed, preload cabinet data
if (getAuth()) loadCabinetProfile();
}); });
})(); })();
+4
View File
@@ -1,6 +1,10 @@
TODO / ideas backlog TODO / ideas backlog
==================== ====================
1.1.1.2 hotfix DONE — WebKit "Reload" + onboarding persist
- Поправлено JFXLauncher.java:512 — document.addEventListener('contextmenu', e=> e.preventDefault(), true) глобально (Reload больше не появляется)
- Onboarding fix: localStorage volatile в WebView → теперь persist в Config (~/.zernmc/launcher.properties onboardedVersion/completedOnboardBlocks) через /api/settings GET/POST, JS sync localStorage<->Config. Прохождение без "Не показывать" теперь не всплывает снова. Версия 1.1.1.2
Java Agent + IPC (low priority, overkill now) Java Agent + IPC (low priority, overkill now)
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси) - Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main - Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main