diff --git a/README.md b/README.md index 5fef510..d5a69bc 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,21 @@ ## Состав дистрибутива -В архиве `ZernMC-win-<версия>.zip`: +**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):** +- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе` +- Качает `JRE 47М` → `lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe` +- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык +- Локальные файлы `~/.zernmc` не трогает -- `zernmc.exe` — основная версия с GUI (JavaFX) -- `zernmc-cli.exe` — консольная версия (TUI) -- `zernmc-cli-jfx.exe` — консоль + JavaFX UI -- `lib/jre21` — встроенный JRE 21 -- `lib/javafx` — модули JavaFX 23.0.1 +**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):** -Bootstrap-модуль встроен прямо в `.exe`, отдельный jar рядом не нужен. +- `zernmc.exe` — GUI (JavaFX) +- `zernmc-cli.exe` — TUI +- `zernmc-cli-jfx.exe` — консоль + JavaFX +- `lib/jre21` — JRE 21 (только оффлайн) +- `lib/javafx` — JavaFX 23.0.1 + +Bootstrap встроен в `.exe`. ## Чего пока нет в лаунчере @@ -86,17 +92,19 @@ Bootstrap-модуль встроен прямо в `.exe`, отдельный j ## Как скачать и запустить -Скачать последнюю версию можно по ссылкам: +**Скачать лаунчер (выбери один):** +- **Онлайн-установщик (рекомендуется)** — `ZernMC-Online-Setup.exe` (~5.7М, Go, без Java): скачай → выбери папку установки (по умолчанию `C:\ZernMC`) → [x] Создать ярлык ZernMC Launcher на рабочем столе → Установить. Первый запуск докачает JRE 47М + файлы лаунчера 50М. Данные сборок `~/.zernmc` остаются в профиле пользователя. Деинсталляция — `C:\ZernMC\uninstall.exe` или удаление папки. +- **Оффлайн ZIP** — `ZernMC-win-<версия>.zip` (98М, с JRE): скачай → выбери папку куда распаковать (диалог в `ZernMC-Offline-Setup.exe` или вручную) → [x] Ярлык → готово. Для air-gapped. -**Скачать лаунчер:** -- https://api.zernmc.ru/launcher/download/latest -- https://api.zernmc.online/launcher/download/latest +**Зеркала (оба варианта):** +- https://api.zern.cc/launcher/download/jre — JRE для онлайн +- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP -**Инструкция:** -1. Скачайте zip-архив -2. Распакуйте в удобную папку (например `C:\ZernMC`) -3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима) -4. Войдите в аккаунт и нажмите **«Начать игру»** +**Инструкция (оба):** +1. Скачай online exe **или** offline zip +2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык +3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки +4. Войди в аккаунт и нажми **«Начать игру»** ### Установка первой сборки diff --git a/installer/go.mod b/installer/go.mod new file mode 100644 index 0000000..6140853 --- /dev/null +++ b/installer/go.mod @@ -0,0 +1,8 @@ +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 +) diff --git a/installer/go.sum b/installer/go.sum new file mode 100644 index 0000000..11393c3 --- /dev/null +++ b/installer/go.sum @@ -0,0 +1,4 @@ +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= diff --git a/installer/main.go b/installer/main.go new file mode 100644 index 0000000..b3becb7 --- /dev/null +++ b/installer/main.go @@ -0,0 +1,552 @@ +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() +} diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index 73cd02b..ae26a95 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -402,6 +402,44 @@ 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"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 06029e9..da937c9 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -447,7 +447,7 @@ public class JFXLauncher extends Application { startServer(); WebView webView = new WebView(); - webView.setContextMenuEnabled(false); + webView.setContextMenuEnabled(true); WebEngine engine = webView.getEngine(); engine.setJavaScriptEnabled(true); @@ -508,6 +508,9 @@ 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);" ); } @@ -607,6 +610,12 @@ 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); @@ -1250,14 +1259,55 @@ public class JFXLauncher extends Application { Path dir = instance.getPath(); Map info = new HashMap<>(); - int modsCount = 0; + List> modsList = new ArrayList<>(); Path modsDir = dir.resolve("mods"); if (Files.exists(modsDir)) { try (var files = Files.list(modsDir)) { - modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count(); - } + files.forEach(p -> { + String fn = p.getFileName().toString(); + boolean isJar = fn.endsWith(".jar"); + boolean isDisabled = fn.endsWith(".jar.disabled"); + if (!isJar && !isDisabled) return; + String display = isDisabled ? fn.substring(0, fn.length()-9) : fn; + Map m = new HashMap<>(); + m.put("fileName", fn); + // try parse mod metadata + String modId = null, modName = null, modVer = null; + try (var zf = new java.util.jar.JarFile(p.toFile())) { + var e1 = zf.getEntry("fabric.mod.json"); + if (e1 != null) { + String json = new String(zf.getInputStream(e1).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var jo = new org.json.JSONObject(json); + modId = jo.optString("id", null); + modName = jo.optString("name", null); + modVer = jo.optString("version", null); + } else { + var e2 = zf.getEntry("META-INF/mods.toml"); + if (e2 == null) e2 = zf.getEntry("META-INF/neoforge.mods.toml"); + if (e2 == null) e2 = zf.getEntry("mods.toml"); + if (e2 != null) { + String toml = new String(zf.getInputStream(e2).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var mid = java.util.regex.Pattern.compile("modId\\s*=\\s*\"([^\"]+)\"").matcher(toml); + if (mid.find()) modId = mid.group(1); + var dname = java.util.regex.Pattern.compile("displayName\\s*=\\s*\"([^\"]+)\"").matcher(toml); + if (dname.find()) modName = dname.group(1); + var ver = java.util.regex.Pattern.compile("version\\s*=\\s*\"([^\"]+)\"").matcher(toml); + if (ver.find()) modVer = ver.group(1); + } + } + } catch (Exception ignored) {} + m.put("displayName", modName != null ? modName : (modId != null ? modId : display)); + m.put("modId", modId != null ? modId : display.replace(".jar","")); + m.put("version", modVer != null ? modVer : "?"); + m.put("enabled", !isDisabled); + try { m.put("size", Files.size(p)); } catch (Exception e) { m.put("size", 0L); } + modsList.add(m); + }); + } catch (Exception ignored) {} + modsList.sort((a,b) -> String.valueOf(a.get("displayName")).compareToIgnoreCase(String.valueOf(b.get("displayName")))); } - info.put("modsCount", modsCount); + info.put("modsCount", modsList.size()); + info.put("mods", modsList); List screenshots = new ArrayList<>(); Path screenshotsDir = dir.resolve("screenshots"); @@ -1327,6 +1377,12 @@ 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 res = new HashMap<>(); res.put("success", true); res.put("maxMemory", Config.getMaxMemory()); @@ -1343,6 +1399,8 @@ 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()); @@ -1785,6 +1843,148 @@ public class JFXLauncher extends Application { } } + private void handleModsToggle(HttpExchange exchange) { + try { + Map 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 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 body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + if (name == null) { + Map 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 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 body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + if (name == null) { + Map 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) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java index e526529..d18ac5b 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -22,6 +22,8 @@ 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(); @@ -61,6 +63,8 @@ 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()); @@ -79,6 +83,8 @@ 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"); @@ -266,4 +272,9 @@ 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(); } } diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index 41afe00..182e985 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -245,6 +245,16 @@
Loading...
+ @@ -519,6 +529,15 @@ +
+
+

Show tutorial again

+

You can replay the tutorial in Settings.

+
+
+ +
+
@@ -570,7 +589,7 @@