Compare commits
178 Commits
ui
..
934803582c
| Author | SHA1 | Date | |
|---|---|---|---|
| 934803582c | |||
| 0ae1db3582 | |||
| 60d5094b5a | |||
| cb6c952175 | |||
| c628397850 | |||
| b4bd76ed35 | |||
| 943211314e | |||
| 566559d639 | |||
| 2d6355ea57 | |||
| 3a16e56000 | |||
| ce61b111e9 | |||
| 46d5211150 | |||
| fdd4e24e3c | |||
| cdc81e4e8d | |||
| 4f5bd5387a | |||
| dcd5e91ebe | |||
| 553aad41f9 | |||
| f706e8393c | |||
| 26e0280d84 | |||
| 421fb04ab5 | |||
| 7d30caba74 | |||
| a87a871e43 | |||
| c51d441743 | |||
| 7235017493 | |||
| 4acbdedf70 | |||
| e962adbb58 | |||
| 22b056614b | |||
| 6024df5093 | |||
| 1307d10e81 | |||
| cffa6519ca | |||
| ce8cf32ddd | |||
| 72c58aced3 | |||
| ffc491c333 | |||
| 2846e3edd1 | |||
| 7ae81b3c91 | |||
| 375b98586d | |||
| 348969e79c | |||
| 15532bf341 | |||
| 424cf9bc25 | |||
| ef7d5edef3 | |||
| d9c527b8db | |||
| f32ac1ef98 | |||
| 6763d0144a | |||
| 37ec2bc342 | |||
| 6a0c59f032 | |||
| 599e9d5e67 | |||
| 929d5a4ad6 | |||
| 9a853a6134 | |||
| 6368c32a83 | |||
| d7ba06f24c | |||
| dcb3412a58 | |||
| c2150aa7d2 | |||
| 03a1a1533e | |||
| 03c908c80c | |||
| b9bd642605 | |||
| f58db7f941 | |||
| 0a388c90fd | |||
| 6cbf8a6549 | |||
| d4b6ea081e | |||
| 96e68dab89 | |||
| e6a6392565 | |||
| 1efd283234 | |||
| 546652f44c | |||
| 5c8e93fd95 | |||
| 1d069811e3 | |||
| 201269efea | |||
| 0d61ad1107 | |||
| e49e630afe | |||
| 0a2b80ed06 | |||
| b493b3278b | |||
| ec7ef01760 | |||
| 166dbf8935 | |||
| 7014c4a455 | |||
| d956bce921 | |||
| a765d064c4 | |||
| 1d5241075b | |||
| 2c670b1103 | |||
| 389280f7f1 | |||
| ee1e4fa8d2 | |||
| e17b1d073a | |||
| a8f3ca5049 | |||
| 59480217aa | |||
| 4697b16ab4 | |||
| 099df80cc6 | |||
| 74cd5ffdf3 | |||
| 01668dd3bf | |||
| b2dbbac6ca | |||
| e32a057684 | |||
| d4dc35aac3 | |||
| 1e7231af57 | |||
| fd6e292d6e | |||
| 1e876ffe28 | |||
| 2d515108f0 | |||
| 13c9f67f6e | |||
| 659265c2f0 | |||
| d8f189558a | |||
| 6f56012e3a | |||
| 3a0570e7da | |||
| 985abf7440 | |||
| ec551ab2e3 | |||
| e5948b5337 | |||
| 5a826c8511 | |||
| ce12854e1b | |||
| e566703332 | |||
| aaa19df5e4 | |||
| 0ee8077787 | |||
| fba944b4b8 | |||
| d39b40053a | |||
| 1199ca9e21 | |||
| 50080d890f | |||
| f6fbb66cdc | |||
| d7a928cce4 | |||
| 3bd3d1d0e8 | |||
| df9fa7b867 | |||
| 81fbe028e8 | |||
| 513c07666b | |||
| 04f97c3c80 | |||
| f40cf7afed | |||
| 0cef411125 | |||
| 523f659269 | |||
| 04620d76c4 | |||
| d0b4e187c8 | |||
| f2d3de82f7 | |||
| b4431702dc | |||
| cd2cf44d9c | |||
| 8939e24e69 | |||
| c0310ed573 | |||
| c96b502ad4 | |||
| bfcffdd88d | |||
| 331fc9a863 | |||
| e347c042d5 | |||
| bb564e6e9b | |||
| 6f53002266 | |||
| 9688509df5 | |||
| efc4b086d1 | |||
| 2cdc438411 | |||
| b60e414d37 | |||
| 10ec8625b9 | |||
| f24cc078c5 | |||
| adde40d921 | |||
| 6bf6c1634a | |||
| 98462ba4a3 | |||
| 11ec84fe24 | |||
| 8b56652a73 | |||
| d7a6eb760e | |||
| c6dd215e9b | |||
| a3f9871d6e | |||
| 2b6cb6b3ad | |||
| cca6ef3eca | |||
| 8733e359e6 | |||
| 89c0057759 | |||
| bf26baaf93 | |||
| 13a43a01ef | |||
| 296f564b39 | |||
| de703a4ddd | |||
| a501329956 | |||
| 5516aeb12f | |||
| 7b48ae2ab6 | |||
| 9bee361ea4 | |||
| 8cbad9af96 | |||
| 33cb7830e3 | |||
| 920330bbab | |||
| c03d7a788f | |||
| b47793b618 | |||
| 94968e8e77 | |||
| 0b4af1353d | |||
| 4edbe7e910 | |||
| 3d8313f7d2 | |||
| e21fd922ab | |||
| b29222af68 | |||
| ac3ce1800f | |||
| 2babe53e99 | |||
| 369f8c2f9d | |||
| c0a85658f4 | |||
| 7568e34d91 | |||
| 7670edbff7 | |||
| cf4a5c74e5 | |||
| 1a5336a996 |
@@ -21,21 +21,15 @@
|
||||
|
||||
## Состав дистрибутива
|
||||
|
||||
**Онлайн-установщик `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-win-<версия>.zip`:
|
||||
|
||||
**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
|
||||
- `zernmc.exe` — основная версия с GUI (JavaFX)
|
||||
- `zernmc-cli.exe` — консольная версия (TUI)
|
||||
- `zernmc-cli-jfx.exe` — консоль + JavaFX UI
|
||||
- `lib/jre21` — встроенный JRE 21
|
||||
- `lib/javafx` — модули JavaFX 23.0.1
|
||||
|
||||
- `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`.
|
||||
Bootstrap-модуль встроен прямо в `.exe`, отдельный jar рядом не нужен.
|
||||
|
||||
## Чего пока нет в лаунчере
|
||||
|
||||
@@ -92,19 +86,17 @@ Bootstrap встроен в `.exe`.
|
||||
|
||||
## Как скачать и запустить
|
||||
|
||||
**Скачать лаунчер (выбери один):**
|
||||
- **Онлайн-установщик (рекомендуется)** — `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.zern.cc/launcher/download/jre — JRE для онлайн
|
||||
- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP
|
||||
**Скачать лаунчер:**
|
||||
- https://api.zernmc.ru/launcher/download/latest
|
||||
- https://api.zernmc.online/launcher/download/latest
|
||||
|
||||
**Инструкция (оба):**
|
||||
1. Скачай online exe **или** offline zip
|
||||
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
|
||||
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
|
||||
4. Войди в аккаунт и нажми **«Начать игру»**
|
||||
**Инструкция:**
|
||||
1. Скачайте zip-архив
|
||||
2. Распакуйте в удобную папку (например `C:\ZernMC`)
|
||||
3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима)
|
||||
4. Войдите в аккаунт и нажмите **«Начать игру»**
|
||||
|
||||
### Установка первой сборки
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module zernmc-installer
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
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/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=
|
||||
@@ -1,552 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sqweek/dialog"
|
||||
)
|
||||
|
||||
var (
|
||||
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
|
||||
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
|
||||
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
|
||||
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
|
||||
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultInstallDir = "C:\\ZernMC"
|
||||
serverBase = "https://api.zern.cc"
|
||||
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Offline flag overrides online
|
||||
isOnline := *onlineFlag && !*offlineFlag
|
||||
|
||||
dir := *installDirFlag
|
||||
if dir == "" {
|
||||
dir = askInstallDir()
|
||||
}
|
||||
if dir == "" {
|
||||
fmt.Println("Installation cancelled")
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fatal("Cannot create directory: %v", err)
|
||||
}
|
||||
|
||||
createShortcut := *shortcutFlag
|
||||
if !isFlagSet("shortcut") {
|
||||
// ask via dialog if not explicitly flagged
|
||||
createShortcut = askShortcut()
|
||||
}
|
||||
|
||||
if isOnline {
|
||||
if err := installOnline(dir, createShortcut); err != nil {
|
||||
fatal("Online install failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
zipPath := *offlineZipFlag
|
||||
if zipPath == "" {
|
||||
// try to find adjacent offline zip
|
||||
zipPath = findAdjacentOfflineZip()
|
||||
if zipPath == "" {
|
||||
// ask user
|
||||
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
|
||||
}
|
||||
}
|
||||
if zipPath == "" {
|
||||
fatal("Offline zip not specified")
|
||||
}
|
||||
if err := installOffline(dir, zipPath, createShortcut); err != nil {
|
||||
fatal("Offline install failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nInstallation complete!")
|
||||
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
|
||||
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
|
||||
if createShortcut {
|
||||
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
|
||||
}
|
||||
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
|
||||
}
|
||||
|
||||
func isFlagSet(name string) bool {
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func askInstallDir() string {
|
||||
// Try GUI dialog first (Windows)
|
||||
if runtime.GOOS == "windows" {
|
||||
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
|
||||
if err == nil && dir != "" {
|
||||
// dialog.Directory returns chosen dir; append ZernMC if not already
|
||||
if !strings.Contains(strings.ToLower(dir), "zernmc") {
|
||||
dir = filepath.Join(dir, "ZernMC")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
// fallback to default if dialog fails/cancelled with empty
|
||||
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
|
||||
}
|
||||
// CLI fallback
|
||||
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
input = defaultInstallDir
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func askShortcut() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
// simple yes/no dialog
|
||||
// sqweek/dialog Message does not return bool, so use question
|
||||
// fallback to true by default if dialog unavailable
|
||||
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
|
||||
return yes
|
||||
}
|
||||
|
||||
func fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
// also show dialog on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func installOnline(dir string, createShortcut bool) error {
|
||||
fmt.Println("Online installation — downloading JRE + launcher files...")
|
||||
// Step 1: download JRE zip if missing
|
||||
jreDir := filepath.Join(dir, "lib", "jre21")
|
||||
javaExe := filepath.Join(jreDir, "bin", "java.exe")
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
fmt.Println("Downloading JRE 21 (47 MB)...")
|
||||
jreURL := serverBase + "/launcher/download/jre"
|
||||
tmpZip := filepath.Join(os.TempDir(), jreZipName)
|
||||
if err := downloadFile(jreURL, tmpZip); err != nil {
|
||||
// fallback to direct zip from builds if server endpoint not ready
|
||||
// try alternative mirrors
|
||||
mirrors := []string{
|
||||
"https://api.zernmc.ru/launcher/download/jre",
|
||||
"https://api.zernmc.online/launcher/download/jre",
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range mirrors {
|
||||
fmt.Printf("Retry mirror %s\n", m)
|
||||
if err := downloadFile(m, tmpZip); err == nil {
|
||||
lastErr = nil
|
||||
break
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("JRE download failed: %w", lastErr)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
|
||||
if err := unzip(tmpZip, dir); err != nil {
|
||||
// try alt: unzip should contain jre21/ prefix or lib/jre21?
|
||||
// our zip contains jre21/ at root, need to move to lib/jre21
|
||||
return fmt.Errorf("unzip JRE: %w", err)
|
||||
}
|
||||
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
|
||||
// if tmp unzip created dir/jre21, move to lib/jre21
|
||||
candidate := filepath.Join(dir, "jre21")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
|
||||
os.RemoveAll(jreDir)
|
||||
if err := os.Rename(candidate, jreDir); err != nil {
|
||||
// fallback copy
|
||||
if err := copyDir(candidate, jreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
os.RemoveAll(candidate)
|
||||
}
|
||||
}
|
||||
os.Remove(tmpZip)
|
||||
if _, err := os.Stat(javaExe); err != nil {
|
||||
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
|
||||
}
|
||||
fmt.Println("JRE ready")
|
||||
} else {
|
||||
fmt.Println("JRE already present, skipping")
|
||||
}
|
||||
|
||||
// Step 2: fetch meta and download launcher files
|
||||
fmt.Println("Fetching launcher meta...")
|
||||
meta, err := fetchMeta()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch meta: %w", err)
|
||||
}
|
||||
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
|
||||
|
||||
for i, f := range meta.Files {
|
||||
rel := f.Path
|
||||
// skip JRE entries (should not be in meta, but just in case)
|
||||
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
need := true
|
||||
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
need = false
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
|
||||
// try mirrors on failure
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
// try versioned fallback (some servers use builds/ vs versions/)
|
||||
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
|
||||
_ = alt
|
||||
return fmt.Errorf("download %s: %w", rel, err)
|
||||
}
|
||||
// verify hash
|
||||
hash, _ := fileSHA256(dest)
|
||||
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
|
||||
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
|
||||
}
|
||||
}
|
||||
|
||||
// write build.version
|
||||
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
|
||||
fmt.Printf("warning write build.version: %v\n", err)
|
||||
}
|
||||
// also ensure bin/zernmclauncher.jar etc are present
|
||||
fmt.Println("Launcher files ready")
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installOffline(dir, zipPath string, createShortcut bool) error {
|
||||
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return fmt.Errorf("offline zip not found: %s", zipPath)
|
||||
}
|
||||
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
|
||||
if err := unzip(zipPath, dir); err != nil {
|
||||
return fmt.Errorf("unzip: %w", err)
|
||||
}
|
||||
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
|
||||
// if zip contained top-level folder, handle it
|
||||
// we unzip directly to dir, so should be fine
|
||||
|
||||
if createShortcut {
|
||||
if err := createDesktopShortcut(dir); err != nil {
|
||||
fmt.Printf("shortcut failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
if err := createUninstaller(dir); err != nil {
|
||||
fmt.Printf("uninstall.exe failed: %v\n", err)
|
||||
}
|
||||
fmt.Println("Offline unpack complete")
|
||||
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
// support resume? simple GET
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// ensure parent
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
total := resp.ContentLength
|
||||
var written int64
|
||||
buf := make([]byte, 64*1024)
|
||||
start := time.Now()
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
written += int64(n)
|
||||
if total > 0 {
|
||||
pct := float64(written) / float64(total) * 100
|
||||
elapsed := time.Since(start).Seconds()
|
||||
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
|
||||
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
|
||||
} else {
|
||||
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
out.Close()
|
||||
// verify tmp exists
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
// fallback copy
|
||||
if err2 := copyFile(tmp, dest); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(path, src)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
func fetchMeta() (*Meta, error) {
|
||||
// try /launcher/meta/{version} flow: first get version, then meta
|
||||
resp, err := http.Get(serverBase + "/launcher/version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var verResp struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver := verResp.Version
|
||||
if ver == "" {
|
||||
ver = "1.1.1"
|
||||
}
|
||||
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer metaResp.Body.Close()
|
||||
if metaResp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
|
||||
}
|
||||
var meta Meta
|
||||
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.Version == "" {
|
||||
meta.Version = ver
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findAdjacentOfflineZip() string {
|
||||
exe, _ := os.Executable()
|
||||
dir := filepath.Dir(exe)
|
||||
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
// also check current dir
|
||||
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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 {
|
||||
// create uninstall.bat and copy self as uninstall.exe
|
||||
exe, _ := os.Executable()
|
||||
uninstExe := filepath.Join(dir, "uninstall.exe")
|
||||
if exe != "" && exe != uninstExe {
|
||||
_ = copyFile(exe, uninstExe)
|
||||
}
|
||||
bat := filepath.Join(dir, "uninstall.bat")
|
||||
content := fmt.Sprintf(`@echo off
|
||||
echo Uninstalling ZernMC from %s
|
||||
timeout /t 2 >nul
|
||||
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
|
||||
echo Data in %%USERPROFILE%%\.zernmc will be kept.
|
||||
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
|
||||
`, dir)
|
||||
_ = os.WriteFile(bat, []byte(content), 0644)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCmd(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -402,44 +402,6 @@ How to use:
|
||||
basedir="../../server/builds"
|
||||
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
|
||||
excludes="build.version,*.jar"/>
|
||||
|
||||
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
|
||||
<exec executable="bash" failonerror="false">
|
||||
<arg value="-c"/>
|
||||
<arg value="if command -v go >/dev/null 2>&1; then echo 'Building Go installers...'; mkdir -p ../../server/builds; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../installer 2>&1 || echo 'Go build skipped (no go)'; cp ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe 2>/dev/null || true; fi"/>
|
||||
</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>
|
||||
</configuration>
|
||||
</execution>
|
||||
|
||||
@@ -447,7 +447,7 @@ public class JFXLauncher extends Application {
|
||||
startServer();
|
||||
|
||||
WebView webView = new WebView();
|
||||
webView.setContextMenuEnabled(true);
|
||||
webView.setContextMenuEnabled(false);
|
||||
WebEngine engine = webView.getEngine();
|
||||
engine.setJavaScriptEnabled(true);
|
||||
|
||||
@@ -508,9 +508,6 @@ public class JFXLauncher extends Application {
|
||||
" req.open('POST', '/api/open-url', false);" +
|
||||
" req.send(JSON.stringify({url: target.href}));" +
|
||||
" }" +
|
||||
"}, true);" +
|
||||
"document.addEventListener('contextmenu', function(e) {" +
|
||||
" e.preventDefault();" +
|
||||
"}, true);"
|
||||
);
|
||||
}
|
||||
@@ -610,12 +607,6 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
|
||||
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
|
||||
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("/assets/", this::handleStatic);
|
||||
|
||||
@@ -1259,55 +1250,14 @@ public class JFXLauncher extends Application {
|
||||
Path dir = instance.getPath();
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
|
||||
List<Map<String, Object>> modsList = new ArrayList<>();
|
||||
int modsCount = 0;
|
||||
Path modsDir = dir.resolve("mods");
|
||||
if (Files.exists(modsDir)) {
|
||||
try (var files = Files.list(modsDir)) {
|
||||
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"))));
|
||||
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count();
|
||||
}
|
||||
}
|
||||
info.put("modsCount", modsList.size());
|
||||
info.put("mods", modsList);
|
||||
info.put("modsCount", modsCount);
|
||||
|
||||
List<String> screenshots = new ArrayList<>();
|
||||
Path screenshotsDir = dir.resolve("screenshots");
|
||||
@@ -1377,12 +1327,6 @@ public class JFXLauncher extends Application {
|
||||
if (body.containsKey("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<>();
|
||||
res.put("success", true);
|
||||
res.put("maxMemory", Config.getMaxMemory());
|
||||
@@ -1399,8 +1343,6 @@ public class JFXLauncher extends Application {
|
||||
data.put("javaPath", Config.getJavaPath());
|
||||
data.put("locale", Config.getLocale());
|
||||
data.put("systemBasedJvm", Config.isSystemBasedJvm());
|
||||
data.put("onboardedVersion", Config.getOnboardedVersion());
|
||||
data.put("completedOnboardBlocks", Config.getCompletedOnboardBlocks());
|
||||
data.put("cpuCores", Config.getSystemCpuCores());
|
||||
data.put("totalRamMB", Config.getSystemTotalRamMB());
|
||||
data.put("systemJvmFlags", Config.getSystemJvmFlags());
|
||||
@@ -1843,148 +1785,6 @@ 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)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
|
||||
Path dst;
|
||||
if (enable) {
|
||||
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
} else {
|
||||
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
|
||||
else { sendJson(exchange, Map.of("success", true)); return; }
|
||||
}
|
||||
Files.move(src, dst);
|
||||
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 ======================
|
||||
|
||||
private void handleAdmin(HttpExchange exchange) {
|
||||
|
||||
@@ -22,8 +22,6 @@ public class Config {
|
||||
private static volatile boolean ramManuallySet = false;
|
||||
private static volatile String locale = "en";
|
||||
private static volatile boolean systemBasedJvm = false;
|
||||
private static volatile String onboardedVersion = "";
|
||||
private static volatile String completedOnboardBlocks = "";
|
||||
|
||||
static {
|
||||
load();
|
||||
@@ -63,8 +61,6 @@ public class Config {
|
||||
javaPath = props.getProperty("javaPath", "java");
|
||||
locale = props.getProperty("locale", "en");
|
||||
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
|
||||
onboardedVersion = props.getProperty("onboardedVersion", "");
|
||||
completedOnboardBlocks = props.getProperty("completedOnboardBlocks", "");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
|
||||
@@ -83,8 +79,6 @@ public class Config {
|
||||
props.setProperty("javaPath", javaPath);
|
||||
props.setProperty("locale", locale);
|
||||
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
|
||||
props.setProperty("onboardedVersion", onboardedVersion);
|
||||
props.setProperty("completedOnboardBlocks", completedOnboardBlocks);
|
||||
|
||||
try (var os = Files.newOutputStream(CONFIG_FILE)) {
|
||||
props.store(os, "ZernMC Launcher Configuration");
|
||||
@@ -272,9 +266,4 @@ public class Config {
|
||||
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||
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(); }
|
||||
}
|
||||
|
||||
@@ -245,16 +245,6 @@
|
||||
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</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>
|
||||
|
||||
@@ -529,15 +519,6 @@
|
||||
<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 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>
|
||||
</main>
|
||||
@@ -589,7 +570,7 @@
|
||||
<div class="modal-body">
|
||||
<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" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</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>
|
||||
</div>
|
||||
|
||||
<div id="tab-zernmc" class="modal-tab-content active">
|
||||
@@ -609,28 +590,11 @@
|
||||
</div>
|
||||
|
||||
<div id="tab-custom" class="modal-tab-content">
|
||||
<div class="field">
|
||||
<label data-i18n="install.custom.name">Pack Name</label>
|
||||
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
|
||||
<div class="disabled-tab">
|
||||
<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>
|
||||
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
|
||||
<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 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 id="install-progress" class="install-progress hidden">
|
||||
|
||||
@@ -66,10 +66,6 @@ const LOCALES = {
|
||||
'install.serverPack.label': 'Server Pack',
|
||||
'install.localName.label': 'Local Name',
|
||||
'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.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.',
|
||||
'install.progress.installing': 'Installing...',
|
||||
@@ -168,18 +164,6 @@ const LOCALES = {
|
||||
'whitelist.done': 'Mods installed!',
|
||||
'whitelist.selectNone': 'Select mods first',
|
||||
'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',
|
||||
'admin.title': 'Admin Panel',
|
||||
'admin.clients': 'Clients',
|
||||
@@ -227,42 +211,6 @@ const LOCALES = {
|
||||
'install.preset.label': 'Preset (optional)',
|
||||
'install.preset.default': 'Default',
|
||||
'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: {
|
||||
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
||||
@@ -329,10 +277,6 @@ const LOCALES = {
|
||||
'install.serverPack.label': 'Серверная сборка',
|
||||
'install.localName.label': 'Локальное имя',
|
||||
'install.downloadBtn': 'Скачать и установить',
|
||||
'install.custom.name': 'Название сборки',
|
||||
'install.custom.mcVersion': 'Версия Minecraft',
|
||||
'install.custom.loader': 'Загрузчик',
|
||||
'install.custom.loaderVersion': 'Версия загрузчика',
|
||||
'install.custom.unavailable': 'Пока недоступно',
|
||||
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
|
||||
'install.progress.installing': 'Установка...',
|
||||
@@ -431,18 +375,6 @@ const LOCALES = {
|
||||
'whitelist.done': 'Моды установлены!',
|
||||
'whitelist.selectNone': 'Выберите моды',
|
||||
'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': 'Админка',
|
||||
'admin.title': 'Панель администратора',
|
||||
'admin.clients': 'Клиенты',
|
||||
@@ -490,42 +422,6 @@ const LOCALES = {
|
||||
'install.preset.label': 'Пресет (опционально)',
|
||||
'install.preset.default': 'Стандартный',
|
||||
'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': 'Туториал можно будет пройти в настройках.',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -589,157 +485,49 @@ class ZernMCLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BACKGROUND — hollow diamonds ====================
|
||||
// ==================== BACKGROUND ====================
|
||||
initBg() {
|
||||
const c = document.getElementById('bg-canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
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;
|
||||
let t = 0, mode = 'login', modePulse = 0;
|
||||
|
||||
const PALETTES = {
|
||||
login: {
|
||||
far: { r: 59, g: 130, b: 246, a: 0.18 },
|
||||
mid: { r: 59, g: 130, b: 246, a: 0.32 },
|
||||
near: { r: 96, g: 165, b: 255, a: 0.42 }
|
||||
},
|
||||
register: {
|
||||
far: { r: 249, g: 115, b: 22, a: 0.18 },
|
||||
mid: { r: 249, g: 115, b: 22, a: 0.34 },
|
||||
near: { r: 255, g: 162, b: 77, a: 0.44 }
|
||||
}
|
||||
};
|
||||
this.setWaveMode = m => { mode = m; modePulse = 1; };
|
||||
|
||||
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();
|
||||
};
|
||||
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
|
||||
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);
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
const w = c.width, h = c.height;
|
||||
modePulse += (0 - modePulse) * 0.03;
|
||||
|
||||
// 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);
|
||||
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 (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();
|
||||
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();
|
||||
}
|
||||
|
||||
tick += 16;
|
||||
rafId = requestAnimationFrame(draw);
|
||||
t++;
|
||||
};
|
||||
|
||||
// pause when hidden
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) cancelAnimationFrame(rafId);
|
||||
else draw();
|
||||
});
|
||||
|
||||
const anim = () => { draw(); requestAnimationFrame(anim); };
|
||||
resize();
|
||||
draw();
|
||||
anim();
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
@@ -843,22 +631,6 @@ class ZernMCLauncher {
|
||||
|
||||
if (r.success) {
|
||||
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();
|
||||
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
|
||||
this.toast(tr(key, null, {username: r.data.username}), 'success');
|
||||
@@ -867,21 +639,6 @@ class ZernMCLauncher {
|
||||
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
|
||||
if (reg.success) {
|
||||
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.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
|
||||
return;
|
||||
@@ -971,8 +728,7 @@ class ZernMCLauncher {
|
||||
container.style.transform = '';
|
||||
container.style.opacity = '';
|
||||
|
||||
if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
|
||||
else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
|
||||
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@@ -983,8 +739,6 @@ class ZernMCLauncher {
|
||||
}
|
||||
|
||||
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('main-screen').classList.add('hidden');
|
||||
}
|
||||
@@ -1017,7 +771,6 @@ class ZernMCLauncher {
|
||||
this.startFriendStatusHeartbeat();
|
||||
this.enhanceSelects();
|
||||
this.updateAdminNav();
|
||||
setTimeout(()=> this.checkOnboarding(), 600);
|
||||
}
|
||||
|
||||
async loadServerPacksList() {
|
||||
@@ -1354,7 +1107,6 @@ class ZernMCLauncher {
|
||||
`;
|
||||
if (!isDisabled) {
|
||||
el.addEventListener('click', () => this.selectPack(inst));
|
||||
el.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCtxMenu(inst, e.clientX, e.clientY); });
|
||||
}
|
||||
targetList.appendChild(el);
|
||||
});
|
||||
@@ -1510,9 +1262,6 @@ class ZernMCLauncher {
|
||||
} else {
|
||||
whitelistSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Local mods for any pack (server = readOnly except whitelist)
|
||||
this.loadLocalMods(inst.name, inst.isServerPack);
|
||||
}
|
||||
|
||||
async loadPackInfo(name) {
|
||||
@@ -1581,83 +1330,6 @@ class ZernMCLauncher {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
|
||||
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 ====================
|
||||
@@ -2923,313 +2595,12 @@ 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) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
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">×</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">×</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">×</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) {
|
||||
|
||||
@@ -29,10 +29,8 @@
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { -webkit-user-select: none; user-select: none; }
|
||||
.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, body, * { -webkit-user-select: none; user-select: none; }
|
||||
input, textarea { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
html { font-size: 14px; }
|
||||
|
||||
@@ -47,11 +45,8 @@ body {
|
||||
|
||||
#bg-canvas {
|
||||
position: fixed; inset: 0; width: 100%; height: 100%;
|
||||
z-index: 0; opacity: 0.14; pointer-events: none;
|
||||
z-index: 0; opacity: 0.08; 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; }
|
||||
|
||||
@@ -336,9 +331,6 @@ body {
|
||||
|
||||
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.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 {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
@@ -991,45 +983,3 @@ body {
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.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
@@ -19,8 +19,8 @@
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>1.1.1</revision>
|
||||
<hotfix>2</hotfix>
|
||||
<revision>1.1.0</revision>
|
||||
<hotfix>0</hotfix>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
@@ -1798,21 +1798,6 @@ 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")
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
|
||||
+5
-54
@@ -70,7 +70,7 @@
|
||||
|
||||
<section class="section server" id="server">
|
||||
<div class="section-head">
|
||||
<span class="section-index">01 / 07</span>
|
||||
<span class="section-index">01 / 06</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<section class="section highlights" id="highlights">
|
||||
<div class="section-head">
|
||||
<span class="section-index">02 / 07</span>
|
||||
<span class="section-index">02 / 06</span>
|
||||
<h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2>
|
||||
<p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
<section class="section features" id="features">
|
||||
<div class="section-head">
|
||||
<span class="section-index">03 / 07</span>
|
||||
<span class="section-index">03 / 06</span>
|
||||
<h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2>
|
||||
<p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p>
|
||||
</div>
|
||||
@@ -382,62 +382,13 @@
|
||||
<p class="download-desc">Windows · <span id="download-size">—</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/jre" download>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
|
||||
<span>Online Setup (5.8М)</span>
|
||||
</a>
|
||||
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/latest">
|
||||
<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>
|
||||
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest">
|
||||
<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>
|
||||
<span data-i18n="download.latest">Скачать актуальную версию</span>
|
||||
</a>
|
||||
<div class="download-mirrors" id="mirrors">
|
||||
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
|
||||
</div>
|
||||
|
||||
<!-- Git widget (launcher repo) -->
|
||||
<div class="github-widget" id="github-widget" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);text-decoration:none">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.51 2.87 8.33 6.84 9.68.5.09.68-.22.68-.48v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.1-1.5-1.1-1.5-.9-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.05a9.2 9.2 0 0 1 5 0c1.9-1.32 2.74-1.05 2.74-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.07.36.32.68.94.68 1.9v2.82c0 .26.18.58.69.48A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/></svg>
|
||||
sasheg/launcher
|
||||
<span style="margin-left:auto;font-size:12px;color:var(--text-secondary);font-weight:500">git.swe.zernmc.ru</span>
|
||||
</a>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)" id="github-stats">
|
||||
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> ветка <strong id="gh-branch" style="color:var(--text)">ui</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">bcd10cc</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>
|
||||
</div>
|
||||
|
||||
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
|
||||
|
||||
+14
-117
@@ -13,7 +13,6 @@
|
||||
'nav.download': 'Скачать',
|
||||
'nav.news': 'Новости',
|
||||
'nav.play': 'Играть сейчас',
|
||||
'section.of': 'Раздел {n} из {m}',
|
||||
'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР',
|
||||
'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>',
|
||||
'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.',
|
||||
@@ -127,7 +126,6 @@
|
||||
'nav.download': 'Download',
|
||||
'nav.news': 'News',
|
||||
'nav.play': 'Play now',
|
||||
'section.of': 'Section {n} of {m}',
|
||||
'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER',
|
||||
'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.',
|
||||
@@ -255,13 +253,7 @@
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
const toggle = document.getElementById('langToggleLabel');
|
||||
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]));
|
||||
});
|
||||
if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU';
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
el.textContent = tr(el.dataset.i18n);
|
||||
@@ -323,8 +315,6 @@
|
||||
const dlVersion = document.getElementById('download-version');
|
||||
const dlSize = document.getElementById('download-size');
|
||||
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');
|
||||
|
||||
if (heroVersion) heroVersion.textContent = version;
|
||||
@@ -340,22 +330,6 @@
|
||||
if (dlBtn && data.new_format && 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/jre'; // JRE endpoint validated, exe name is ZernMC-Online-Setup-<ver>.exe
|
||||
dlOnline.setAttribute('data-version', v);
|
||||
// probe if online exe exists
|
||||
fetch(API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe', {method:'HEAD'}).then(r=>{
|
||||
if (r.ok) dlOnline.href = API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe';
|
||||
}).catch(()=>{});
|
||||
} catch(e){}
|
||||
}
|
||||
if (dlOffline && data.new_format && data.new_format.download_url) {
|
||||
dlOffline.href = API + data.new_format.download_url;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load launcher info', e);
|
||||
}
|
||||
@@ -449,10 +423,6 @@
|
||||
const next = document.getElementById('carouselNext');
|
||||
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) => {
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'carousel-slide';
|
||||
@@ -461,10 +431,11 @@
|
||||
const vid = document.createElement('video');
|
||||
vid.src = '/highlights/' + h.file;
|
||||
vid.controls = true;
|
||||
vid.autoplay = true;
|
||||
vid.loop = true;
|
||||
vid.muted = true;
|
||||
vid.playsInline = true;
|
||||
slide.appendChild(vid);
|
||||
videos[i] = vid;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = '/highlights/' + h.file;
|
||||
@@ -485,74 +456,38 @@
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||
dot.addEventListener('click', () => goto(i, true));
|
||||
dot.addEventListener('click', () => goto(i));
|
||||
dotsWrap.appendChild(dot);
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
let timer = null;
|
||||
|
||||
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) {
|
||||
function goto(i) {
|
||||
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
|
||||
track.style.transform = 'translateX(-' + index * 100 + '%)';
|
||||
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
|
||||
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(manual) { goto(index + 1, !!manual); }
|
||||
function prevSlide() { goto(index - 1, true); }
|
||||
function nextSlide() { goto(index + 1); }
|
||||
function prevSlide() { goto(index - 1); }
|
||||
|
||||
function start() { goto(index, false); }
|
||||
function start() {
|
||||
stop();
|
||||
timer = setInterval(nextSlide, 6500);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
|
||||
// A video slide advances only once its playback actually finished.
|
||||
videos.forEach((vid) => {
|
||||
if (!vid) return;
|
||||
vid.addEventListener('ended', () => nextSlide(false));
|
||||
});
|
||||
|
||||
if (prev) prev.addEventListener('click', () => { prevSlide(); });
|
||||
if (next) next.addEventListener('click', () => { nextSlide(true); });
|
||||
if (prev) prev.addEventListener('click', () => { prevSlide(); start(); });
|
||||
if (next) next.addEventListener('click', () => { nextSlide(); start(); });
|
||||
track.addEventListener('mouseenter', stop);
|
||||
track.addEventListener('mouseleave', start);
|
||||
|
||||
goto(0, false);
|
||||
start();
|
||||
}
|
||||
|
||||
function stripMarkup(text) {
|
||||
@@ -719,43 +654,6 @@
|
||||
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 */ }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyI18n();
|
||||
|
||||
@@ -767,7 +665,6 @@
|
||||
loadNews();
|
||||
loadServerStatus();
|
||||
setInterval(loadServerStatus, 15000);
|
||||
loadGithubLanguages();
|
||||
initCarousel();
|
||||
initPreview();
|
||||
initNav();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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)
|
||||
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
|
||||
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main
|
||||
|
||||
Reference in New Issue
Block a user