182 Commits

Author SHA1 Message Date
SashegDev 75738ae127 site: remove GitHub mirror button from git widget, keep only Gitea (languages fetch now Gitea-only) 2026-09-01 06:40:20 +00:00
SashegDev 5985887fb8 site: add GitHub-like language bar (Java 49.4% Python 25.4% JS 12.7% CSS 7.3% HTML 3.5% Go 1.7% — 32k loc) + live fetch from GitHub/Gitea API 2026-09-01 06:39:06 +00:00
SashegDev 8d5bcca1d5 site: add launcher git widget (Gitea sasheg/launcher ui/bcd10cc v1.1.1.2 + GitHub mirror, clone cmd) 2026-09-01 06:36:05 +00:00
SashegDev bcd10cce06 feat: 1.1.1.2 hybrid Go installer (online 5.8M/offline 98M, выбор C:\ZernMC + ярлык ZernMC Launcher.lnk, uninstall.exe, DataDir ~/.zernmc) — hollow-ромбы, custom-сборки, моды с правами, ПКМ без Reload, sticky Играть, онбординг Config persist + /launcher/download/jre 2026-09-01 06:35:28 +00:00
SashegDev 592de204b6 site: unify section indices, show current lang, smarter highlight carousel
- Section badges are now consistently 'N / 07' everywhere (was mixing 06/07)
  and get a localized title tooltip explaining 'Section N of 7'.
- Language toggle now shows the CURRENT language (RU/EN) instead of the
  next one, so the state is obvious before clicking.
- Carousel: manual navigation (arrows/dots) holds the slide 22s before
  resuming, idle auto-advance returns to 6.5s; video slides no longer loop
  and only advance when playback actually ends.
2026-09-01 06:35:28 +00:00
SashegDev 7068558137 launcher: fix CDN Connection Reset — geo-proxy fallback + atomic downloads, v1.1.0.0
- downloadFileWithSmartProxy/getWithSmartProxy now rotate the proxy request
  across api.zern.cc -> api.zernmc.ru -> api.zernmc.online -> api.pl.zern.cc
  -> api.swe.zern.cc instead of retrying the same single proxy base, so a
  region-specific CDN connection reset no longer burns every retry.
- DomainSelector registers every candidate/mirror domain as a fallback proxy
  base at startup; mirrors list stays in sync with LAUNCHER_MIRRORS.
- Downloads write to <target>.part and are moved into place atomically on
  success, so an interrupted transfer can no longer leave a corrupt partial
  file that later 'file exists' checks treat as installed.
- Bump version to 1.1.0.0 (revision 1.1.0, hotfix 0).
2026-08-20 10:07:37 +00:00
SashegDev 9cfd743e36 site: add launcher showcase — auto-rotating non-interactive UI tour 2026-08-20 09:41:38 +00:00
SashegDev 3755411954 site: add RU/EN language toggle with full i18n on landing 2026-08-20 09:31:16 +00:00
SashegDev aaecf7e395 site: add server section, highlights carousel, OBT/Season content
- server status cards (online/players/version) via /api/status mcstatus ping
- highlights carousel ported from old site (/highlights/ static media)
- OBT (war in Zarya, open beta event server) + Season (Prologue, Create:
  Aeronautics, year-long) sections
- nav updated, sections renumbered 01-06
2026-08-20 09:15:14 +00:00
SashegDev 1aefca32f2 site: dawn.gg-style landing at root + port /skin/* for TG bot
- serve new launcher landing site (zern.cc root -> launcher server 1582)
- static assets mounted at /css /js /img
- port GET/HEAD /skin/{filename} from old site with identical headers
- exempt /skin/* + site assets from rate-limit and cache middleware
- Caddy: root domains -> 1582, old site preserved at legacy.zernmc.*
2026-08-20 08:26:00 +00:00
SashegDev 661411553c crash dialog: auto-close 1.2s after successful send to server 2026-08-19 21:21:49 +00:00
SashegDev 96bb8629d3 v1.0.16.21 — prefetch Forge/NeoForge libraries through smart proxy; UI polish (no text selection, log viewer copy/open, locale in settings, window icon, adaptive pack list) 2026-08-19 19:54:52 +00:00
SashegDev 1561320c4e v1.0.16.18 — accordion sidebar pack lists, versionInfo copyright/trademarks 2026-08-19 17:57:36 +00:00
SashegDev 53c00a6414 v1.0.16.17 — re-embed bootstrap jar, switch exe signing to jsign 2026-08-19 14:49:55 +00:00
SashegDev ee7f3812b6 v1.0.16.16 — exe в корне, иконка для GUI, чистка bin 2026-08-19 12:47:56 +00:00
SashegDev 20bb8233b9 v1.0.16.15 — codesign: self-signed Zern.cc exe signature (osslsigncode, digicert ts) + cli in bin layout + cli-jfx variant 2026-08-19 12:15:27 +00:00
SashegDev c86f24bc5b v1.0.16.14 — rotatsionny log: latest.log + .log.gz arkhiv (Minecraft-style), no limit, legacy launcher.log removal 2026-08-18 22:32:14 +00:00
SashegDev a2073044e9 v1.0.16.13 — TSPU/SNI-obkhod: domain selection at login + dynamic mirrors
- add DomainSelector: probe every API candidate at launcher startup, pick
  fastest reachable (primary api.zern.cc, legacy .ru/.online, geo pl/swe);
  integrate into CLI start and JFX network init
- Config.setServerUrl() + persist chosen domain in launcher.properties
- ZHttpClient: default BASE_URL and ZERN_SERVER health-check follow the
  selected domain (runtime failover instead of hardcoded api.zernmc.ru)
- server: LAUNCHER_MIRRORS main=api.zern.cc (primary), legacy .ru/.online,
  geo-pl api.pl.zern.cc, geo-swe api.swe.zern.cc; /launcher/mirrors exposes them
- diag: check all TLD+geo API hosts (api.{pl,ru,swe}.zern.cc, .ru, .online)
  for DNS/TCP/HTTP, known server IPs for all 4 geo nodes
- reverse-proxy geo boxes (pl, swe) -> main:1582 so clients bypass TSPU via
  an unblocked SNI; ru left as-is (VLESS VPN box, not proxying API)
2026-08-18 22:19:55 +00:00
SashegDev ecb49e1eb7 v1.0.16.3 — fix install hang at 0% (JDK request timeout not enforced on stalled body, cancel via sendAsync; proxy fallback on timeout), verify client.jar integrity, mod install via smart proxy 2026-08-15 09:31:23 +00:00
SashegDev a815d685d3 v1.0.16.2 — fix NeoForge/Forge install (bundled JRE for installers, visible progress in JFX UI, skip existing vanilla files), hide zernmc-cli.exe from users 2026-08-15 07:29:43 +00:00
SashegDev 21cd30c1c3 v1.0.16.0 — fix Zern-OBT asset index (1.20.1->5), JavaFX 23->23.0.1 (WebKit WebSocket fix), drop embedded JavaFX from jar (scope=provided), deliver JavaFX 23.0.1 via incremental update, JFXBridge for safe CLI fallback 2026-08-14 07:29:23 +00:00
SashegDev 77fd97ad24 v1.0.15.3 — non-blocking logger (fix install freeze), URL-encode pack file paths, news ordering by mtime 2026-08-13 19:56:42 +00:00
SashegDev 2f78acf526 v1.0.15.2 — fix launcher 'no packs available' (NPE uri is null in sendBounded) 2026-08-13 14:09:22 +00:00
SashegDev f857d91e4d v1.0.15.1 — fix server re-extract of launcher zips; correct Forge launch command (-cp per version.json) 2026-08-13 09:33:44 +00:00
SashegDev b6dccd67ac v1.0.15.0 — fix launcher hang on close/launch and restore Forge classpath
Hang on close (JFX freezes on exit):
- LaunchService: replace process.onExit().thenRun() — it completes on the common
  ForkJoinPool whose non-daemon workers keep the JVM alive (the v1.0.14.2 regression
  re-introduced in v1.0.14.3's sendBounded) — with a daemon watcher thread that blocks
  on process.waitFor(). Removes ForkJoinPool pinning.
- JFXLauncher: HttpServer executor now uses daemon thread factory; installThread is daemon.
- ZHttpClient: drop the non-daemon CachedThreadPool executor and restore synchronous
  client.send() with request-level timeout, undoing the v1.0.14.3 sendAsync().get() that
  submitted work to a non-daemon pool.

Hang / no game launch (Forge/NeoForge):
- LaunchCommandBuilder: remove the self-invented -DlegacyClassPath injection. Modern
  Forge derives the MC-BOOTSTRAP layer from the module path / the ${classpath}
  substitution that AstralRinth and the 1.0.13 launcher use; a redundant -DlegacyClassPath
  duplicated every library and broke the bootstrap. Now relies solely on ${classpath}
  substitution from version.json (only built when referenced by the version.json).
- update LaunchCommandBuilderTest to assert the corrected contract (no -DlegacyClassPath,
  ${classpath} substituted into -p).

Version bump to new revision (hotfix=0): revision 1.0.14 -> 1.0.15.
Build: mvn -pl launcher package -> ZernMC-win-1.0.15.0.zip, build.version=1.0.15.0.

Signed-off-by: SashegDev <gdsasheg@gmail.com>
2026-08-12 12:04:01 +00:00
SashegDev 5e9ed7ec42 v1.0.14.5 — instant proxy fallback for all downloads, server proxy retries, loader library repair
- ZHttpClient: single direct attempt per resource, then immediate retry via /proxy/download (getWithSmartProxy, downloadFileWithSmartProxy)
- getRetry() for server proxy endpoints incl. transient 502/5xx; isRetryableError classification
- MAX_FAILS_BEFORE_PROXY 1 -> service switches to proxy-first after first failure
- detectService: files.minecraftforge.net routed through proxy
- repairMissingLibraries/repairLibrariesFromJson: re-download missing libs (incl. maven-coordinates format with -v2 fallback)
- JFX: force Mojang service check synchronously before install if network init not finished
- Forge/NeoForge: installer + downloads via ZHttpClient (no more bare HttpClient without request timeout), NeoForge subprocess 10-min timeout
- Fabric: pre-download loader libs via proxy, repair after install
2026-08-01 11:52:23 +00:00
SashegDev be41928378 v1.0.14.4 — enable adaptive proxy health monitoring in JFX, show per-service direct/proxy status in settings UI 2026-07-31 12:04:08 +00:00
SashegDev 1685b74389 v1.0.14.3 — route Mojang version data via server proxy, hard-bounded HTTP sends, Forge process timeout 2026-07-31 11:51:21 +00:00
SashegDev e89b37190b v1.0.14.2 — fix JFX hang (ForkJoinPool), retry logic for connection reset, library logging 2026-07-30 16:09:44 +00:00
SashegDev 8888e9d977 v1.0.14.1 — bump hotfix, proxy allows all Mojang CDN domains 2026-07-30 15:37:43 +00:00
SashegDev 7881ee64b0 fix: add missing Mojang CDN domains (piston-data, libraries.minecraft.net, maven repos) to proxy allow list, increase proxy timeout to 300s 2026-07-30 15:36:06 +00:00
SashegDev fd47fa2b0b fix: inject real version into UI, add timeout to FabricInstaller process 2026-07-30 15:30:32 +00:00
SashegDev f4edf5962b v1.0.14 — bridge release for older launchers 2026-07-30 15:17:38 +00:00
SashegDev f180663cef v1.0.13.1 — pack download stability, proxy for MC/Forge downloads, build.version priority 2026-07-30 14:31:13 +00:00
SashegDev 48fd1d2d82 fix: preserve username case in register/login validators 2026-07-30 13:03:48 +00:00
SashegDev 5d82bfda4f feat: include hotfix in manifest and build.version (1.0.13.0) 2026-07-30 11:52:40 +00:00
SashegDev 3c6c12554e chore: ignore todo.txt 2026-07-30 11:29:48 +00:00
SashegDev cebe90d5f6 feat: auto-detect new ZIP archives in builds/ + fix exe download path 2026-07-30 11:29:45 +00:00
SashegDev a86189b67e chore: remove accidental todo.txt 2026-07-30 11:28:33 +00:00
SashegDev 07da727ee6 feat: robust update with .update suffix + bootstrap self-update 2026-07-30 11:28:30 +00:00
SashegDev 7b5eac2b02 chore: remove accidental todo.txt 2026-07-30 11:12:11 +00:00
SashegDev de0dbab4c8 fix: support 4-part Windows version in version comparison 2026-07-30 11:12:08 +00:00
SashegDev b00f38d226 chore: bump hotfix to 1 2026-07-30 11:06:49 +00:00
SashegDev b29bc376a9 feat: add hotfix property for Windows 4-part version 2026-07-30 11:05:10 +00:00
SashegDev bf503d8b62 feat: login/register mode toggle with wave background animation 2026-07-30 10:25:26 +00:00
SashegDev 52386312f0 bump: version 1.0.11 → 1.0.12 2026-07-30 10:07:35 +00:00
SashegDev 0046c546df feat: Discord-style login page redesign 2026-07-30 10:05:53 +00:00
SashegDev 23b2236d7d chore: add .flattened-pom.xml to .gitignore 2026-07-29 06:16:46 +00:00
SashegDev 576425ede9 refactor: use ${revision} for CI-friendly version management 2026-07-29 06:16:32 +00:00
SashegDev 109f379abc bump: version 1.0.10 → 1.0.11, fix Forge/NeoForge launch 2026-07-29 06:15:26 +00:00
SashegDev 511c0a332d debug: print classpath entries to identify _1._20._1.forge module source 2026-07-14 11:02:28 +00:00
SashegDev b1812e270a fix: ensure version jar exists for Forge to prevent _1._20._1 module name
findVersionJar() falls back to versions/1.20.1/1.20.1.jar when the Forge
version jar doesnt exist. Filename 1.20.1.jar produces automatic module _1._20._1
which conflicts with module minecraft (split-package on net.minecraft.server).

Fix: ensureVersionJarForForge() copies vanilla jar to versions/<versionId>/
<versionId>.jar before findVersionJar() is called. Vanilla jar has
Automatic-Module-Name: minecraft so it becomes module minecraft, not _1._20._1.
2026-07-14 10:49:54 +00:00
SashegDev 2465484d18 fix: remove duplicate --width/--height for Forge - version.json already includes them via resolution_width/resolution_height placeholders 2026-07-14 10:43:43 +00:00
SashegDev 95fa3143c5 fix: rewrite Forge launch to pass version.json args directly (AstralRinth approach)
Root cause: our launcher was fighting with version.json by manually constructing
-p, -cp, --add-modules, --add-opens, -DignoreList, and -DlibraryDirectory.
AstralRinth passes version.json JVM args through with placeholder substitution,
then appends memory/GC/custom args.

Changes:
- VersionManifest: add getAllJvmArguments() and getAllGameArguments() that merge
  parent (vanilla) args with child (forge) args
- LaunchCommandBuilder.build(): rewrite Forge/NeoForge branch to parse ALL
  version.json JVM args and game args with placeholder substitution
- Removed: ensureVersionJarForForge(), findSrgClientJar(),
  filterClasspathAgainstModulePath(), manual -Djava.library.path for Forge
- Classpath: built from version.json libraries + client jar (first position)
- Memory/GC args appended AFTER version.json args (like AstralRinth)
- Fallback to manual args if version.json has no JVM/game args
2026-07-14 10:30:55 +00:00
SashegDev aa5fd1fa74 bump: version 1.0.9 → 1.0.10 2026-07-14 07:50:54 +00:00
SashegDev e32f215092 fix: check version jar manifest before assuming correct module - force overwrite vanilla jar with SRG jar if missing Automatic-Module-Name: minecraft - broaden SRG client jar search to multiple paths 2026-07-14 07:46:37 +00:00
SashegDev a391fda261 fix: use SRG client jar for securejarhandler minecraft module
ensureVersionJarForForge() was copying the vanilla Mojang jar
(obfuscated class names) to versions/<forgeId>/<forgeId>.jar.
securejarhandler creates the minecraft module from this jar, but
obfuscated names dont match SRG names like PreparableReloadListener.
The package is claimed by the module but class is not found → crash.

Now prioritizes the SRG-mapped client jar from Forge libraries
(client-*-srg.jar) which has Automatic-Module-Name: minecraft and
correct SRG class names. Falls back to vanilla jar only if SRG jar
is not found.

Also removed redundant -DlibraryDirectory already in manifest args.
2026-07-14 07:27:48 +00:00
SashegDev 944bbd34b1 fix: Forge split-package ResolutionException - remove ignoreList manipulation and call ensureVersionJarForForge 2026-07-13 22:07:31 +00:00
SashegDev bdfbf4c11e refactor: deduplicate classpath, improve forge jar detection, clean up comments 2026-07-13 20:29:53 +00:00
SashegDev b3a0280064 Fix 12: Add forge client jar to classpath (missing from version JSON libs)
The forge patched client jar (forge-<version>-client.jar) contains all
Minecraft classes with Automatic-Module-Name: minecraft, but it is NOT
listed in the version JSON libraries array - so buildClasspathFromManifest
does not include it on the -cp. FML discovers it by scanning the library
directory, but securejarhandler only processes classpath entries to create
named modules.

This fix explicitly finds and prepends forge-<version>-client.jar to the
classpath when launching Forge/NeoForge, so securejarhandler can create
the minecraft module from it.
2026-07-13 16:33:26 +00:00
SashegDev f41af52619 Fix 11: Remove forge- from ignoreList, let forge client jar create minecraft module
Instead of copying the vanilla jar to versions/<versionId>/ and adding it to
-cp (Fix 9/10), we now simply remove forge- from the -DignoreList pattern.
This allows securejarhandler to process forge-1.20.1-47.4.20-client.jar on
the classpath, which has Automatic-Module-Name: minecraft in its MANIFEST,
and create the minecraft module from it.

The forge client jar contains all patched MC classes, so PreparableReloadListener
and all other vanilla classes are accessible in the minecraft module.
No duplicate module creation, no split-package errors.
2026-07-13 16:27:29 +00:00
SashegDev 7145b8beb6 Fix 10: Add copied version jar to classpath for securejarhandler minecraft module
The copied version jar (1.20.1-forge-47.4.20.jar) is now on -cp so
securejarhandler can scan it and create the minecraft module. The jar
name does not match the ignoreList pattern forge- (starts with 1),
so it is included in the module layer. This ensures PreparableReloadListener
and other vanilla classes are findable in the minecraft module.
2026-07-13 16:05:28 +00:00
SashegDev 9d7506b7f2 fix: copy vanilla jar to versions/<versionId>/ for securejarhandler minecraft module
securejarhandler creates the minecraft module from versions/<versionId>/<versionId>.jar.
The Forge installer only creates the JSON there, not the jar. Without the jar,
securejarhandler cannot create the minecraft module, causing ClassNotFoundException
for PreparableReloadListener (a vanilla MC class expected in the minecraft module).

Fix: copy the vanilla version jar (e.g. versions/1.20.1/1.20.1.jar) to the
Forge version directory (e.g. versions/1.20.1-forge-47.4.20/1.20.1-forge-47.4.20.jar)
before launch. The jar is NOT put on -cp to prevent automatic module _1._20._1
from being created, which would conflict with the minecraft module (split-package).

Also removed patched forge client jar from -cp since securejarhandler excludes it
via -DignoreList (forge-* pattern).
2026-07-13 15:53:04 +00:00
SashegDev 414576b673 fix: use patched Forge client jar instead of vanilla version jar
Vanilla 1.20.1.jar on -cp creates automatic module _1._20._1 which
conflicts with Forge minecraft module. Use forge-*-client.jar from
libraries/ instead (contains all MC classes). Falls back to vanilla.
2026-07-13 15:37:20 +00:00
SashegDev f84544b7a3 fix: dont intercept Forge -p, its a hardcoded bootstrap list not ${classpath}
Forge manifest -p is 8 hardcoded bootstrap JARs using ${library_directory},
not ${classpath}. Intercepting it replaced those with ALL libraries causing
duplicate module errors. Version jar stays on -cp only (not -p).
2026-07-13 14:37:31 +00:00
SashegDev 748483914f fix: split classpath from module path to prevent Forge split-package error
- Build two separate classpaths: modulePath (libraries only) and classpath (libraries + version jar)
- Intercept manifest -p to use modulePath (no version jar) preventing _1._20._1 vs minecraft module conflict
- Intercept manifest -cp to skip (our explicit -cp already added)
- Fix --version to use getVersionId() instead of instance.getName()
2026-07-13 14:24:25 +00:00
SashegDev 4c83489420 fix: include version jar in classpath for Forge/NeoForge module resolution
The Minecraft client jar (version jar) contains all MC classes including
PreparableReloadListener. Forges
2026-07-13 14:02:20 +00:00
SashegDev 1821baa878 fix: include net.minecraft libs in classpath for Forge module path resolution
Forge version.json uses -p ${classpath} to set the module path to the same
argfile as the classpath. Previously we filtered net.minecraft:* libs from the
classpath string, which meant they were absent from the module path too, causing
ClassNotFoundException for PreparableReloadListener and other MC classes.

Now net.minecraft:* libs are included in the Forge classpath argfile so
securejarhandler can load them as modules.
2026-07-13 13:43:29 +00:00
SashegDev 5fbc336beb fix: Forge/NeoForge split-package ResolutionException — skip version jar on classpath, fix ${classpath} variable resolution 2026-07-13 13:32:34 +00:00
SashegDev fc55882d59 ForgeFix и новые фичи в интерфейсе 2026-07-13 13:16:51 +00:00
SashegDev 8d8fe0e99c DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню 2026-07-01 19:36:52 +00:00
SashegDev 6063743cb4 fix: автодетект --cli в Bootstrap, отображение локальных сборок в JFX, обработка ошибок установки и друзей 2026-06-30 13:44:26 +00:00
SashegDev 1ac663d6c0 fix: критичные баги — отсутствие auth на pack файлах, сломанный proxy fallback, race condition installInProgress, JSON парсинг, падение loadMetadata 2026-06-30 10:58:24 +00:00
SashegDev d93fac3e7e minor fixes 2026-06-07 16:36:50 +03:00
SashegDev c041881776 иним чиним чиним чиним а так же новая система друзей и бутстраппера 2026-06-07 12:32:34 +00:00
SashegDev 79e0eadb64 чиним cli + ui | Cli 99% готовность, UI примерно 70% 2026-05-24 18:38:16 +00:00
SashegDev 633de49547 fix: использовать java.exe вместо javaw.exe для отладки, inheritIO вместо ручного чтения 2026-05-11 12:21:29 +00:00
SashegDev 067ce0c89e fix: добавить UTF-8 параметры при запуске процессов и исправить обработку стрелок в ArrowMenu 2026-05-10 23:53:45 +00:00
SashegDev d5a06821e7 чиним cli + ui..... ДА БЛЯ НУ СКОЛЬКО МОЖНО ТО А 2026-05-10 02:48:13 +00:00
SashegDev 5f68956895 ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ а так же фикс CLI 2026-05-10 01:46:38 +00:00
SashegDev 8acf4e5f1f попытка оптимизации и ДЖЛЫВОСШФРСЖДЛВОФЖДЛОВСМДЖЛФ ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ 2026-05-10 01:24:47 +00:00
SashegDev a97852cfd4 Fix: JFX launcher inherit console, no game output capture, SSE log optimization 2026-05-10 00:25:49 +00:00
SashegDev ba141c195b Real-time log streaming via SSE 2026-05-10 00:05:10 +00:00
SashegDev 48694181ad Launcher UI: MC/loader versions from server, split instances, console log sync, disable ZernMC for FREE 2026-05-09 23:55:08 +00:00
SashegDev c5ee482308 Launcher UI redesign + server mirror sync + file download optimization 2026-05-09 23:47:04 +00:00
SashegDev fac4cfabf1 Server: generate meta.json for builds/ on startup for incremental updates 2026-05-08 18:51:15 +00:00
SashegDev 737b048b31 Bootstrap: incremental update via meta, server: fix file endpoint paths 2026-05-08 18:45:42 +00:00
SashegDev 11c1f6bd6b Pass launcher.server system property from Bootstrap to JFXLauncher 2026-05-08 18:38:18 +00:00
SashegDev 505ecb4d3d Assets: try meta download first, fallback to JAR extract 2026-05-08 18:37:24 +00:00
SashegDev 942ab7f145 Extract UI assets from JAR on first launch 2026-05-08 18:33:55 +00:00
SashegDev 8923395f60 Fix: NPE in AuthManager, game logs display in UI 2026-05-08 17:58:18 +00:00
SashegDev f88f115456 Fix: use vanilla classpath for modloaders (fabric/forge/neoforge), add JS debug logging 2026-05-08 17:50:33 +00:00
SashegDev f65c5c4180 Debug: classpath for modloaders, game logs in UI 2026-05-08 17:43:12 +00:00
SashegDev 9a1cf886d7 Debug: add stdout/stderr capture, log game logs to console 2026-05-08 17:36:49 +00:00
SashegDev 7bc4cfba46 Add game log file writing, debug modloader launch 2026-05-08 17:23:38 +00:00
SashegDev 2eac52b561 Clean up debug logging 2026-05-08 15:49:33 +00:00
SashegDev e105f73d4e Debug: log server version response 2026-05-08 15:45:23 +00:00
SashegDev a754dea612 Simplify: read version only from JAR manifest, remove .version file 2026-05-08 15:09:59 +00:00
SashegDev 18029a5f1c Fix version reading - fallback to JAR manifest, fix server version URL 2026-05-08 14:51:53 +00:00
SashegDev 81dd4c2892 Fix Bootstrap to use bin/ directory properly
- Read version from bin/.version file (reliable, no JAR locking)
- Save version to bin/.version when downloading JAR
- Use getLauncherJar() for all JAR path references
- Create binDir in main()
- Remove build.version dependency completely
2026-05-08 13:17:30 +00:00
SashegDev 8ab89075f2 Fix version reading from JAR manifest
- Read version from bin/.version file (reliable, no JAR locking issues)
- Save version to bin/.version when downloading JAR
- Remove complex JAR/ZIP reading code
- Use simple file-based version storage
2026-05-08 12:39:14 +00:00
SashegDev 914f753872 Remove build.version dependency
- Read version only from JAR manifest (Implementation-Version)
- Remove all VERSION_FILE references from Bootstrap
- Remove build.version from scanLocalFiles() and update methods
2026-05-08 12:15:43 +00:00
SashegDev 657304d7c7 Fix: Bootstrap update and meta parsing
- Rewrite getLauncherMeta() to properly parse server meta response
- Change downloadUpdate() fallback to JAR-only (not ZIP) to avoid JRE lock issues
- Simplify downloadUpdateLegacy() to skip ZIP (which locks JRE files)
- Add handling for AccessDeniedException when updating locked files
- Improve error logging for meta parsing failures
2026-05-08 11:19:10 +00:00
SashegDev 7ec41919a3 Fix: Fabric loader launch and Bootstrap paths
- Add Fabric support in LaunchCommandBuilder.findVersionJson()
- Fix Bootstrap to properly use bin/ directory for launcher JAR
- Fix server.py to accept both ZernMC-win-*.zip and ZernMCLauncher-*.zip
- Add debug output for version.json resolution
2026-05-08 11:04:45 +00:00
SashegDev 12c3be84ea Fix: Multiple launcher issues
- Fix CLI arrow keys: remove 50ms timeout in escape sequence handling (ArrowMenu, LoginMenu)
- Add network logs polling to UI via /api/logs endpoint
- Display user role in launcher header (AuthManager, AuthService, JFXLauncher, UI)
- Capture and display game logs in launcher via /api/game-logs endpoint
- Fix demo mode bug in VersionManifest.ruleMatches() - was incorrectly adding --demo flag
- Fix modloader launch: pass proper auth info (accessToken, uuid) from AuthManager
- Add game log capture in MinecraftLib and LaunchService
2026-05-08 10:11:49 +00:00
SashegDev 4189df9163 Server: Add launcher version scanning on startup
- Scan versions/ directory and generate meta.json for each version
- Log progress: 'Scanning launcher versions...', 'Launcher meta ready: vX (Y files)'
- Meta cached in memory for faster access
2026-05-07 18:50:07 +00:00
SashegDev 54e107f0b5 Bootstrap: Add incremental update support via meta system
- Get server version from /launcher/meta (new method)
- Scan local files and calculate SHA256 hashes
- POST to /launcher/diff to get what files need update
- Download only changed files via /launcher/file/{version}/{path}
- Delete obsolete files
- Fallback to ZIP/JAR if meta system fails
- Works with legacy method as backup
2026-05-07 18:41:35 +00:00
SashegDev 4b055172b5 Server: Add launcher meta system for incremental updates
- Create versions/ folder structure for new format builds
- Generate meta.json with SHA256 hashes for each file
- Add endpoints:
  - GET /launcher/meta - list all versions with meta
  - GET /launcher/meta/{version} - meta for specific version
  - POST /launcher/diff - get diff between local and server files
  - GET /launcher/file/{version}/{path} - download individual file
  - GET /launcher/download/zip/{version} - download full ZIP for new install
- Legacy builds (ZIP files) remain unchanged
2026-05-07 18:40:00 +00:00
SashegDev abf667ebdb Server: Default to 1 worker - better for file downloads
- Multiple workers cause contention and slow down large file downloads
- Single worker with async handles concurrent requests fine
2026-05-07 18:03:37 +00:00
SashegDev 07ca5c3668 Server: Reduce rate limit log spam - periodic summary only
- Instead of logging every rate limit warning, now logs summary every 60s
- Shows: IP_blocked=X, rate_limited=Y
2026-05-07 17:56:46 +00:00
SashegDev 566f9c13b3 Server: Add direct_passthrough for faster file serving
- FileResponse with direct_passthrough=True bypasses buffering
- Should improve file download speeds
2026-05-07 17:54:03 +00:00
SashegDev 3e85ff3a22 Server: Skip logging for file downloads
- Don't log every /pack/*/file/* request to reduce overhead
- Helps with large file downloads
2026-05-07 17:53:08 +00:00
SashegDev 3063153a97 Server: Fix /docs endpoint - allow openapi.json and swagger
- Remove openapi.json, swagger-ui, api/docs from suspicious paths
- Fix is_suspicious_path() to allow swagger/openapi patterns
2026-05-07 17:48:54 +00:00
SashegDev 805b55b85d Server: Remove broken PID-based logging
- is_master() doesn't work with uvicorn workers
- Keeping clean logs from cache + disabled httpx debug
2026-05-07 17:46:42 +00:00
SashegDev 58603a3e6a Server: PID-based logging - only master logs startup
- Only master PID logs blocklist loading, pack scanning, etc.
- Worker processes stay silent during startup
- Much cleaner logs
2026-05-07 17:45:36 +00:00
SashegDev 1241fbffcd Server: Add file lock for blocklist loading
- Only one worker downloads blocklist
- Other workers wait and read from cache
- Prevents duplicate downloads on startup
2026-05-07 17:43:21 +00:00
SashegDev 87a55f1dd1 Server: Cache blocklist to file + disable httpx debug logs
- Blocklist now cached to data/blocklist_cache.txt
- Only downloads once, then reuses cache
- Disable httpx/httpcore debug logs to reduce noise
2026-05-07 17:42:15 +00:00
SashegDev 91533d76cc Server: Fix blocklist loading - only once at startup
- Move public blocklist loading into lifespan (not on import)
- Avoids loading 8 times with 4 workers
- Cleaner startup logs
2026-05-07 17:40:32 +00:00
SashegDev f2365f9bcc Server: Auto-load public IP blocklists
- Load known bad IPs from FireHOL blocklists on startup
- ~4400 IPs blocked by default
- Set PUBLIC_BLOCKLIST=false to disable
- Combined with manual BLOCKED_IPS env var
2026-05-07 17:38:08 +00:00
SashegDev c8a705249c Server: Simplify IP filtering - only blacklist
- Remove whitelist (not needed for public launcher)
- Only BLOCKED_IPS env var supported now
2026-05-07 17:14:47 +00:00
SashegDev 094e40979e Server: Add bot protection middleware
- Global rate limiting (60 requests/minute per IP)
- IP whitelist/blacklist via ALLOWED_IPS and BLOCKED_IPS env vars
- Bot detection - silent 404 for suspicious paths (.env, phpinfo, etc.)
- Path traversal detection
- Reduced noise in logs from bot scanners
2026-05-07 17:09:45 +00:00
SashegDev a288dca5f9 Server: Add legacy build support
- Add version parsing to distinguish new vs legacy format builds
- New format: ZernMC-win-*.zip (1.0.8+ with bundled JRE21/JavaFX)
- Legacy: ZernMCLauncher-*.zip (< 1.0.8 or with suffix)
- /launcher/download/latest now returns new format by default
- Add /launcher/download/legacy endpoint for old builds
- Add legacy info to /launcher/info and /launcher/version responses
- Update download_zip to accept both ZernMCLauncher- and ZernMC-win- patterns
2026-05-07 16:44:10 +00:00
SashegDev 36d7d0cefa Refactor: Multi-module Maven project structure
- Restructured to multi-module Maven project (bootstrap + launcher)
- Removed duplicate code (launcher/launcher/ with JCEF)
- Added JavaFX modules to lib/javafx in ZIP
- Added JRE 21 to lib/jre21 in ZIP
- Fixed Bootstrap with UTF-8 encoding and JavaFX module-path
- Fixed JAR naming (zernmclauncher.jar)
- Added Windows build configuration (ZernMC-win-*.zip)
- Fixed version parsing for -any, -alpha, -beta suffixes
2026-05-06 21:35:14 +00:00
SashegDev c15db6487b коммит последних действий 2026-05-06 15:49:14 +00:00
SashegDev 200e6b164b Multi-module project: bootstrap + launcher, UI updates
- Split into 2 Maven modules: bootstrap (updater) + launcher (UI)
- New UI: blue-orange theme, grid animation background
- Fixed version parsing bug (start += 11)
- Added unit tests for version parsing
- Server: adapted to new build structure (builds/zernmc)
2026-05-06 10:33:08 +00:00
SashegDev 896b58472f feat(api): add internal API foundation for UI
- Create api package with AuthService, InstanceService, LaunchService
- Add ApiResponse<T> model for consistent responses
- Create LauncherAPI central facade for all services
- Update Main.java to use new API for session checking
- All services compile successfully
2026-05-05 04:12:39 +00:00
SashegDev a501dd1d99 refactor(launch): dynamic version JSON parsing for Forge/NeoForge compatibility
- Replace hardcoded Forge/NeoForge args with version.json parsing
- Add VersionManifest.java — parses mainClass, arguments, libraries from JSON
- Implement rule matching for OS-specific library/argument filtering
- Build classpath dynamically from manifest libraries with fallback resolution
- Resolve game args with variable substitution (${version_name}, ${game_directory}, etc.)
- Auto-discover version.json path with multiple candidate formats
- Support all Forge versions (1.12.2 through 1.21+) and NeoForge out of the box
2026-05-04 22:58:49 +00:00
SashegDev 37e7c990a7 feat: add NeoForge support, fix Forge installPack bug, update server proxy
- Fix MinecraftLib.installPack() returning false for Forge (was dead code)
- Add NeoForgeInstaller.java with installer download and execution
- Update LaunchCommandBuilder with NeoForge JVM args, classpath, launch args
- Update LaunchMenu with NeoForge option, version selector, support check
- Update Instance.java loader type comment (vanilla, fabric, forge, neoforge)
- Update PackDownloader to handle neoforge loader type
- Update ZHttpClient with NEOFORGE_MAVEN service type and detection
- Add NeoForge proxy endpoints (/proxy/neoforge/versions, /proxy/neoforge/maven)
- Add maven.neoforged.net to proxy allowed_domains
- Add asset_index to PackMeta model and pack_manager scanning
- Include asset_index in /packs list endpoint response
2026-05-04 22:53:22 +00:00
SashegDev 14da6c1a45 test(client): add JUnit 5 tests (30 tests) — unit + integration
- Add JUnit 5 dependency to pom.xml with surefire plugin
- Add setBaseUrl() to ZHttpClient for test server override
- AuthManagerParsingTest (7 tests): error extraction from JSON responses
  (simple detail, validation array, multiple errors, plain text, truncation)
- PackDownloaderParsingTest (13 tests): JSON contract for packs, manifests,
  diffs, file info, ServerPack toString
- ServerIntegrationTest (10 tests): real Java client ↔ real FastAPI server
  (register, login, duplicate, wrong password, /admin/me, validate token,
  refresh, packs auth, pack manifest public, launcher version)
- Integration tests auto-start test server via venv python3 subprocess
  on random port with isolated temp DB, graceful skip if unavailable

All 30 tests pass, 0 failures
2026-05-04 22:40:10 +00:00
SashegDev a80c00f91c test(server): add client-facing endpoint tests (20 tests), fix pack contract assertions
- Add test_client.py with comprehensive client-server contract tests:
  - TestAuthFlowClient: full register → login → refresh → validate → /admin/me → logout lifecycle
  - TestPacksClientContract: /packs response fields matching ServerPack.java
  - TestPackManifestClientContract: /pack/{name} fields matching PackManifest.java
  - TestPackDiffClientContract: /pack/{name}/diff matching DiffResponse/FileInfo.java
    (all-new, no-changes, outdated-file, extra-local-file scenarios)
  - TestPackFileDownload: file serving, 404, path traversal security
  - TestPackPermissions: auth/pass requirements for /packs and /diff
  - TestLauncherVersion: /launcher/version endpoint
  - TestProxyEndpoints: /proxy/status, /proxy/fabric/versions/loader
- Add logged_in_user_with_pass fixture (role=1) for pack-related tests
- Add pack_fixture: creates temp pack with mod file, scans it, cleans up
- Fix manifest test: files don't have 'url' field (only in diff response)
- Fix /pack/{name} test: endpoint is public, no auth required

Total: 67 tests passing (47 existing + 20 new)
2026-05-04 22:28:12 +00:00
SashegDev ff921def7f test(server): add comprehensive test suite (47 tests), fix DB lock and schema bugs
- Add pytest test suite: test_auth.py, test_admin.py, test_pass.py,
  test_proxy.py, test_rate_limit.py, test_client_contract.py
- Fix SQLite 'database is locked' errors: moved log_audit() calls outside
  with get_db() blocks in register, login, logout, refresh, activate_pass
- Enable WAL mode and busy_timeout in get_db() for concurrent access
- Fix /admin/me: removed non-existent 'email' column from query
- Fix /admin/users list: disambiguated activated_at column in JOIN query
- Fix /auth/refresh: now returns refresh_token + expires_in + username/uuid/role
  to match AuthManager.AuthSession expectations; revokes old refresh token
- Fix conftest.py: unique usernames per test to avoid conflicts
- All 47 tests passing
2026-05-04 22:14:06 +00:00
SashegDev ba4eea8c4e fix(server,security): add ban check to validate_token, replace rate_limit DB with TTLCache 2026-05-04 21:12:35 +00:00
SashegDev 7daaedd327 chore(server): remove unused models, delete http_logger.py, rename viev_logs.py → view_logs.py 2026-05-04 21:10:11 +00:00
SashegDev 95e5338635 refactor(server): clean main.py — remove duplicate imports, dead code, unify logging, fix proxy lifecycle 2026-05-04 21:09:10 +00:00
SashegDev d78c6c27a5 feat(server): add /auth/pass/activate endpoint for pass code activation 2026-05-04 21:06:56 +00:00
SashegDev 466f155e0e feat(server): connect admin_router to FastAPI app 2026-05-04 21:06:02 +00:00
SashegDev 014ffc4fae fix(server): add role aliases in roles.py to fix broken admin_router imports 2026-05-04 21:04:44 +00:00
SashegDev ec8e4c05d2 fix(pom.xml): correct launch4j JAR path for exe build 2026-05-04 20:52:28 +00:00
SashegDev ae7532eec0 fix(TUI): proper arrow key handling — parse ESC sequences instead of treating as Esc 2026-05-04 20:39:29 +00:00
SashegDev 7b2a571180 just workin on the todo 2026-05-04 20:26:27 +00:00
SashegDev ce8237c0db last commit to uuuuh idl 2026-05-04 15:19:46 +00:00
SashegDev 75d87084e3 The fuck was hapanned тут 2026-04-22 12:54:57 +00:00
SashegDev 18919f7a8e Merge branch 'main' into alpha 2026-04-22 15:26:39 +03:00
SashegDev 1c2ae28511 Коммит, для того что бы если что роллбекать 2026-04-22 12:23:51 +00:00
SashegDev 9e003fe1a1 Фиксы проходок (нормально, в отличии от main ветки)
ОНО РАБОТАЕТ СУКАААА
2026-04-20 19:30:17 +00:00
SashegDev 6bd49217d0 Update issue templates 2026-04-20 19:59:07 +03:00
SashegDev 3afd832db9 Create LICENSE 2026-04-20 19:57:52 +03:00
SashegDev 81aebef6a5 test penis 2026-04-09 18:13:21 +00:00
SashegDev efff2d4ef6 fixes 2026-04-09 18:03:00 +00:00
SashegDev 46cafa5250 рефакторинг + новая система модерации 2026-04-09 17:28:48 +00:00
SashegDev 7ea88e0a27 СУКА ЛАСТ ФИКСЫ ДЛЯ ПРОХОДОК (логин работает) 2026-04-08 20:22:47 +00:00
SashegDev b29698a3a2 ДА БЛЯ Я ЗАБЕАЛСЯ ФИКСИТЬ ПОМОГИТЕ Я КОНЧЕННЫЫЫЙ 2026-04-08 20:16:51 +00:00
SashegDev c66a8d372a SuperMinor Fixes (надеюсь последние для аккаунтов) 2026-04-08 20:04:52 +00:00
SashegDev 8d24bb9a92 Minor fixes(важные блять) 2026-04-08 20:02:09 +00:00
SashegDev e6dacdcb61 Server Fixes 2026-04-08 19:56:38 +00:00
SashegDev e6e4d88aec 1.0.7 типоооо и фиксы 2026-04-08 19:45:15 +00:00
SashegDev 9657053e08 utf-8 рефактор чутка 2026-04-07 19:00:42 +00:00
SashegDev 27aafeaab3 небольшой рефактор 2026-04-07 18:54:16 +00:00
SashegDev af3183c6ef ВАЖНИ ФИКСЕС 2026-04-07 18:40:51 +00:00
SashegDev 5627c9cc6a Изменил версию и немного фиксов 2026-04-07 18:28:26 +00:00
SashegDev f0973e93df Readme модификейшин 2026-04-07 18:08:11 +00:00
SashegDev 02608a9856 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-07 18:01:49 +00:00
SashegDev c5c119fcb6 Попытка добавления проходок, аккаунтов, а так же доработка прокси 2026-04-07 17:50:29 +00:00
SashegDev bce719bdc1 Update README.md 2026-04-07 18:11:54 +03:00
SashegDev 2509a9b4e9 Орфографическая правка 2026-04-07 12:24:06 +03:00
SashegDev 7696cfb9fb REAMDE.md update, мяу 2026-04-07 11:17:36 +03:00
SashegDev 27a0366fb8 ДОБАВЛЕНИЕ ПРОКСИ РЕЖИМА ЙОООУ 1.0.5 2026-04-06 19:57:32 +00:00
SashegDev f3a6bb21a0 Багфиксы ClassPath 2026-04-06 18:17:07 +00:00
SashegDev 911f4120dd Smol Fixes Yoooo 2026-04-06 17:00:39 +00:00
SashegDev 81c34b44e6 Both | БЛЯЯЯ ЗАГРУЗКА ПАКОВ С СЕРВЕРА СЮДААА 2026-04-06 00:32:36 +00:00
SashegDev 1c5beff21d Server BugFixes + убрал генерацию sevrer команды т.к это уже в клиенте лол 2026-04-05 22:25:43 +00:00
SashegDev ea7b77b549 Немного рефактора 2026-04-05 18:45:31 +00:00
SashegDev 7fb2cdc059 Попытка заставить работать Forge 2026-04-05 16:18:39 +00:00
SashegDev a10bc16d86 ФАБРИК ПОДДЕРЖКАААААААА 2026-04-05 15:43:16 +00:00
SashegDev 385d33ae5b Починил загрузку ассетов, добавлена оптимизация
запуск Vanilla версий работает
2026-04-05 14:56:01 +00:00
SashegDev 06a1b71ab8 uuuh почему бы и нет 2026-04-05 10:47:28 +00:00
SashegDev f5cbd2e649 Изменение получения self-version 2026-04-05 10:46:32 +00:00
SashegDev f1ab0a6cdf КЛИЕНТ ЛАУНЧЕРА ЙОООО 2026-04-05 00:18:57 +00:00
SashegDev 27dcd97c15 перенос readme т.к я криворукий 2026-04-04 14:59:00 +00:00
SashegDev ca522e229b server update 2026-04-04 14:57:15 +00:00
SashegDev 897637434b test 2026-04-04 14:55:18 +00:00
SashegDev b03bb845a6 first commit 2026-04-04 14:49:24 +00:00
15 changed files with 1793 additions and 86 deletions
+24 -16
View File
@@ -21,15 +21,21 @@
## Состав дистрибутива
В архиве `ZernMC-win-<версия>.zip`:
**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
- Качает `JRE 47М``lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
- Локальные файлы `~/.zernmc` не трогает
- `zernmc.exe`основная версия с GUI (JavaFX)
- `zernmc-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. Войди в аккаунт и нажми **«Начать игру»**
### Установка первой сборки
+8
View File
@@ -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
)
+4
View File
@@ -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=
+552
View File
@@ -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()
}
+38
View File
@@ -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"/>
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false">
<arg value="-c"/>
<arg value="if command -v go >/dev/null 2>&amp;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>&amp;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(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<String, Object> info = new HashMap<>();
int modsCount = 0;
List<Map<String, Object>> 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<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);
}
}
info.put("modsCount", modsCount);
} 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", modsList.size());
info.put("mods", modsList);
List<String> 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<String, Object> 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<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,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(); }
}
+41 -5
View File
@@ -245,6 +245,16 @@
<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>
@@ -519,6 +529,15 @@
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</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>
@@ -570,7 +589,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> <span class="tag-wip">WIP</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span></button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
@@ -590,11 +609,28 @@
</div>
<div id="tab-custom" class="modal-tab-content">
<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 class="field">
<label data-i18n="install.custom.name">Pack Name</label>
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
</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">
+665 -36
View File
@@ -66,6 +66,10 @@ 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...',
@@ -164,6 +168,18 @@ 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',
@@ -211,6 +227,42 @@ 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': 'Настройки',
@@ -277,6 +329,10 @@ 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': 'Установка...',
@@ -375,6 +431,18 @@ 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': 'Клиенты',
@@ -422,6 +490,42 @@ 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': 'Туториал можно будет пройти в настройках.',
}
};
@@ -485,49 +589,157 @@ class ZernMCLauncher {
}
}
// ==================== BACKGROUND ====================
// ==================== BACKGROUND — hollow diamonds ====================
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
let t = 0, mode = 'login', modePulse = 0;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
let mode = 'login'; // login=blue, register=orange
let modeLerp = 0; // 0=login, 1=register
let exitProgress = 0; // 0..1 fly-out
let exitActive = false;
let rafId = 0;
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.setWaveMode = m => { mode = m; modePulse = 1; };
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
window.addEventListener('resize', resize);
const draw = () => {
ctx.clearRect(0, 0, c.width, c.height);
const w = c.width, h = c.height;
modePulse += (0 - modePulse) * 0.03;
const baseAlpha = 0.04 + modePulse * 0.03;
const amp = 20 + modePulse * 10;
const freq = 0.008 + modePulse * 0.003;
const speed = 0.008;
const lines = 5;
for (let i = 0; i < lines; i++) {
const yOff = (h / (lines + 1)) * (i + 1);
const alpha = baseAlpha * (1 - i * 0.12);
ctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
const y = yOff + wave + wave2;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
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 }
}
ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
t++;
};
const anim = () => { draw(); requestAnimationFrame(anim); };
const diamonds = [];
const COUNT = 44;
const seed = () => Math.random();
const lerp = (a,b,t) => a + (b-a)*t;
const lerpColor = (ca, cb, t) => ({
r: lerp(ca.r, cb.r, t),
g: lerp(ca.g, cb.g, t),
b: lerp(ca.b, cb.b, t),
a: lerp(ca.a, cb.a, t)
});
const rebuild = () => {
diamonds.length = 0;
const w = c.width / dpr, h = c.height / dpr;
// distribute with jitter, keep margins so diamonds not clipped
for (let i = 0; i < COUNT; i++) {
const layerRoll = seed();
let layer, size;
if (layerRoll < 0.35) { layer = 'far'; size = 12 + seed()*6; }
else if (layerRoll < 0.70) { layer = 'mid'; size = 22 + seed()*10; }
else { layer = 'near'; size = 36 + seed()*16; }
// avoid center where login form sits (roughly 360x420 centered)
let x, y, attempts = 0;
do {
x = seed() * (w - size*1.4) + size*0.7;
y = seed() * (h - size*1.4) + size*0.7;
attempts++;
} while (attempts < 5 && Math.abs(x - w/2) < 190 && Math.abs(y - h/2) < 220);
diamonds.push({
x, y, ox: x, oy: y,
size,
layer,
rot: 45 * Math.PI/180,
driftX: (seed()-0.5)*0.6,
driftY: 0.15 + seed()*0.45,
phase: seed()*Math.PI*2,
depthMul: layer==='far'?0.15 : layer==='mid'?0.45 : 1.0
});
}
};
const resize = () => {
const w = window.innerWidth, h = window.innerHeight;
c.width = w * dpr; c.height = h * dpr;
c.style.width = w + 'px'; c.style.height = h + 'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
rebuild();
};
window.addEventListener('resize', resize);
// public API
this.setDiamondMode = (m) => { mode = m==='register'?'register':'login'; };
// keep compat
this.setWaveMode = this.setDiamondMode;
this.animateDiamondExit = () => {
if (reduceMotion || exitActive) return Promise.resolve();
exitActive = true;
exitProgress = 0;
return new Promise(res => {
const start = performance.now();
const dur = 900;
const ease = t => t<0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
const step = (now) => {
const p = Math.min(1, (now - start)/dur);
exitProgress = ease(p);
if (p < 1) requestAnimationFrame(step);
else { exitActive = false; res(); }
};
requestAnimationFrame(step);
});
};
let tick = 0;
const draw = () => {
const w = c.width / dpr, h = c.height / dpr;
ctx.clearRect(0, 0, w, h);
// lerp palette between modes
const target = mode === 'register' ? 1 : 0;
modeLerp += (target - modeLerp) * 0.06;
const palFar = lerpColor(PALETTES.login.far, PALETTES.register.far, modeLerp);
const palMid = lerpColor(PALETTES.login.mid, PALETTES.register.mid, modeLerp);
const palNear = lerpColor(PALETTES.login.near, PALETTES.register.near, modeLerp);
// exit parallax offset (vh in px)
const farExit = reduceMotion ? 0 : exitProgress * (h*0.40);
const midExit = reduceMotion ? 0 : exitProgress * (h*0.70);
const nearExit = reduceMotion ? 0 : exitProgress * (h*1.10);
for (const d of diamonds) {
const pal = d.layer==='far'? palFar : d.layer==='mid'? palMid : palNear;
// idle drift
const t = tick * 0.001 + d.phase;
const dx = Math.sin(t * 0.6 + d.ox*0.01) * d.driftX;
const dyBase = reduceMotion ? 0 : (tick * 0.015 * d.depthMul * d.driftY);
// exit offset + breathing scale
const exitOff = d.layer==='far'? farExit : d.layer==='mid'? midExit : nearExit;
const breath = reduceMotion ? 1 : 1 + Math.sin(t*0.8)*0.03;
const x = d.ox + dx;
let y = (d.oy + dyBase - exitOff) % (h + d.size*2);
if (y < -d.size) y += h + d.size*2;
const s = d.size * breath * (exitActive && d.layer==='near' ? (1 + exitProgress*0.06) : 1);
const alpha = pal.a * (exitActive ? (1 - exitProgress*0.7) : 1);
if (alpha < 0.02) continue;
ctx.save();
ctx.translate(x, y);
ctx.rotate(d.rot);
ctx.strokeStyle = `rgba(${Math.round(pal.r)},${Math.round(pal.g)},${Math.round(pal.b)},${alpha})`;
ctx.lineWidth = d.layer==='far'? 1 : d.layer==='mid'? 1.1 : 1.4;
// hollow diamond: centered square
ctx.strokeRect(-s/2, -s/2, s, s);
ctx.restore();
}
tick += 16;
rafId = requestAnimationFrame(draw);
};
// pause when hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelAnimationFrame(rafId);
else draw();
});
resize();
anim();
draw();
}
// ==================== API ====================
@@ -631,6 +843,22 @@ 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');
@@ -639,6 +867,21 @@ 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;
@@ -728,7 +971,8 @@ class ZernMCLauncher {
container.style.transform = '';
container.style.opacity = '';
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
}, 150);
}
@@ -739,6 +983,8 @@ 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');
}
@@ -771,6 +1017,7 @@ class ZernMCLauncher {
this.startFriendStatusHeartbeat();
this.enhanceSelects();
this.updateAdminNav();
setTimeout(()=> this.checkOnboarding(), 600);
}
async loadServerPacksList() {
@@ -1107,6 +1354,7 @@ 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);
});
@@ -1262,6 +1510,9 @@ 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) {
@@ -1330,6 +1581,83 @@ 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 ====================
@@ -2595,12 +2923,313 @@ class ZernMCLauncher {
}
}
replayOnboarding() {
localStorage.removeItem('zern_onboardBlocks');
localStorage.removeItem('zern_onboardVersion');
// force show all blocks
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
this.showOnboarding(BLOCKS, curVer, []);
}
esc(s) {
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">&times;</button></div>
<div class="modal-body pack-settings-form">
<div class="field"><label>${t('ctx.name')}</label><input type="text" id="ps-name" value="${this.esc(inst.name)}" ${isServer?'disabled':''}></div>
<div class="field"><label>${t('ctx.loader')}</label>
<select id="ps-loader" ${isServer?'disabled':''}>
<option value="vanilla" ${inst.loaderType==='vanilla'?'selected':''}>Vanilla</option>
<option value="fabric" ${inst.loaderType==='fabric'?'selected':''}>Fabric</option>
<option value="forge" ${inst.loaderType==='forge'?'selected':''}>Forge</option>
<option value="neoforge" ${inst.loaderType==='neoforge'?'selected':''}>NeoForge</option>
</select>
</div>
<div class="field ${inst.loaderType==='vanilla'?'hidden':''}" id="ps-loader-ver-field"><label>${t('ctx.loaderVersion')}</label><input type="text" id="ps-loader-ver" value="${this.esc(inst.loaderVersion||'')}" placeholder="0.16.0" ${isServer?'disabled':''}></div>
${isServer?'<p style="font-size:12px;color:var(--text-muted)">'+t('mods.disabled')+' — server pack is read-only</p>':''}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px">
<button class="btn-secondary" id="ps-cancel">${t('ctx.cancel')}</button>
<button class="btn-primary" id="ps-save" ${isServer?'disabled':''}>${t('ctx.save')}</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e=>{ if(e.target===modal) modal.remove(); });
document.getElementById('ps-close').addEventListener('click', ()=>modal.remove());
document.getElementById('ps-cancel').addEventListener('click', ()=>modal.remove());
const loaderSel = document.getElementById('ps-loader');
loaderSel?.addEventListener('change', e=>{
const f=document.getElementById('ps-loader-ver-field');
if(e.target.value==='vanilla') f.classList.add('hidden'); else f.classList.remove('hidden');
});
document.getElementById('ps-save').addEventListener('click', async ()=>{
const newName = document.getElementById('ps-name').value.trim();
const loader = document.getElementById('ps-loader').value;
const loaderVer = document.getElementById('ps-loader-ver').value.trim();
const body = {name: inst.name};
if (newName && newName!==inst.name) body.newName=newName;
if (loader) body.loader=loader;
if (loaderVer) body.loaderVersion=loaderVer;
const r = await this.req('/pack/settings', {method:'POST', body: JSON.stringify(body)});
if (r.success) { modal.remove(); this.toast(t('ctx.save')+' ✓','success'); await this.loadInstances(); if(newName) this.state.selectedPack=null; }
else this.toast(r.error||'Error','error');
});
}
async deletePack(name) {
if (!confirm(tr('ctx.deleteConfirm', null, {name}))) return;
const r = await this.req('/pack/delete', {method:'POST', body: JSON.stringify({name})});
if (r.success) { this.toast(t('ctx.delete')+': '+name,'success'); if(this.state.selectedPack?.name===name) this.showEmptyState(); await this.loadInstances(); }
else this.toast(r.error||'Error','error');
}
// ==================== ONBOARDING ====================
async checkOnboarding() {
// show only on first login or version diff, highlight-only — persist via Config + localStorage
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
let storedVer = localStorage.getItem('zern_onboardVersion') || '';
let completed = (localStorage.getItem('zern_onboardBlocks') || '').split(',').filter(Boolean);
try {
const r = await this.req('/settings');
if (r.success && r.data) {
const srvVer = r.data.onboardedVersion || '';
const srvBlocks = (r.data.completedOnboardBlocks || '').split(',').filter(Boolean);
if (!storedVer && srvVer) { storedVer = srvVer; localStorage.setItem('zern_onboardVersion', srvVer); }
if (completed.length===0 && srvBlocks.length) { completed = srvBlocks; localStorage.setItem('zern_onboardBlocks', srvBlocks.join(',')); }
// if local newer, sync to server
if (storedVer && !srvVer) { this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: storedVer, completedOnboardBlocks: completed.join(',')})}); }
}
} catch(e){}
const firstLogin = !storedVer; // no onboard yet
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
let toShow;
if (firstLogin) toShow = BLOCKS;
else {
// version diff: show only blocks added after storedVer
const isNewer = (a,b)=> a.localeCompare(b, undefined, {numeric:true})>0;
toShow = BLOCKS.filter(b=> !completed.includes(b.id) && isNewer(b.addedIn, storedVer));
if (toShow.length===0 && storedVer !== curVer) {
// version bump but all blocks already completed -> no show
localStorage.setItem('zern_onboardVersion', curVer);
return;
}
}
if (toShow.length===0) return;
this.showOnboarding(toShow, curVer, completed);
}
showOnboarding(blocks, curVer, completed) {
let idx = 0;
const overlay = document.createElement('div');
overlay.className = 'onboard-overlay';
overlay.id = 'onboard-overlay';
document.body.appendChild(overlay);
const card = document.createElement('div');
card.className = 'onboard-card';
card.id = 'onboard-card';
document.body.appendChild(card);
const spot = document.createElement('div');
spot.className = 'onboard-spot';
spot.id = 'onboard-spot';
const label = document.createElement('div');
label.className = 'onboard-spot-label';
label.id = 'onboard-spot-label';
document.body.appendChild(spot);
document.body.appendChild(label);
const resolveTarget = (b) => {
const sels = Array.isArray(b.sel) ? b.sel : [b.sel];
for (const s of sels) {
for (const el of document.querySelectorAll(s)) {
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
const visible = r.width>0 && r.height>0 && style.display!=='none' && style.visibility!=='hidden' && el.offsetParent!==null;
if (!visible) continue;
if (el.closest('.hidden')) continue;
// check collapsed accordion
if (el.closest('.pack-list-collapse') && el.closest('.section-collapsible.collapsed')) continue;
return el;
}
}
return null;
};
const ensureView = async (b) => {
if (b.requiresView==='settings' || b.id==='pass' || b.id==='logs') {
this.switchView('settings');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
} else if (b.id==='install' || b.id==='manage' || b.id==='mods') {
this.switchView('packs');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
}
};
const persist = (vers, blocksArr) => {
const v = vers || curVer;
const b = blocksArr.join(',');
localStorage.setItem('zern_onboardBlocks', b);
localStorage.setItem('zern_onboardVersion', v);
// persist to backend Config so WebView localStorage volatility doesn't resurrect tutorial
this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: v, completedOnboardBlocks: b})}).catch(()=>{});
};
const goNext = async () => {
if (document.getElementById('onboard-never')?.checked) {
const all = [...new Set(blocks.map(x=>x.id).concat(completed))];
persist(curVer, all);
closeAll(); return;
}
if (idx===blocks.length-1) {
const newCompleted = [...new Set([...completed, ...blocks.map(x=>x.id)])];
persist(curVer, newCompleted);
closeAll();
} else { idx++; await render(); }
};
const goBack = async () => { if(idx>0){ idx--; await render(); } };
const showConfirm = (onYes) => {
let m = document.createElement('div');
m.className='modal-backdrop'; m.style.zIndex='410';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.confirmTitle')||'Точно пропустить туториал?'}</h3><button class="modal-close" id="c-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.confirmDesc')||'Вы уверены?'}</p><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px"><button class="btn-secondary btn-sm" id="c-no">${t('onboard.no')||'Нет'}</button><button class="btn-primary btn-sm" id="c-yes">${t('onboard.yes')||'Да'}</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#c-close').onclick=close;
m.querySelector('#c-no').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
m.querySelector('#c-yes').onclick=()=>{ close(); onYes(); };
};
const showInfo = () => {
let m=document.createElement('div'); m.className='modal-backdrop'; m.style.zIndex='411';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.infoTitle')||'Туториал'}</h3><button class="modal-close" id="i-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.infoDesc')||'Туториал можно будет пройти в настройках.'}</p><div style="display:flex;justify-content:flex-end;margin-top:16px"><button class="btn-primary btn-sm" id="i-ok">OK</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#i-close').onclick=close;
m.querySelector('#i-ok').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
};
const requestClose = () => {
showConfirm(()=>{ closeAll(); showInfo(); });
};
const render = async () => {
const b = blocks[idx];
await ensureView(b);
card.innerHTML = `
<div class="onboard-progress">${tr('onboard.progress', null, {current:String(idx+1), total:String(blocks.length)})}</div>
<h3>${t(b.titleKey)}</h3>
<p>${t(b.descKey)}</p>
<div class="onboard-actions">
<button class="btn-secondary btn-sm" id="onboard-skip">${t('onboard.skip')}</button>
${idx>0?'<button class="btn-secondary btn-sm" id="onboard-back">'+t('onboard.back')+'</button>':''}
<button class="btn-primary btn-sm" id="onboard-next">${idx===blocks.length-1? t('onboard.done'): t('onboard.next')}</button>
</div>
<label style="display:flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--text-muted)"><input type="checkbox" id="onboard-never"> ${t('onboard.neverShow')}</label>
`;
const el = resolveTarget(b);
if (el) {
const r = el.getBoundingClientRect();
spot.style.left = (r.left-4)+'px';
spot.style.top = (r.top-4)+'px';
spot.style.width = (r.width+8)+'px';
spot.style.height = (r.height+8)+'px';
spot.style.display='block';
label.textContent = t(b.titleKey);
label.style.left = (r.left + r.width/2)+'px';
label.style.top = (r.top)+'px';
label.style.display='block';
el.scrollIntoView({block:'center', behavior:'smooth'});
} else {
spot.style.display='none'; label.style.display='none';
}
document.getElementById('onboard-skip').addEventListener('click', requestClose);
document.getElementById('onboard-next').addEventListener('click', goNext);
const backBtn=document.getElementById('onboard-back');
if(backBtn) backBtn.addEventListener('click', goBack);
};
const closeAll = ()=>{
overlay.remove(); card.remove(); spot.remove(); label.remove();
document.removeEventListener('keydown', escHandler);
};
spot.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
label.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
const escHandler = (e)=>{ if(e.key==='Escape') requestClose(); };
document.addEventListener('keydown', escHandler);
overlay.addEventListener('click', (e)=>{ if(e.target===overlay) requestClose(); });
render();
}
}
function renderFriendItem(f) {
+53 -3
View File
@@ -29,8 +29,10 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, * { -webkit-user-select: none; user-select: none; }
input, textarea { -webkit-user-select: text; user-select: text; }
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 { font-size: 14px; }
@@ -45,8 +47,11 @@ body {
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.08; pointer-events: none;
z-index: 0; opacity: 0.14; pointer-events: none;
}
#login-screen.exiting { pointer-events: none; }
.login-container.exit { will-change: transform, opacity, filter; }
#bg-canvas.bg-exit { will-change: transform; }
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
@@ -331,6 +336,9 @@ 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;
@@ -983,3 +991,45 @@ 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
View File
@@ -19,8 +19,8 @@
</modules>
<properties>
<revision>1.1.0</revision>
<hotfix>0</hotfix>
<revision>1.1.1</revision>
<hotfix>2</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+15
View File
@@ -1798,6 +1798,21 @@ 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"""
+54 -5
View File
@@ -70,7 +70,7 @@
<section class="section server" id="server">
<div class="section-head">
<span class="section-index">01 / 06</span>
<span class="section-index">01 / 07</span>
<h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2>
<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 / 06</span>
<span class="section-index">02 / 07</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 / 06</span>
<span class="section-index">03 / 07</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,13 +382,62 @@
<p class="download-desc">Windows · <span id="download-size"></span></p>
</div>
</div>
<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>
<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>
<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>
+116 -13
View File
@@ -13,6 +13,7 @@
'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 и не только.',
@@ -126,6 +127,7 @@
'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.',
@@ -253,7 +255,13 @@
document.documentElement.lang = currentLang;
const toggle = document.getElementById('langToggleLabel');
if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU';
if (toggle) toggle.textContent = currentLang === 'ru' ? 'RU' : 'EN';
// Section indices: add a tooltip explaining what "02 / 07" means.
document.querySelectorAll('.section-index').forEach((el) => {
const m = (el.textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/);
if (m) el.setAttribute('title', tr('section.of').replace('{n}', m[1]).replace('{m}', m[2]));
});
document.querySelectorAll('[data-i18n]').forEach((el) => {
el.textContent = tr(el.dataset.i18n);
@@ -315,6 +323,8 @@
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;
@@ -330,6 +340,22 @@
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);
}
@@ -423,6 +449,10 @@
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';
@@ -431,11 +461,10 @@
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;
@@ -456,38 +485,74 @@
const dot = document.createElement('span');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => goto(i));
dot.addEventListener('click', () => goto(i, true));
dotsWrap.appendChild(dot);
});
let index = 0;
let timer = null;
function goto(i) {
function schedule(delay) {
stop();
timer = setInterval(() => nextSlide(false), delay);
}
function isVideoAt(i) {
const h = HIGHLIGHTS[i];
return h && h.type === 'video';
}
function goto(i, manual) {
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
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();
}
}
});
function nextSlide() { goto(index + 1); }
function prevSlide() { goto(index - 1); }
function start() {
if (isVideoAt(index)) {
// A video slide always advances on its 'ended' event, never on a timer.
stop();
timer = setInterval(nextSlide, 6500);
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 start() { goto(index, false); }
function stop() {
if (timer) { clearInterval(timer); timer = null; }
}
if (prev) prev.addEventListener('click', () => { prevSlide(); start(); });
if (next) next.addEventListener('click', () => { nextSlide(); start(); });
// 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); });
track.addEventListener('mouseenter', stop);
track.addEventListener('mouseleave', start);
start();
goto(0, false);
}
function stripMarkup(text) {
@@ -654,6 +719,43 @@
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();
@@ -665,6 +767,7 @@
loadNews();
loadServerStatus();
setInterval(loadServerStatus, 15000);
loadGithubLanguages();
initCarousel();
initPreview();
initNav();
+4
View File
@@ -1,6 +1,10 @@
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