191 Commits

Author SHA1 Message Date
SashegDev 025a21d01f site: add launcher-style auth (login/register modal, JWT localStorage) + cabinet with Сборки tab (list /packs, download /pack/{name}/zip with Bearer, pass check, manual Prologue intercept) 2026-09-02 04:35:57 +00:00
SashegDev 146a56cdff fix: pack zip route shadowing (zip before file, 404 -> 403), manual ZernPrologue specify NeoForge 21.1.228 with warning 2026-09-02 04:30:44 +00:00
SashegDev 3006408447 site+server: add manual Zern Prologue pack variant (download zip + instructions) — site card with path %USERPROFILE%\.zernmc\instances\ZernPrologue, prism/multimc import, endpoint /pack/ZernPrologue/zip (requires pass) 2026-09-02 00:57:43 +00:00
SashegDev 48f73ef0d4 revert: Go absolute installers GUI bootstrap-Inno 1.1.1.5
- installer/main.go: Go absolute GUI wizard (Welcome->Dir->Progress->Finish) via dialog, bootstrap colors, embed offline.zip for offline 104M standalone, online 5.8M downloads JRE+meta full files
- go.mod: +walk/win for Inno-like wizard (Go 1.21, sys 0.28)
- pom hotfix 5, Go build: online small (empty offline.zip) + offline large (embed ZernMC-win-*.zip), both signed, Content-Disposition kept
2026-09-01 15:29:12 +00:00
SashegDev b864e17921 feat: 1.1.1.4 GUI bootstrap-Inno installer + standalone offline
- Installer.java: Swing dark Bootstrap (bg 0c0c12, accent e94560) Inno wizard (sidebar steps, Welcome->Dir->Progress->Finish), JRE + meta download via /launcher/file, offline via embedded /offline.zip + appended ZIP + adjacent fallback, shortcut/uninstall
- pom: hotfix 4, launch4j 2 exe ZernMC-Online/Offline-Setup-1.1.1.4.exe (icon, versionInfo, header gui, jar shaded), offline.zip resource
- server/main.py: Content-Disposition attachment filename for online/offline, BUILDS_DIR absolute
- Builds: Online 6.3M (downloads), Offline 103M (standalone embedded 98M zip), both signed, site headers fixed
2026-09-01 14:14:30 +00:00
SashegDev 1900db3caf fix: 1.1.1.3 online installer 5.8M + mods toggle + ZGC vs G1GC conflict
- server/main.py BUILDS_DIR absolute + /launcher/download/online,offine-setup
- site index/js online->/download/online 5.8M
- LaunchCommandBuilder skip G1GC when systemBasedJvm (ZGC conflict -> Bootstrap crash)
- JFXLauncher toggle robust rename + FileAlreadyExists fix
2026-09-01 13:51:30 +00:00
SashegDev 5944c7b1b6 site: switch git widget branch ui -> main (19a1633) for main merge 2026-09-01 06:49:40 +00:00
SashegDev 19a1633ec4 Merge ui -> main: 1.1.1.2 hybrid Go installer + hollow diamonds + custom packs + mods + onboarding + git widget 2026-09-01 06:49:26 +00:00
SashegDev 75738ae127 site: remove GitHub mirror button from git widget, keep only Gitea (languages fetch now Gitea-only) 2026-09-01 06:40:20 +00:00
SashegDev 5985887fb8 site: add GitHub-like language bar (Java 49.4% Python 25.4% JS 12.7% CSS 7.3% HTML 3.5% Go 1.7% — 32k loc) + live fetch from GitHub/Gitea API 2026-09-01 06:39:06 +00:00
SashegDev 8d5bcca1d5 site: add launcher git widget (Gitea sasheg/launcher ui/bcd10cc v1.1.1.2 + GitHub mirror, clone cmd) 2026-09-01 06:36:05 +00:00
SashegDev bcd10cce06 feat: 1.1.1.2 hybrid Go installer (online 5.8M/offline 98M, выбор C:\ZernMC + ярлык ZernMC Launcher.lnk, uninstall.exe, DataDir ~/.zernmc) — hollow-ромбы, custom-сборки, моды с правами, ПКМ без Reload, sticky Играть, онбординг Config persist + /launcher/download/jre 2026-09-01 06:35:28 +00:00
SashegDev 592de204b6 site: unify section indices, show current lang, smarter highlight carousel
- Section badges are now consistently 'N / 07' everywhere (was mixing 06/07)
  and get a localized title tooltip explaining 'Section N of 7'.
- Language toggle now shows the CURRENT language (RU/EN) instead of the
  next one, so the state is obvious before clicking.
- Carousel: manual navigation (arrows/dots) holds the slide 22s before
  resuming, idle auto-advance returns to 6.5s; video slides no longer loop
  and only advance when playback actually ends.
2026-09-01 06:35:28 +00:00
SashegDev af415157ac Merge pull request 'Слияние ui -> main' (#1) from ui into main
Reviewed-on: #1
2026-08-20 13:54:02 +00:00
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
74 changed files with 7614 additions and 429 deletions
+1
View File
@@ -20,3 +20,4 @@ packs/
.__pycache__
.pytest_cache
.venvtodo.txt
*.bak
+63 -38
View File
@@ -8,32 +8,50 @@
- Написан полностью с нуля, без использования готовых решений
- Удобная загрузка и обновление сборок прямо с сервера ZernMC
- Чистый и относительно читаемый код как клиента, так и сервера
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
- Возможность запуска клиента даже без графического интерфейса (TUI-режим)
- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`)
- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI
- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
- Инкрементальное обновление JavaFX и автообновление лаунчера
- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
- Полное отсутствие рекламы и телеметрии
- Не собирает никаких данных о пользователе
- Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.exe`
- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe`
- Есть система аккаунтов и проходок
## Состав дистрибутива
**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
- Качает `JRE 47М``lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
- Локальные файлы `~/.zernmc` не трогает
**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
- `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`.
## Чего пока нет в лаунчере
- Графического интерфейса (GUI) — только TUI
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
- Поддержки **Forge** (в разработке)
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
- Раздела новостей об обновлениях Minecraft и лаунчера
- Выбора готовых пресетов оптимизации JVM
- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память)
- Поддержки Quilt, LabyMod и других лоадеров
- Кастомных модов (UI, спавнеры, DPI, карточки)
- Сайта для лаунчера и сервера
- Трекинга наигранного времени
- Сайта для лаунчера и сервера (текущий требует переделки)
- Трекинга наигранного времени (обновление раз в час вместо минуты)
## Что планируется доработать в ближайшее время
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
- **GUI мод** — привести в единый стиль с главным меню
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией
- **Сайт** — полноценный сайт для лаунчера и сервера
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
@@ -50,7 +68,7 @@
### Управление в лаунчере (TUI)
Лаунчер использует **текстовый интерфейс (TUI)**:
Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**:
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
- `ENTER` — выбор пункта
@@ -58,27 +76,35 @@
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
Основная версия `zernmc.exe` запускает графический интерфейс JavaFX.
### Расположение сборок
Все установленные сборки хранятся в папке:
- **Windows**: `%USERPROFILE%\.zernmc\instances`
- **Linux**: `~/.zernmc/instances`
- **Linux**: `~/.zernmc\instances`
Если сборка не удаляется корректно — удалите папку вручную.
### Логи
Логи лаунчера пишутся в файл `latest.log` рядом с исполняемым файлом; старые логи архивируются в `.log.gz` (в стиле Minecraft).
## Как скачать и запустить
Пока автообновления и GitHub Releases не настроены, скачать последнюю версию можно по ссылкам:
**Скачать лаунчер (выбери один):**
- **Онлайн-установщик (рекомендуется)** — `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. Распакуйте в удобную папку
3. Запустите `ZernMCLauncher.exe`
4. Нажмите **«Начать игру»**
**Инструкция (оба):**
1. Скачай online exe **или** offline zip
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
4. Войди в аккаунт и нажми **«Начать игру»**
### Установка первой сборки
@@ -86,28 +112,31 @@
1. Выберите **«Установить новую сборку»**
2. Выберите тип сборки:
- **ZernMC** — готовые сборки с нашего сервера
- **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
- **Модовый загрузчик** — Fabric / Forge / NeoForge
- **Ваниль** — чистая версия от Mojang
**Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5)
## Возможные проблемы и решения
### «Error: Invalid or corrupt jarfile» при запуске exe
Перекачайте архив целиком из https://api.zernmc.ru/launcher/download/latest и распакуйте заново — старые версии (до 1.0.16.17) были повреждены подписью и не запускаются.
### Проблемы с загрузкой ассетов
Иногда лаунчер неправильно определяет Asset Index.
**Решение:**
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
### Forge версии
Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
### Forge / NeoForge
Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим.
### Прокси-режим
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
## Как сообщить об ошибке (Issue)
1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
1. Скопируйте весь текст ошибки из логов (`latest.log`)
2. Укажите, что вы делали перед ошибкой
3. Укажите версию лаунчера
4. Создайте Issue на GitHub
@@ -118,25 +147,21 @@
git clone https://github.com/SashegDev/launcher
```
Откройте `launcher/launcher/pom.xml`
отредактируете пути для билда, так как сейчас - он расчитан на разработку только на моём сервере
отредактируйте пути для билда, так как сейчас - он рассчитан на разработку только на своём сервере
```bash
mvn clean package
```
можете начинать!
**Сборка exe на Linux требует:** `jsign` (`~/tools/jsign/jsign.jar`), файлы сертификата в `/root/cert/codesign/`, встроенный JRE в `~/launcher/jre/jre21`.
### Благодарность
Glitch135 - За бета-тест лаунчера и фидбеки
### помощь
Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
- Glitch135 — за бета-тест лаунчера и фидбеки
Буду рад помощи в разработке!
Особенно приветствуется помощь с:
- Переходом на нормальный графический интерфейс (GUI)
- Улучшением генерации команды запуска
- Добавлением поддержки Forge / NeoForge / Quilt
- Добавлением поддержки Quilt / LabyMod
- Стабилизацией серверной части
- вообще улучшением клиента
- Вообще улучшением клиента
+12
View File
@@ -0,0 +1,12 @@
module zernmc-installer
go 1.21
require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
)
+13
View File
@@ -0,0 +1,13 @@
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"archive/zip"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/sqweek/dialog"
)
//go:embed offline.zip
var offlineFS embed.FS
var (
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from embedded zip)")
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
)
const (
defaultInstallDir = "C:\\ZernMC"
serverBase = "https://api.zern.cc"
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
)
func main() {
flag.Parse()
isOnline := *onlineFlag && !*offlineFlag
// GUI mode on Windows unless --cli
hasCLI := false
for _, a := range os.Args { if a=="--cli" { hasCLI=true } }
if runtime.GOOS=="windows" && !hasCLI {
// try GUI, fallback to console on error
if err := runGUI(isOnline); err != nil {
fmt.Printf("GUI failed %v, fallback console\n", err)
runConsole(isOnline)
}
return
}
runConsole(isOnline)
}
func runConsole(isOnline bool) {
dir := *installDirFlag
if dir == "" { dir = askInstallDir() }
if dir == "" { fmt.Println("Installation cancelled"); os.Exit(0) }
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
if err := os.MkdirAll(dir, 0755); err != nil { fatal("Cannot create directory: %v", err) }
createShortcut := *shortcutFlag
if !isFlagSet("shortcut") { createShortcut = askShortcut() }
var err error
if isOnline { err = installOnline(dir, createShortcut, nil) } else {
zipPath := *offlineZipFlag
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
if zipPath=="" {
// try embedded
err = installOfflineEmbedded(dir, createShortcut)
} else { err = installOffline(dir, zipPath, createShortcut) }
}
if err != nil { fatal("%v", err) }
fmt.Println("\nInstallation complete!")
}
func runGUI(isOnline bool) error {
// Fallback to console dialogs if GUI not available (walk requires windows)
// For now use sqweek/dialog wizard to mimic Inno bootstrap
dir := *installDirFlag
if dir == "" {
// Welcome
ok := dialog.Message("Добро пожаловать в ZernMC\n\nМастер установит ZernMC Launcher на ваш компьютер.\nОнлайн — скачает 47М JRE + 50М лаунчер | Оффлайн — распакует встроенный архив\n\nНажмите ОК чтобы выбрать папку C:\\ZernMC").Title("ZernMC Setup — Приветствие").YesNo()
if !ok { return fmt.Errorf("cancelled") }
d, err := dialog.Directory().Title("Выберите папку установки — ZernMC").Browse()
if err != nil || d=="" { d = defaultInstallDir }
if !strings.Contains(strings.ToLower(d), "zernmc") { d = filepath.Join(d, "ZernMC") }
dir = d
}
if err := os.MkdirAll(dir, 0755); err != nil { dialog.Message("Ошибка создания папки: %v", err).Title("Ошибка").Error(); return err }
createShortcut := *shortcutFlag
if !isFlagSet("shortcut") { createShortcut = dialog.Message("Создать ярлык на рабочем столе?").Title("Ярлык").YesNo() }
// Progress via console + dialog
fmt.Printf("Установка в %s ...\n", dir)
var err error
if isOnline {
err = installOnline(dir, createShortcut, func(cur, total int, name string){
fmt.Printf("[%d/%d] %s\n", cur, total, name)
})
} else {
zipPath := *offlineZipFlag
if zipPath=="" { zipPath = findAdjacentOfflineZip() }
if zipPath!="" { err = installOffline(dir, zipPath, createShortcut) } else { err = installOfflineEmbedded(dir, createShortcut) }
}
if err != nil { dialog.Message("Ошибка установки: %v", err).Title("Ошибка").Error(); return err }
dialog.Message("Установка завершена!\n\nЗапустите с ярлыка или C:\\ZernMC\\zernmc.exe").Title("ZernMC Setup — Готово").Info()
// launch?
if dialog.Message("Запустить ZernMC сейчас?").Title("Готово").YesNo() {
exec.Command(filepath.Join(dir, "zernmc.exe")).Start()
}
return nil
}
func isFlagSet(name string) bool { found:=false; flag.Visit(func(f *flag.Flag){ if f.Name==name { found=true } }); return found }
func askInstallDir() string {
if runtime.GOOS=="windows" {
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
if err==nil && dir!="" {
if !strings.Contains(strings.ToLower(dir), "zernmc") { dir = filepath.Join(dir, "ZernMC") }
return dir
}
fmt.Printf("Dialog cancelled (%v), using default %s\n", err, defaultInstallDir)
}
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
var input string; fmt.Scanln(&input); input=strings.TrimSpace(input)
if input=="" { input=defaultInstallDir }
return input
}
func askShortcut() bool {
if runtime.GOOS!="windows" { return false }
return dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
}
func fatal(format string, args ...interface{}) {
msg:=fmt.Sprintf(format, args...); fmt.Fprintln(os.Stderr, msg)
if runtime.GOOS=="windows" { dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error() }
os.Exit(1)
}
func installOnline(dir string, createShortcut bool, progress func(cur,total int, name string)) error {
fmt.Println("Online installation — downloading JRE + launcher files...")
jreDir:=filepath.Join(dir, "lib", "jre21"); javaExe:=filepath.Join(jreDir, "bin", "java.exe")
if _, err:=os.Stat(javaExe); err!=nil {
fmt.Println("Downloading JRE 21 (47 MB)...")
jreURL:=serverBase+"/launcher/download/jre"; tmpZip:=filepath.Join(os.TempDir(), jreZipName)
if err:=downloadFile(jreURL, tmpZip); err!=nil {
mirrors:=[]string{"https://api.zernmc.ru/launcher/download/jre","https://api.zernmc.online/launcher/download/jre"}
var lastErr error
for _, m:=range mirrors { fmt.Printf("Retry mirror %s\n", m); if err:=downloadFile(m, tmpZip); err==nil { lastErr=nil; break } else { lastErr=err } }
if lastErr!=nil { return fmt.Errorf("JRE download failed: %w", lastErr) }
}
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
if err:=unzip(tmpZip, dir); err!=nil { return fmt.Errorf("unzip JRE: %w", err) }
candidate:=filepath.Join(dir, "jre21")
if _, err:=os.Stat(candidate); err==nil { os.MkdirAll(filepath.Join(dir, "lib"), 0755); os.RemoveAll(jreDir); if err:=os.Rename(candidate, jreDir); err!=nil { if err:=copyDir(candidate, jreDir); err!=nil { return err }; os.RemoveAll(candidate) } }
os.Remove(tmpZip)
if _, err:=os.Stat(javaExe); err!=nil { return fmt.Errorf("JRE unpack failed, %s not found", javaExe) }
fmt.Println("JRE ready")
} else { fmt.Println("JRE already present, skipping") }
fmt.Println("Fetching launcher meta...")
meta, err:=fetchMeta()
if err!=nil { return fmt.Errorf("fetch meta: %w", err) }
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
for i, f:=range meta.Files {
rel:=f.Path
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") { continue }
dest:=filepath.Join(dir, filepath.FromSlash(rel))
need:=true
if fi, err:=os.Stat(dest); err==nil && !fi.IsDir() {
hash, _:=fileSHA256(dest)
if hash==strings.TrimPrefix(f.Hash, "sha256:") { need=false }
}
if !need { continue }
if progress!=nil { progress(i+1, len(meta.Files), rel) }
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
url:=fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
if err:=downloadFile(url, dest); err!=nil { return fmt.Errorf("download %s: %w", rel, err) }
hash, _:=fileSHA256(dest)
if hash!=strings.TrimPrefix(f.Hash, "sha256:") { fmt.Printf(" WARNING hash mismatch for %s\n", rel) }
}
if err:=os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err!=nil { fmt.Printf("warning write build.version: %v\n", err) }
fmt.Println("Launcher files ready")
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
return nil
}
func installOfflineEmbedded(dir string, createShortcut bool) error {
fmt.Println("Offline embedded installation...")
data, err:=offlineFS.ReadFile("offline.zip")
if err!=nil {
// try adjacent
zp:=findAdjacentOfflineZip()
if zp!="" { return installOffline(dir, zp, createShortcut) }
return fmt.Errorf("offline.zip not embedded, put ZernMC-win-*.zip next to installer or rebuild with offline.zip: %w", err)
}
tmp:=filepath.Join(os.TempDir(), fmt.Sprintf("offline-%d.zip", time.Now().UnixNano()))
if err:=os.WriteFile(tmp, data, 0644); err!=nil { return err }
defer os.Remove(tmp)
fmt.Println("Unpacking embedded (98 MB)...")
if err:=unzip(tmp, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
fmt.Println("Offline unpack complete")
return nil
}
func installOffline(dir, zipPath string, createShortcut bool) error {
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
if _, err:=os.Stat(zipPath); err!=nil { return fmt.Errorf("offline zip not found: %s", zipPath) }
fmt.Println("Unpacking (98 MB)...")
if err:=unzip(zipPath, dir); err!=nil { return fmt.Errorf("unzip: %w", err) }
if createShortcut { if err:=createDesktopShortcut(dir); err!=nil { fmt.Printf("shortcut failed: %v\n", err) } }
if err:=createUninstaller(dir); err!=nil { fmt.Printf("uninstall.exe failed: %v\n", err) }
fmt.Println("Offline unpack complete")
return nil
}
func downloadFile(url, dest string) error {
client:=&http.Client{Timeout: 120*time.Second}
resp, err:=client.Get(url)
if err!=nil { return err }
defer resp.Body.Close()
if resp.StatusCode!=200 { return fmt.Errorf("HTTP %d", resp.StatusCode) }
if err:=os.MkdirAll(filepath.Dir(dest), 0755); err!=nil { return err }
tmp:=dest+".tmp"
out, err:=os.Create(tmp)
if err!=nil { return err }
defer out.Close()
total:=resp.ContentLength
var written int64
buf:=make([]byte, 64*1024)
start:=time.Now()
for {
n, err:=resp.Body.Read(buf)
if n>0 {
if _, werr:=out.Write(buf[:n]); werr!=nil { return werr }
written+=int64(n)
if total>0 {
pct:=float64(written)/float64(total)*100
elapsed:=time.Since(start).Seconds()
speed:=float64(written)/1024/1024/(elapsed+0.001)
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
} else { fmt.Printf("\r %.1f MB ", float64(written)/1024/1024) }
}
if err==io.EOF { break }
if err!=nil { return err }
}
fmt.Println()
out.Close()
if err:=os.Rename(tmp, dest); err!=nil { if err2:=copyFile(tmp, dest); err2!=nil { return err2 }; os.Remove(tmp) }
return nil
}
func unzip(src, dest string) error {
r, err:=zip.OpenReader(src)
if err!=nil { return err }
defer r.Close()
for _, f:=range r.File {
fpath:=filepath.Join(dest, f.Name)
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", fpath) }
if f.FileInfo().IsDir() { os.MkdirAll(fpath, f.Mode()); continue }
if err:=os.MkdirAll(filepath.Dir(fpath), 0755); err!=nil { return err }
out, err:=os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err!=nil { return err }
rc, err:=f.Open()
if err!=nil { out.Close(); return err }
_, err=io.Copy(out, rc)
out.Close(); rc.Close()
if err!=nil { return err }
}
return nil
}
func copyFile(src, dst string) error { in, err:=os.Open(src); if err!=nil { return err }; defer in.Close(); out, err:=os.Create(dst); if err!=nil { return err }; defer out.Close(); _, err=io.Copy(out, in); return err }
func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err!=nil { return err }; rel, _:=filepath.Rel(path, src); target:=filepath.Join(dst, rel); if info.IsDir() { return os.MkdirAll(target, info.Mode()) }; return copyFile(path, target) }) }
func fileSHA256(path string) (string, error) { f, err:=os.Open(path); if err!=nil { return "", err }; defer f.Close(); h:=sha256.New(); if _, err:=io.Copy(h, f); err!=nil { return "", err }; return hex.EncodeToString(h.Sum(nil)), nil }
type Meta struct { Version string `json:"version"`; Files []struct{ Path string `json:"path"`; Size int64 `json:"size"`; Hash string `json:"hash"` } `json:"files"` }
func fetchMeta() (*Meta, error) {
resp, err:=http.Get(serverBase+"/launcher/version")
if err!=nil { return nil, err }
defer resp.Body.Close()
var verResp struct{ Version string `json:"version"` }
if err:=json.NewDecoder(resp.Body).Decode(&verResp); err!=nil { return nil, err }
ver:=verResp.Version
if ver=="" { ver="1.1.1" }
metaResp, err:=http.Get(serverBase+"/launcher/meta/"+ver)
if err!=nil { return nil, err }
defer metaResp.Body.Close()
if metaResp.StatusCode!=200 { return nil, fmt.Errorf("meta %d", metaResp.StatusCode) }
var meta Meta
if err:=json.NewDecoder(metaResp.Body).Decode(&meta); err!=nil { return nil, err }
if meta.Version=="" { meta.Version=ver }
return &meta, nil
}
func findAdjacentOfflineZip() string {
exe, _:=os.Executable(); dir:=filepath.Dir(exe)
matches, _:=filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
if len(matches)>0 { return matches[0] }
matches, _=filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
if len(matches)>0 { return matches[0] }
matches, _=filepath.Glob("ZernMC-Offline*.zip")
if len(matches)>0 { return matches[0] }
return ""
}
func createDesktopShortcut(installDir string) error {
if runtime.GOOS!="windows" { return nil }
desktop:=filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
if _, err:=os.Stat(desktop); err!=nil { desktop=filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop") }
if _, err:=os.Stat(desktop); err!=nil { return fmt.Errorf("desktop not found") }
lnk:=filepath.Join(desktop, "ZernMC Launcher.lnk")
target:=filepath.Join(installDir, "zernmc.exe")
ps:=fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
tmp:=filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
if err:=os.WriteFile(tmp, []byte(ps), 0644); err!=nil { return err }
defer os.Remove(tmp)
cmd:=exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr
return cmd.Run()
}
func createUninstaller(dir string) error {
exe, _:=os.Executable(); uninstExe:=filepath.Join(dir, "uninstall.exe")
if exe!="" && exe!=uninstExe { _=copyFile(exe, uninstExe) }
bat:=filepath.Join(dir, "uninstall.bat")
content:=fmt.Sprintf("@echo off\necho Uninstalling ZernMC from %s\ntimeout /t 2 >nul\ndel \"%%%%USERPROFILE%%%%\\Desktop\\ZernMC Launcher.lnk\" 2>nul\ndel \"%%%%USERPROFILE%%%%\\OneDrive\\Desktop\\ZernMC Launcher.lnk\" 2>nul\necho Data in %%%%USERPROFILE%%%%\\.zernmc will be kept.\n", dir)
_=os.WriteFile(bat, []byte(content), 0644)
return nil
}
func runCmd(name string, args ...string) error { cmd:=exec.Command(name, args...); cmd.Stdout=os.Stdout; cmd.Stderr=os.Stderr; return cmd.Run() }
@@ -47,13 +47,43 @@ public class Bootstrap {
return binDir.resolve(JAR_NAME);
}
private static Path resolveBaseDir() {
// 1) Стартуем от папки, где лежит сам .exe (для launch4j codeSource -> путь к exe).
Path exeDir = null;
try {
java.security.CodeSource cs = Bootstrap.class.getProtectionDomain().getCodeSource();
if (cs != null && cs.getLocation() != null) {
Path loc = Paths.get(cs.getLocation().toURI()).toAbsolutePath();
if (!Files.isDirectory(loc)) {
loc = loc.getParent(); // jar/exe -> его папка
}
exeDir = loc;
}
} catch (Exception ignored) {
}
if (exeDir != null) {
// exe в bin/ -> база родитель (lib/jre21/assets в корне)
Path parent = exeDir.getParent();
Path candidates[] = { exeDir, parent }; // порядок: корень дистрибутива, затем родитель
for (Path c : candidates) {
if (c == null) continue;
if (Files.exists(c.resolve("bin").resolve(JAR_NAME))) {
return c;
}
}
}
// 2) Fallback: текущая рабочая папка (старое поведение).
return Paths.get("").toAbsolutePath();
}
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
baseDir = resolveBaseDir();
binDir = baseDir.resolve("bin");
Files.createDirectories(binDir);
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
javafxPath = baseDir.resolve("lib").resolve("javafx");
rotateLogs();
log("=== ZernMC Launcher ===");
@@ -78,6 +108,19 @@ public class Bootstrap {
log("Server version: " + serverVersion);
setVersionInfo(currentVersion, serverVersion);
if ("unknown".equals(serverVersion)) {
boolean ranDiagnostics = runDiagnosticsIfPossible();
if (ranDiagnostics) {
serverVersion = getServerVersion();
log("Server version after diagnostics: " + serverVersion);
setVersionInfo(currentVersion, serverVersion);
}
if ("unknown".equals(serverVersion) && !argList.contains("--offline")) {
log("Server still unreachable. Launcher will start in offline mode.");
argList.add("--offline");
}
}
loadMirrors();
log("Primary server: " + BASE_URL);
log("Mirrors available: " + (MIRRORS.size() + 1));
@@ -125,6 +168,119 @@ public class Bootstrap {
}
}
private static boolean runDiagnosticsIfPossible() {
Path diagJar = binDir.resolve("zernmcdiag.jar");
if (!Files.exists(diagJar)) {
log("Diagnostics tool not found: bin/zernmcdiag.jar is missing.");
log("Please download the latest launcher manually from the ZernMC website.");
showMessageUi("ZernMC — нет связи с сервером",
"Не удалось связаться с серверами Zern.\n\n"
+ "Файл диагностики (bin/zernmcdiag.jar) отсутствует.\n"
+ "Скачайте свежий лаунчер вручную с официального сайта ZernMC.");
return false;
}
boolean confirmed;
if (isCliMode) {
confirmed = askCliConfirm();
} else {
confirmed = confirmUi("ZernMC — нет связи с сервером",
"Не удалось связаться с серверами Zern.\nПровести диагностику сети?");
}
if (!confirmed) {
log("Diagnostics declined by user.");
return false;
}
try {
launchDiag(diagJar);
return true;
} catch (Exception e) {
log("Failed to launch diagnostics: " + e.getMessage());
return false;
}
}
private static void launchDiag(Path diagJar) throws Exception {
String os = System.getProperty("os.name").toLowerCase();
Path javaBin = findJava(false);
if (os.contains("windows")) {
Path javawPath = javaBin.resolveSibling("javaw.exe");
if (Files.exists(javawPath)) {
javaBin = javawPath;
}
}
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-jar");
cmd.add(diagJar.toAbsolutePath().toString());
if (isCliMode) {
cmd.add("--cli");
}
log("Starting diagnostics: " + String.join(" ", cmd));
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.redirectErrorStream(false);
Process p = pb.start();
drainOutput(p.getInputStream(), "diag");
drainOutput(p.getErrorStream(), "diag-err");
int exit = p.waitFor();
log("Diagnostics finished, exit code: " + exit);
}
private static void drainOutput(InputStream stream, String tag) {
Thread t = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
log("[" + tag + "] " + line);
}
} catch (IOException ignored) {
}
}, "diag-drain-" + tag);
t.setDaemon(true);
t.start();
}
private static boolean askCliConfirm() {
System.out.println("Server unreachable. Run network diagnostics? [y/N]");
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
return line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"));
} catch (IOException e) {
return false;
}
}
private static boolean confirmUi(String title, String message) {
final boolean[] result = new boolean[1];
try {
SwingUtilities.invokeAndWait(() -> {
int choice = JOptionPane.showConfirmDialog(null, message, title,
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
result[0] = choice == JOptionPane.YES_OPTION;
});
} catch (Exception e) {
log("UI confirm error: " + e.getMessage());
}
return result[0];
}
private static void showMessageUi(String title, String message) {
try {
SwingUtilities.invokeAndWait(() ->
JOptionPane.showMessageDialog(null, message, title, JOptionPane.INFORMATION_MESSAGE));
} catch (Exception e) {
log("UI message error: " + e.getMessage());
}
}
private static void launchInProcess(String[] args) throws Exception {
ClassLoader parent = Bootstrap.class.getClassLoader();
URL[] urls = { getLauncherJar().toUri().toURL() };
@@ -172,6 +328,12 @@ public class Bootstrap {
cmd.add(getLauncherJar().toAbsolutePath().toString());
cmd.add("--jfx");
for (String arg : args) {
if (!"--jfx".equals(arg) && !"--cli".equals(arg) && !cmd.contains(arg)) {
cmd.add(arg);
}
}
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.redirectErrorStream(false);
@@ -239,11 +401,50 @@ public class Bootstrap {
return javaBin;
}
private static void rotateLogs() {
// Minecraft-style: archive previous latest.log as .log.gz, keep unlimited.
try {
Path latest = logDir.resolve("latest.log");
if (Files.exists(latest) && Files.size(latest) > 0) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
int n = 1;
Path archive;
do {
archive = logDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz");
n++;
} while (Files.exists(archive));
gzip(archive, latest);
Files.deleteIfExists(latest);
}
} catch (Exception e) {
System.out.println("[Bootstrap] rotate error: " + e.getMessage());
}
// Migrate old unlimited log.
try {
Files.deleteIfExists(logDir.resolve("launcher.log"));
} catch (Exception ignored) {
}
}
private static void gzip(Path archive, Path source) {
try (java.io.InputStream in = Files.newInputStream(source);
java.io.OutputStream out = Files.newOutputStream(archive);
java.util.zip.GZIPOutputStream gz = new java.util.zip.GZIPOutputStream(out)) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) > 0) {
gz.write(buf, 0, n);
}
} catch (Exception e) {
System.out.println("[Bootstrap] gzip error: " + e.getMessage());
}
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
Files.writeString(logDir.resolve("latest.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
if (ui != null) ui.setStatus(msg);
@@ -749,7 +950,7 @@ public class Bootstrap {
// Exit
info.put("exit_code", exitCode);
info.put("stderr", stderr != null ? stderr : "");
info.put("launcher_log", readFileSafe(logDir.resolve("launcher.log"), 200));
info.put("launcher_log", readFileSafe(logDir.resolve("latest.log"), 200));
// System
Map<String, Object> system = new HashMap<>();
@@ -851,6 +1052,13 @@ public class Bootstrap {
statusLabel.setForeground(new Color(0x45, 0xe9, 0x60));
statusLabel.setText("Crash report sent. Thank you!");
sendBtn.setText("Sent");
new Thread(() -> {
try { Thread.sleep(1200); } catch (InterruptedException ignored) {}
SwingUtilities.invokeLater(() -> {
frame.dispose();
System.exit(exitCode);
});
}).start();
} else {
statusLabel.setForeground(accentColor);
statusLabel.setText("Failed to send. Check logs folder.");
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmc-diag</artifactId>
<packaging>jar</packaging>
<name>ZernMC Network Diagnostics</name>
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
<dependencies>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
<filters>
<filter>
<artifact>dnsjava:dnsjava</artifact>
<excludes>
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,29 @@
package me.sashegdev.zernmc.launcher.diag;
import java.nio.file.Path;
public record DiagCheck(String category, String name, String status, String detail, long latencyMs) {
public boolean ok() {
return "OK".equals(status);
}
public String renderLine() {
StringBuilder sb = new StringBuilder();
if ("OK".equals(status)) {
sb.append(" [OK] ");
} else if ("FAIL".equals(status)) {
sb.append(" [FAIL]");
} else {
sb.append(" [SKIP]");
}
sb.append(' ').append(category).append(" / ").append(name);
if (latencyMs >= 0) {
sb.append(" (").append(latencyMs).append(" ms)");
}
if (detail != null && !detail.isEmpty()) {
sb.append(" -> ").append(detail);
}
return sb.toString();
}
}
@@ -0,0 +1,55 @@
package me.sashegdev.zernmc.launcher.diag;
import java.util.List;
import java.util.Map;
public final class DiagConstants {
public static final String HOST_API = "api.zern.cc";
public static final List<String> HOSTS = List.of(
HOST_API,
"api.zernmc.ru",
"api.zernmc.online",
"api.pl.zern.cc",
"api.swe.zern.cc",
"api.ru.zern.cc"
);
public static final List<String> KNOWN_SERVER_IPS = List.of(
"87.120.187.36", // main (Frankfurt)
"212.22.82.243", // ru geo (Moscow)
"2.26.5.202", // pl geo (Warsaw)
"2.26.55.218" // swe geo (Stockholm)
);
public static final String PRIMARY_API = "https://" + HOST_API;
public static final String DIRECT_API = "http://87.120.187.36:1582";
public static final List<Integer> TLS_PORTS = List.of(443, 80);
public static final int DIRECT_PORT = 1582;
public static final String VERSION_PATH = "/launcher/version";
public static final String CLIENT_IP_PATH = "/launcher/ip";
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
Map.entry("Google 8.8.8.8", "8.8.8.8"),
Map.entry("Google 8.8.4.4", "8.8.4.4"),
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
);
public static final int CONNECT_TIMEOUT_MS = 8000;
public static final int READ_TIMEOUT_MS = 8000;
private DiagConstants() {
}
}
@@ -0,0 +1,79 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
public final class DiagMain {
public static void main(String[] args) {
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
Path baseDir = baseDirFromArgs(argList);
if (cliMode) {
runCli(baseDir);
return;
}
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
}
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
ui.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
System.exit(0);
}
});
ui.setVisible(true);
ui.start();
});
}
private static Path baseDirFromArgs(List<String> args) {
int idx = args.indexOf("--dir");
if (idx >= 0 && idx + 1 < args.size()) {
return Path.of(args.get(idx + 1)).toAbsolutePath();
}
return Path.of("").toAbsolutePath();
}
private static void runCli(Path baseDir) {
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
Path log = diag.run();
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
System.out.println("Client IP: " + diag.getClientIp());
if (log != null) {
System.out.print("Send report to the Zern server? [y/N]: ");
System.out.flush();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
}
} catch (Exception e) {
System.out.println("Send skipped: " + e.getMessage());
}
}
int exitCode = 0;
for (DiagCheck c : diag.getChecks()) {
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
exitCode = 1;
}
}
System.exit(exitCode);
}
}
@@ -0,0 +1,194 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridLayout;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.border.EmptyBorder;
public final class DiagnosticsUI extends JFrame {
private static final Color BG = new Color(0x15151a);
private static final Color PANEL = new Color(0x1e1e24);
private static final Color TEXT = new Color(0xe8e8ea);
private static final Color ACCENT = new Color(0x5dade2);
private final Path baseDir;
private final JTextArea output = new JTextArea();
private final JButton sendButton = new JButton("Отправить на сервер");
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
private volatile Path diagLog;
private volatile String clientIp = "unknown";
public DiagnosticsUI(Path baseDir) {
this.baseDir = baseDir;
setTitle("ZernMC — Диагностика сети");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(760, 560);
setMinimumSize(new Dimension(600, 420));
setLocationRelativeTo(null);
getContentPane().setBackground(BG);
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
JLabel header = new JLabel("Проверка связи с серверами Zern");
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
header.setForeground(ACCENT);
header.setBorder(new EmptyBorder(0, 0, 10, 0));
output.setEditable(false);
output.setFont(new Font("Consolas", Font.PLAIN, 12));
output.setBackground(PANEL);
output.setForeground(TEXT);
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JScrollPane scroll = new JScrollPane(output);
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
statusLabel.setForeground(TEXT);
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
JButton openButton = new JButton("Открыть папку с логом");
JButton closeButton = new JButton("Закрыть");
sendButton.setEnabled(false);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
buttons.setOpaque(false);
buttons.add(openButton);
buttons.add(sendButton);
buttons.add(closeButton);
JPanel south = new JPanel(new BorderLayout());
south.setOpaque(false);
south.add(statusLabel, BorderLayout.NORTH);
south.add(buttons, BorderLayout.SOUTH);
setLayout(new BorderLayout());
add(header, BorderLayout.NORTH);
add(scroll, BorderLayout.CENTER);
add(south, BorderLayout.SOUTH);
openButton.addActionListener(e -> openFolder());
closeButton.addActionListener(e -> dispose());
sendButton.addActionListener(e -> sendReport());
}
public void start() {
SwingWorker<Void, String> worker = new SwingWorker<>() {
@Override
protected Void doInBackground() {
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
this::publish,
c -> publish(renderCheck(c)));
diagLog = diag.run();
clientIp = diag.getClientIp();
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
return null;
}
@Override
protected void process(java.util.List<String> chunks) {
for (String line : chunks) {
appendLine(line);
}
}
@Override
protected void done() {
sendButton.setEnabled(diagLog != null);
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
}
};
worker.execute();
}
private static String renderCheck(DiagCheck c) {
String prefix = switch (c.status()) {
case "OK" -> "[OK] ";
case "FAIL" -> "[FAIL]";
default -> "[SKIP]";
};
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
if (c.latencyMs() >= 0) {
sb.append(" (").append(c.latencyMs()).append(" ms)");
}
if (c.detail() != null && !c.detail().isEmpty()) {
sb.append(" -> ").append(c.detail());
}
return sb.toString();
}
private void appendLine(String line) {
output.append(line);
if (!line.endsWith("\n")) {
output.append("\n");
}
output.setCaretPosition(output.getDocument().getLength());
}
private void openFolder() {
try {
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
if (dir != null && Files.exists(dir)) {
Desktop.getDesktop().open(dir.toFile());
} else {
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
}
}
private void sendReport() {
if (diagLog == null) {
return;
}
sendButton.setEnabled(false);
statusLabel.setText("Отправка отчёта на сервер...");
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
@Override
protected ReportSender.SendResult doInBackground() throws Exception {
return ReportSender.send(diagLog, clientIp);
}
@Override
protected void done() {
sendButton.setEnabled(true);
try {
ReportSender.SendResult r = get();
if (r.ok()) {
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Отчёт успешно отправлен на сервер Zern.",
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
} else {
statusLabel.setText("Не удалось отправить отчёт.");
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Не удалось отправить отчёт на сервер.\n"
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
+ "\n\nОшибка: " + r.detail(),
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
}
} catch (Exception e) {
statusLabel.setText("Ошибка отправки: " + e.getMessage());
}
}
};
worker.execute();
}
}
@@ -0,0 +1,128 @@
package me.sashegdev.zernmc.launcher.diag;
import java.net.InetAddress;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.xbill.DNS.ARecord;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SimpleResolver;
import org.xbill.DNS.Type;
public final class DnsResolver {
private DnsResolver() {
}
public static List<String> resolveSystem(String host) {
try {
InetAddress[] addrs = InetAddress.getAllByName(host);
List<String> ips = new ArrayList<>();
for (InetAddress a : addrs) {
String ip = a.getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHGoogle(String host) {
try {
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
String body = doGet(url, "application/json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHCloudflare(String host) {
try {
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
String body = doGet(url, "application/dns-json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveUdp(String providerIp, String host) {
try {
SimpleResolver resolver = new SimpleResolver(providerIp);
resolver.setTimeout(Duration.ofSeconds(5));
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
lookup.setResolver(resolver);
lookup.setCache(null);
Record[] records = lookup.run();
if (records == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (Record r : records) {
if (r instanceof ARecord a) {
String ip = a.getAddress().getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
private static String doGet(String url, String accept) throws Exception {
URLConnection conn = new URL(url).openConnection();
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", accept);
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
conn.connect();
byte[] buf;
try (var in = conn.getInputStream()) {
buf = in.readAllBytes();
}
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,58 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public final class NetProbe {
public record TcpResult(boolean ok, long latencyMs, String detail) {
}
public record HttpResult(boolean ok, int status, long latencyMs, String bodySnippet, String detail) {
}
private NetProbe() {
}
public static TcpResult tcpConnect(String host, int port, int timeoutMs) {
long t0 = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), timeoutMs);
return new TcpResult(true, System.currentTimeMillis() - t0, host + ":" + port);
} catch (Exception e) {
return new TcpResult(false, System.currentTimeMillis() - t0, host + ":" + port + " -> " + e.getMessage());
}
}
public static HttpResult httpGet(String url, int timeoutMs) {
long t0 = System.currentTimeMillis();
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setConnectTimeout(timeoutMs);
conn.setReadTimeout(timeoutMs);
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
conn.setInstanceFollowRedirects(false);
int status = conn.getResponseCode();
InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
String snippet = "";
if (in != null) {
byte[] bytes = in.readAllBytes();
String body = new String(bytes, StandardCharsets.UTF_8).trim();
snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
boolean ok = status >= 200 && status < 400;
return new HttpResult(ok, status, System.currentTimeMillis() - t0, snippet, "HTTP " + status);
} catch (Exception e) {
return new HttpResult(false, 0, System.currentTimeMillis() - t0, "", e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
}
@@ -0,0 +1,263 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.json.JSONObject;
public final class NetworkDiagnostics {
private final Path baseDir;
private final Consumer<String> onLine;
private final Consumer<DiagCheck> onCheck;
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
private final StringBuilder report = new StringBuilder();
private String clientIp = "unknown";
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
this.baseDir = baseDir;
this.onLine = onLine;
this.onCheck = onCheck;
}
public List<DiagCheck> getChecks() {
return checks;
}
public String getClientIp() {
return clientIp;
}
public Path run() {
checks.clear();
report.setLength(0);
report.append("=== ZernMC Network Diagnostics Report ===\n");
report.append("Timestamp: ").append(timestamp()).append('\n');
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
runDnsChecks();
runConnectivityChecks();
runHttpChecks();
clientIp = determineClientIp();
int ok = 0, fail = 0, skip = 0;
for (DiagCheck c : checks) {
switch (c.status()) {
case "OK" -> ok++;
case "FAIL" -> fail++;
default -> skip++;
}
}
report.append("=== Summary ===\n");
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
report.append("--- JSON ---\n");
JSONObject json = new JSONObject();
json.put("client_ip", clientIp);
json.put("timestamp", timestamp());
JSONObject jsonChecks = new JSONObject();
for (DiagCheck c : checks) {
JSONObject j = new JSONObject();
j.put("status", c.status());
j.put("detail", c.detail() == null ? "" : c.detail());
j.put("latency_ms", c.latencyMs());
jsonChecks.put(c.category() + "/" + c.name(), j);
}
json.put("checks", jsonChecks);
report.append(json.toString(2)).append('\n');
return writeLog();
}
private Path writeLog() {
try {
Files.createDirectories(baseDir);
Path log = baseDir.resolve("diag.log");
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
if (onLine != null) {
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
}
return log;
} catch (IOException e) {
if (onLine != null) {
onLine.accept("Failed to write diag.log: " + e.getMessage());
}
return null;
}
}
private void runDnsChecks() {
report.append("\n=== DNS resolution ===\n");
for (String host : DiagConstants.HOSTS) {
report.append("Host: ").append(host).append('\n');
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
}
List<Callable<DiagCheck>> tasks = new ArrayList<>();
List<String> taskKeys = new ArrayList<>();
for (String host : DiagConstants.HOSTS) {
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
taskKeys.add("DoH Google/" + host);
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
taskKeys.add("DoH Cloudflare/" + host);
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
taskKeys.add("UDP " + provider + "/" + host);
}
}
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
Thread t = new Thread(r, "diag-dns");
t.setDaemon(true);
return t;
});
try {
List<Future<DiagCheck>> futures = new ArrayList<>();
for (Callable<DiagCheck> task : tasks) {
futures.add(pool.submit(task));
}
int idx = 0;
for (Future<DiagCheck> f : futures) {
String key = taskKeys.get(idx);
try {
DiagCheck c = f.get(20, TimeUnit.SECONDS);
submit(c);
} catch (Exception e) {
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
}
idx++;
}
} finally {
pool.shutdownNow();
}
}
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
if (ips.isEmpty()) {
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
}
boolean matchesKnown = false;
for (String ip : ips) {
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
matchesKnown = true;
break;
}
}
if (matchesKnown) {
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
}
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
}
private void runConnectivityChecks() {
report.append("\n=== Connectivity ===\n");
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
for (int port : DiagConstants.TLS_PORTS) {
submit(tcpToCheck(ip, port));
}
}
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
for (String ip : systemIps) {
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
submit(tcpToCheck(ip, 443));
}
}
}
private DiagCheck tcpToCheck(String host, int port) {
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
}
private void runHttpChecks() {
report.append("\n=== HTTP ===\n");
for (String host : DiagConstants.HOSTS) {
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
}
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
}
private DiagCheck httpToCheck(String url) {
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
}
private String determineClientIp() {
String ip = null;
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (direct.ok()) {
ip = parseIp(direct.bodySnippet());
}
if (ip == null) {
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (primary.ok()) {
ip = parseIp(primary.bodySnippet());
}
}
String result = ip != null ? ip : "unknown";
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
return result;
}
static String parseIp(String snippet) {
if (snippet == null) {
return null;
}
try {
int start = snippet.indexOf("\"ip\"");
if (start < 0) {
return null;
}
int colon = snippet.indexOf(':', start);
int q1 = snippet.indexOf('"', colon);
int q2 = snippet.indexOf('"', q1 + 1);
if (q1 < 0 || q2 < 0) {
return null;
}
String ip = snippet.substring(q1 + 1, q2);
return ip.matches("[\\d.]+") ? ip : null;
} catch (Exception e) {
return null;
}
}
private void submit(DiagCheck c) {
checks.add(c);
report.append(c.renderLine()).append('\n');
if (onCheck != null) {
onCheck.accept(c);
}
if (onLine != null) {
onLine.accept(c.renderLine());
}
}
private static String timestamp() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
}
@@ -0,0 +1,69 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
public final class ReportSender {
public record SendResult(boolean ok, String endpoint, String detail) {
}
private ReportSender() {
}
public static String buildFilename(String clientIp) {
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
return "diag_" + ts + "_" + ip + ".log";
}
public static SendResult send(Path logFile, String clientIp) throws IOException {
String content = Files.readString(logFile, StandardCharsets.UTF_8);
String name = buildFilename(clientIp);
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
List<String> endpoints = List.of(
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
);
String lastDetail = null;
for (String endpoint : endpoints) {
try {
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
byte[] body = content.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(body.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(body);
}
int status = conn.getResponseCode();
boolean ok = status >= 200 && status < 300;
String detail = "HTTP " + status;
conn.disconnect();
return new SendResult(ok, endpoint, detail);
} catch (Exception e) {
lastDetail = e.getMessage();
}
}
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
}
private static String lastEndpointUsed(List<String> endpoints) {
return endpoints.get(endpoints.size() - 1);
}
}
@@ -0,0 +1,35 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DiagCheckTest {
@Test
void renderLine_ok() {
DiagCheck c = new DiagCheck("DNS", "api.zernmc.ru / system", "OK", "87.120.187.36", 12);
String line = c.renderLine();
assertTrue(line.contains("[OK]"));
assertTrue(line.contains("api.zernmc.ru / system"));
assertTrue(line.contains("12 ms"));
assertTrue(line.contains("87.120.187.36"));
}
@Test
void renderLine_fail() {
DiagCheck c = new DiagCheck("TCP", "1.2.3.4:443", "FAIL", "connection refused", -1);
String line = c.renderLine();
assertTrue(line.contains("[FAIL]"));
assertFalse(line.contains("ms)"));
assertTrue(line.contains("connection refused"));
}
@Test
void ok_returnsStatus() {
assertTrue(new DiagCheck("a", "b", "OK", null, -1).ok());
assertFalse(new DiagCheck("a", "b", "FAIL", null, -1).ok());
}
}
@@ -0,0 +1,52 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DnsResolverTest {
@Test
void resolveToCheck_emptyIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "Google 8.8.8.8", List.of());
assertEquals("FAIL", c.status());
assertEquals("no A records", c.detail());
}
@Test
void resolveToCheck_knownIpIsOk() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("87.120.187.36"));
assertEquals("OK", c.status());
assertTrue(c.detail().contains("87.120.187.36"));
}
@Test
void resolveToCheck_unknownIpIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("1.2.3.4"));
assertEquals("FAIL", c.status());
assertTrue(c.detail().contains("DIFFERENT from known"));
assertTrue(c.detail().contains("1.2.3.4"));
}
@Test
void resolveToCheck_multipleIpsOneKnown() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("zern.cc", "system", List.of("1.2.3.4", "87.120.187.36"));
assertEquals("OK", c.status());
}
@Test
void parseIp_valid() {
assertEquals("1.2.3.4", NetworkDiagnostics.parseIp("{\"ip\":\"1.2.3.4\"}"));
}
@Test
void parseIp_invalidReturnsNull() {
assertEquals(null, NetworkDiagnostics.parseIp("not json"));
assertEquals(null, NetworkDiagnostics.parseIp("{\"other\":\"x\"}"));
assertEquals(null, NetworkDiagnostics.parseIp(null));
assertEquals(null, NetworkDiagnostics.parseIp("{\"ip\":\"not-an-ip\"}"));
}
}
@@ -0,0 +1,24 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReportSenderTest {
@Test
void buildFilename_containsTimestampAndIp() {
String name = ReportSender.buildFilename("1.2.3.4");
assertTrue(name.matches("diag_\\d{8}_\\d{6}_[\\d.]+[.]log"), name);
assertTrue(name.contains("1.2.3.4"));
}
@Test
void buildFilename_unknownIp() {
String name = ReportSender.buildFilename(null);
assertTrue(name.contains("unknown"), name);
assertTrue(name.matches("diag_\\d{8}_\\d{6}_unknown[.]log"), name);
String blank = ReportSender.buildFilename(" ");
assertTrue(blank.contains("unknown"), blank);
}
}
+148 -3
View File
@@ -156,9 +156,13 @@
<configuration>
<outfile>../../server/builds/zernmc.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<icon>${project.basedir}/src/main/icons/zernmc.ico</icon>
<headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre>
<path>lib/jre21</path>
@@ -168,10 +172,12 @@
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc</internalName>
<originalFilename>zernmc.exe</originalFilename>
</versionInfo>
@@ -190,7 +196,11 @@
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--cli</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
@@ -199,15 +209,54 @@
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli</internalName>
<originalFilename>zernmc-cli.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI+JFX версия: консоль видима + форсим JFX UI -->
<execution>
<id>l4j-cli-jfx</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli-jfx.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<classPath>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<addDependencies>false</addDependencies>
</classPath>
<cmdLine>--jfx</cmdLine>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI + JFX</fileDescription>
<copyright>Copyright (c) 2023-2026 ZernMC</copyright>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI JFX</productName>
<companyName>ZernMC</companyName>
<trademarks>ZernMC</trademarks>
<internalName>zernmc-cli-jfx</internalName>
<originalFilename>zernmc-cli-jfx.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
@@ -279,22 +328,118 @@
</fileset>
</copy>
<!-- Подпись экзешников самоподписным сертификатом Zern.cc (publisher: ZernMC Launcher) -->
<loadfile property="pfxpass" srcFile="/root/cert/codesign/.pfxpass">
<filterchain>
<striplinebreaks/>
</filterchain>
</loadfile>
<exec executable="java" failonerror="true">
<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.exe"/>
</exec>
<exec executable="java" failonerror="true">
<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-cli.exe"/>
</exec>
<exec executable="java" failonerror="true">
<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-cli-jfx.exe"/>
</exec>
<!-- Создаём README -->
<echo file="../../server/builds/README.txt">
ZernMC Launcher
Files:
- zernmc.exe - Main launcher with GUI
- zernmc.exe - Main launcher with GUI (default)
- zernmc-cli.exe - CLI version (console, architecture-independent)
- zernmc-cli-jfx.exe - CLI + JFX UI (console visible + JavaFX frontend)
How to use:
Just run zernmc.exe
Just run zernmc.exe (or zernmc-cli.exe / zernmc-cli-jfx.exe for CLI)
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${revision}.${hotfix}.zip"
basedir="../../server/builds"
includes="zernmc.exe,bin/**,assets/**,lib/**,README.txt"
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/>
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false">
<arg value="-c"/>
<arg value="if command -v /usr/local/go/bin/go >/dev/null 2>&amp;1; then echo 'Building Go installers (Go absolute)...'; mkdir -p ../../server/builds; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go online build failed'; cd ../launcher/launcher; echo 'Building offline with embedded ZIP...'; cp ../../server/builds/ZernMC-win-${revision}.${hotfix}.zip ../../installer/offline.zip 2>/dev/null || cp ../../server/builds/ZernMC-win-1.1.1.4.zip ../../installer/offline.zip 2>/dev/null || true; cd ../../installer &amp;&amp; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 /usr/local/go/bin/go build -ldflags='-s -w' -o ../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe . 2>&amp;1 || echo 'Go offline build failed'; cd ../launcher/launcher; rm -f ../../installer/offline.zip; touch ../../installer/offline.zip; fi"/>
</exec>
<!-- Подпись Go инсталляторов -->
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe"/>
</exec>
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe"/>
</exec>
</target>
</configuration>
</execution>
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#g)"/>
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -211,7 +211,7 @@ public class Bootstrap {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
Files.writeString(logDir.resolve("latest.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
@@ -37,6 +37,11 @@ public class Main {
boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli");
if (argList.contains("--offline")) {
System.setProperty("zernmc.offline", "true");
LauncherLogger.info("Offline mode requested (--offline)");
}
if (jfxMode) {
launchJFX();
return;
@@ -65,6 +70,7 @@ public class Main {
}
private static void startCLI() throws IOException {
DomainSelector.selectBest();
ZHttpClient.checkAllServicesOnStartup(true);
System.out.println(ZAnsi.cyan("Checking authorization..."));
@@ -88,7 +88,7 @@ public class LauncherAPI {
}
return ApiResponse.success(mcVersions);
} catch (Exception e) {
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage());
}
return ApiResponse.error("Failed to load Minecraft versions");
}
@@ -136,7 +136,7 @@ public class LauncherAPI {
}
return ApiResponse.success(versions);
} catch (Exception e) {
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load loader versions");
}
}
@@ -26,10 +26,15 @@ public class AuthService {
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
} catch (Exception e) {
String msg = e.getMessage();
if (msg != null && msg.contains("HTTP 409")) {
return ApiResponse.error("Username already taken");
if (msg != null) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg);
if (m.find()) {
int code = Integer.parseInt(m.group(1));
String body = msg.substring(m.end()).trim();
return ApiResponse.error(AuthManager.friendlyHttpError(code, body));
}
return ApiResponse.error("Registration error: " + msg);
}
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
}
}
@@ -40,9 +45,9 @@ public class AuthService {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult);
}
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
} catch (Exception e) {
return ApiResponse.error("Auth error: " + e.getMessage());
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
}
}
@@ -24,7 +24,7 @@ public class LaunchService {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[LAUNCH] Shutting down all running processes...");
LauncherLogger.info("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> {
try {
p.destroy();
@@ -67,13 +67,44 @@ public class LaunchService {
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launchOffline(String instanceName, String nickname) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Offline launch: " + instanceName);
String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim();
LaunchOptions options = createOptions();
options.setUsername(safeName);
options.setAccessToken("0");
options.setUuid(java.util.UUID.nameUUIDFromBytes(
("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Offline launch error for " + instanceName, e);
return ApiResponse.error("Offline launch error: " + e.getMessage());
}
}
private ApiResponse<ProcessInfo> launchProcess(Instance instance, String instanceName, LaunchOptions options) {
try {
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
LauncherLogger.info("Generated command for " + instanceName + ":");
@@ -113,17 +113,62 @@ public class AuthManager {
saveSession();
userInfo = fetchUserInfo();
return AuthResult.ok();
} else if (resp.statusCode() == 422) {
return AuthResult.fail("Validation error: " + extractError(resp.body()));
} else {
return AuthResult.fail(extractError(resp.body()));
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
}
} catch (Exception e) {
e.printStackTrace();
return AuthResult.fail("Connection error: " + e.getMessage());
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
return AuthResult.fail(friendlyConnectionError(e));
}
}
/**
* Maps an HTTP status code + raw body to a short, human-readable error message
* (in Russian) suitable for showing directly to the user.
*/
public static String friendlyHttpError(int statusCode, String body) {
switch (statusCode) {
case 401:
case 403:
return "Неверный логин или пароль";
case 404:
return "Сервис временно недоступен, попробуйте позже";
case 409:
return "Логин уже занят";
case 422:
return "Некорректные данные: " + extractError(body);
case 429:
return "Слишком много попыток. Подождите немного.";
default:
if (statusCode >= 500) {
return "Сервер временно недоступен, попробуйте позже";
}
}
String detail = extractError(body);
if (detail.isEmpty() || detail.startsWith("<")) {
return "Ошибка сервера (код " + statusCode + ")";
}
if (detail.length() > 120) {
detail = detail.substring(0, 117) + "...";
}
return detail;
}
/**
* Maps a network/IO exception to a short, human-readable message.
*/
public static String friendlyConnectionError(Exception e) {
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
if (e instanceof java.net.SocketTimeoutException
|| e instanceof java.net.ConnectException
|| e instanceof java.net.UnknownHostException
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|| m.contains("refused")) {
return "Нет соединения с сервером. Проверьте интернет.";
}
return "Ошибка соединения с сервером. Попробуйте позже.";
}
public static void logout() {
if (session != null && session.refreshToken != null) {
try {
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.IOException;
@@ -142,7 +144,7 @@ public class Instance {
this.serverPackName = meta.serverPackName;
this.presetId = meta.presetId;
} catch (Exception e) {
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
}
}
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.IOException;
@@ -51,7 +53,7 @@ public class InstanceManager {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
System.err.println("Не удалось удалить: " + path);
LauncherLogger.warn("Не удалось удалить: " + path);
}
});
return true;
@@ -71,12 +71,12 @@ public class MinecraftLib {
* Stub - will be expanded
*/
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
LauncherLogger.info("Starting full pack install: " + packName);
// 1. Install Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
return false;
}
@@ -84,40 +84,39 @@ public class MinecraftLib {
if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
LauncherLogger.error("Failed to install Fabric");
return false;
}
} else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
LauncherLogger.error("Failed to install Forge");
return false;
}
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
LauncherLogger.error("Failed to install NeoForge");
return false;
}
}
// 3. In the future: diff and mod download
System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
LauncherLogger.info("Basic pack install complete!");
return true;
}
//Launch
public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
LauncherLogger.info("Launching pack: " + instance.getName());
cleanupOldLoaders();
validateJarFiles();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
command.forEach(arg -> System.out.println(" " + arg));
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile());
@@ -126,12 +125,12 @@ public class MinecraftLib {
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
try {
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath()));
LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
} catch (Exception e) {
System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage()));
LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
}
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
LauncherLogger.info("Starting Minecraft...");
ConsoleUtils.clearScreen();
Process process = pb.start();
@@ -168,7 +167,7 @@ public class MinecraftLib {
outThread.join(1000);
errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
LauncherLogger.info("Minecraft exited with code: " + exitCode);
}
private void safeDeleteDirectory(Path dir) {
@@ -215,7 +214,7 @@ public class MinecraftLib {
if (currentLoaderVer == null) return;
System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
LauncherLogger.info("Cleaning old loader versions...");
// Delete all old fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries");
@@ -247,14 +246,14 @@ public class MinecraftLib {
.filter(p -> !isValidJar(p))
.forEach(p -> {
try {
System.out.println(ZAnsi.yellow(" Removing corrupt JAR: " + instance.getPath().relativize(p)));
LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p));
Files.delete(p);
} catch (IOException e) {
System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName()));
LauncherLogger.error("Failed to delete: " + p.getFileName());
}
});
} catch (Exception e) {
System.out.println(ZAnsi.yellow(" Error scanning for corrupt JARs: " + e.getMessage()));
LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage());
}
}
}
@@ -184,8 +184,7 @@ public class PackDownloader {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
LauncherLogger.error("[INSTALL] Failed to install Fabric");
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false;
}
@@ -195,8 +194,7 @@ public class PackDownloader {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
LauncherLogger.error("[INSTALL] Failed to install NeoForge");
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false;
}
@@ -206,8 +204,7 @@ public class PackDownloader {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
LauncherLogger.error("installOrUpdatePack: Forge install failed");
LauncherLogger.error("[INSTALL] Failed to install Forge");
reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false;
}
@@ -217,16 +214,14 @@ public class PackDownloader {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
LauncherLogger.error("installOrUpdatePack: Vanilla install failed");
LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft");
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false;
}
}
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
} else {
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping");
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
LauncherLogger.info("Minecraft already installed, skipping...");
}
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
@@ -239,7 +234,7 @@ public class PackDownloader {
// If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) {
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff");
System.out.println(ZAnsi.green("Pack contains no additional files"));
LauncherLogger.info("Pack contains no additional files");
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
saveUserFilesSnapshot(localFiles);
@@ -253,7 +248,7 @@ public class PackDownloader {
instance.setAssetIndex(resolveAssetIndex(manifest));
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
LauncherLogger.info("Pack installed successfully!");
return true;
}
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
@@ -302,7 +297,7 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(resolveAssetIndex(manifest));
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
LauncherLogger.info("Pack installed successfully!");
LauncherLogger.info("installOrUpdatePack: SUCCESS");
} else {
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
@@ -332,17 +327,17 @@ public class PackDownloader {
}
public boolean updatePack(String packName, String preset) throws Exception {
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
LauncherLogger.info("Checking updates for " + instance.getName() + "...");
PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion();
if (serverVersion <= instance.getServerVersion()) {
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")");
return true;
}
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " v" + serverVersion));
LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion);
// Scan local files
Map<String, String> localFiles = scanLocalFiles();
@@ -351,7 +346,7 @@ public class PackDownloader {
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
if (!protectedFiles.isEmpty()) {
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
System.out.println(ZAnsi.yellow("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten"));
LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten");
}
// Get diff
@@ -366,7 +361,7 @@ public class PackDownloader {
saveUserFilesSnapshot(updatedHashes);
instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
LauncherLogger.info("Pack updated to v" + serverVersion);
}
return success;
@@ -519,34 +514,30 @@ public class PackDownloader {
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
System.out.println(ZAnsi.cyan("\nApplying changes:"));
System.out.println(" Download: " + diff.getToDownload().size() + " files");
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
if (!protectedFiles.isEmpty()) {
System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)"));
}
LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
+ (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
// Create directories if needed
try {
Files.createDirectories(instance.getPath());
} catch (IOException e) {
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
LauncherLogger.error("Error creating directories: " + e.getMessage());
return false;
}
// Delete files (skip protected)
for (String filePath : diff.getToDelete()) {
if (protectedFiles.contains(filePath)) {
System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath));
LauncherLogger.info("Skipped delete (user-modified): " + filePath);
continue;
}
Path fullPath = instance.getPath().resolve(filePath);
try {
if (Files.deleteIfExists(fullPath)) {
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
LauncherLogger.info("Deleted: " + filePath);
}
} catch (IOException e) {
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage());
}
}
@@ -565,7 +556,7 @@ public class PackDownloader {
if (protectedFiles.contains(path)) {
skipped++;
System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path));
LauncherLogger.info("Skipped (user-modified): " + path);
continue;
}
@@ -597,7 +588,6 @@ public class PackDownloader {
}
if (!hashMatch) {
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
System.err.println(ZAnsi.yellow(" Skipped (hash mismatch): " + path));
skipped++;
continue;
}
@@ -642,8 +632,7 @@ public class PackDownloader {
} catch (Exception e) {
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage());
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error"));
LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file");
skipped++;
continue;
}
@@ -653,7 +642,7 @@ public class PackDownloader {
ProgressBar.finish("Download");
}
if (skipped > 0) {
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files"));
LauncherLogger.info("Skipped " + skipped + " user-modified files");
}
return true;
@@ -6,7 +6,9 @@ import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
@@ -33,6 +35,8 @@ public class FabricInstaller {
instance.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion);
ProgressBar.showIndeterminate("Preparing Fabric installer");
String installerVersion = getLatestInstallerVersion();
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
@@ -40,7 +44,7 @@ public class FabricInstaller {
Path installerJar = instancePath.resolve("fabric-installer.jar");
if (!Files.exists(installerJar)) {
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
ProgressBar.showIndeterminate("Downloading Fabric Installer");
downloadFileWithFallback(installerUrl, installerJar);
ProgressBar.finish("Fabric Installer downloaded");
}
@@ -48,6 +52,7 @@ public class FabricInstaller {
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
LauncherLogger.info("Running Fabric Installer...");
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -60,17 +65,50 @@ public class FabricInstaller {
"-noprofile"
);
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
if (!finished) {
boolean hasErrors = false;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line);
} else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line);
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line);
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
LauncherLogger.info(" " + line);
}
}
}
int exitCode;
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
return false;
}
int exitCode = process.exitValue();
exitCode = process.exitValue();
}
if (exitCode != 0) {
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
@@ -48,7 +48,16 @@ public class ForgeInstaller {
LauncherLogger.info("Downloading Forge Installer...");
downloadFileWithProgress(installerUrl, installerJar);
// Step 4: Run Forge Installer and show its output
// Step 4: Pre-download the libraries the installer subprocess would fetch from
// maven.minecraftforge.net directly. On blocked networks that subprocess cannot
// reach the maven host, so we seed every library (version.json + install_profile.json)
// through the launcher smart proxy first. The installer reuses files whose checksums
// validate ("File exists: Checksum validated"), so no direct downloads are needed.
LauncherLogger.info("Prefetching Forge libraries via smart proxy...");
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
LauncherLogger.info("Prefetched " + prefetched + " Forge libraries");
// Step 5: Run Forge Installer and show its output
LauncherLogger.info("Running Forge Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
@@ -105,6 +114,13 @@ public class ForgeInstaller {
while (attempt <= maxRetries) {
LauncherLogger.info("Attempt " + attempt + " of " + maxRetries);
// Re-seed libraries each attempt: the installer may have left partial files,
// and prefetch deletes any with a bad checksum before re-downloading via proxy.
int seeded = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
if (seeded > 0) {
LauncherLogger.info(" Prefetched " + seeded + " libraries for this attempt");
}
ProcessBuilder pb = new ProcessBuilder(
JavaResolver.getJavaExecutable(),
"-jar",
@@ -175,19 +191,8 @@ public class ForgeInstaller {
if (attempt < maxRetries) {
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
Thread.sleep(5000);
// Clean temp files before retry
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
// Удаляем только частично скачанные библиотеки Forge
try (var stream = Files.walk(librariesDir)) {
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
.forEach(p -> {
try { Files.deleteIfExists(p); }
catch (IOException e) { /* ignore */ }
});
}
}
// Prefetch at the top of the next attempt already re-seeds any library that
// was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy).
} else {
LauncherLogger.error("Forge Installer exited with error code: " + exitCode);
@@ -3,6 +3,7 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*;
@@ -57,15 +58,19 @@ public class ModLoaderInstaller {
instance.setAssetIndex(assetIndex);
createLauncherProfile();
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
String jarName = type.loaderName + "-installer.jar";
Path installerJar = instance.getPath().resolve(jarName);
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
LauncherLogger.info("Running " + type.loaderName + " Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
boolean success = runInstaller(installerJar, type);
@@ -145,6 +150,11 @@ public class ModLoaderInstaller {
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
@@ -218,6 +228,7 @@ public class ModLoaderInstaller {
private void downloadMissingLibraries(LoaderType type) throws Exception {
LauncherLogger.info("Checking and downloading missing libraries...");
ProgressBar.showIndeterminate("Downloading missing libraries");
ZHttpClient.repairMissingLibraries(instance.getPath());
}
}
@@ -49,6 +49,10 @@ public class NeoForgeInstaller {
LauncherLogger.info("Downloading NeoForge Installer...");
downloadFileWithProgress(installerUrl, installerJar);
LauncherLogger.info("Prefetching NeoForge libraries via smart proxy...");
int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath());
LauncherLogger.info("Prefetched " + prefetched + " NeoForge libraries");
LauncherLogger.info("Running NeoForge Installer...");
LauncherLogger.info("This may take a few minutes. Please wait...");
@@ -64,7 +64,8 @@ public class VersionInstaller {
Files.createDirectories(versionDir);
boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json"))
&& Files.exists(versionDir.resolve(versionId + ".jar"));
&& Files.exists(versionDir.resolve(versionId + ".jar"))
&& isValidJar(versionDir.resolve(versionId + ".jar"));
if (alreadyInstalled) {
LauncherLogger.info("Minecraft " + versionId + " already installed, skipping");
String existing = Files.readString(versionDir.resolve(versionId + ".json"));
@@ -73,20 +74,29 @@ public class VersionInstaller {
? existingData.getJSONObject("assetIndex").getString("id")
: existingData.getString("assets");
}
// A stale/partial client.jar (e.g. aborted by the download timeout) must be
// removed, otherwise it would be skipped as "already installed" and stay corrupt.
try {
Files.deleteIfExists(versionDir.resolve(versionId + ".jar"));
} catch (IOException e) {
LauncherLogger.warn("Could not remove stale client.jar: " + e.getMessage());
}
ProgressBar.show("Fetching version info", 0, 1, "files");
String versionJson;
try {
// Prefers Zern server Mojang proxy, falls back to direct piston-meta
LauncherLogger.info("[VERSION] Fetching version json for " + versionId + " (server proxy, then direct piston-meta)");
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
LauncherLogger.info("[VERSION] Version json fetched for " + versionId);
} catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage());
throw e;
}
try {
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
} catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to write version info: " + e.getMessage()));
LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage());
throw e;
}
ProgressBar.show("Version info", 1, 1, "files");
@@ -95,16 +105,20 @@ public class VersionInstaller {
// client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files");
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "...");
Path clientJar = versionDir.resolve(versionId + ".jar");
if (!Files.exists(clientJar)) {
try {
ZHttpClient.downloadFileWithSmartProxy(
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
clientJar);
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
} catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to download client.jar: " + e.getMessage()));
LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage());
throw e;
}
} else {
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
}
ProgressBar.show("Client.jar", 1, 1, "files");
@@ -233,7 +247,7 @@ public class VersionInstaller {
try {
ZHttpClient.downloadFileWithSmartProxy(url, target);
} catch (Exception e) {
System.err.println(ZAnsi.yellow("[LIB] Failed to download " + path + ": " + e.getMessage()));
LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage());
}
}
count++;
@@ -306,7 +320,7 @@ public class VersionInstaller {
synchronized (this) {
failed[0]++;
}
System.err.println("Failed to download " + hash);
LauncherLogger.error("[VERSION] Failed to download asset " + hash);
} else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
}
@@ -322,7 +336,7 @@ public class VersionInstaller {
.get(10, TimeUnit.MINUTES);
} catch (TimeoutException e) {
futures.forEach(f -> f.cancel(true));
System.err.println("Asset download timed out after 10 minutes");
LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes");
} catch (CancellationException e) {
// one of the futures was cancelled
} catch (CompletionException e) {
@@ -337,6 +351,14 @@ public class VersionInstaller {
}
}
private boolean isValidJar(Path path) {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(path.toFile())) {
return true;
} catch (Exception e) {
return false;
}
}
public String getAssetIndexId(String versionId) throws Exception {
JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId);
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject;
@@ -36,7 +37,7 @@ public class LaunchCommandBuilder {
}
public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
List<String> command = new ArrayList<>();
@@ -57,7 +58,7 @@ public class LaunchCommandBuilder {
// with placeholder substitution. Do NOT manually add -p, -cp,
// --add-modules, --add-opens, or -DignoreList. The version.json
// defines all of these. This matches AstralRinth's approach.
System.out.println(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
// Ensure version jar exists at versions/<versionId>/<versionId>.jar
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
@@ -92,9 +93,9 @@ public class LaunchCommandBuilder {
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
if (referencesClasspath) {
vars.put("classpath", writeClasspathFile(fullClasspath));
System.out.println(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
} else {
System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
}
boolean hasCpInJson = containsCpArg(allJvmArgs);
@@ -107,9 +108,9 @@ public class LaunchCommandBuilder {
command.add(resolved);
}
}
System.out.println(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
} else {
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
command.addAll(getJvmArguments(options));
}
@@ -119,16 +120,19 @@ public class LaunchCommandBuilder {
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
}
// Append memory/GC args (always after version.json args, like AstralRinth)
// Append memory/GC args (always after version.json args, like AstralRinth) skip if systemBasedJvm adds its own GC (ZGC/G1)
boolean useSystemGc = me.sashegdev.zernmc.launcher.utils.Config.isSystemBasedJvm();
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
command.add("-Xmx" + ramMB + "M");
command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
if (!useSystemGc) {
command.add("-XX:+UseG1GC");
command.add("-XX:+UnlockExperimentalVMOptions");
command.add("-XX:G1NewSizePercent=20");
command.add("-XX:G1ReservePercent=20");
command.add("-XX:MaxGCPauseMillis=50");
command.add("-XX:G1HeapRegionSize=32M");
}
// Append custom user JVM args
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
@@ -141,7 +145,7 @@ public class LaunchCommandBuilder {
if (!hasCpInJson) {
command.add("-cp");
command.add(writeClasspathFile(fullClasspath));
System.out.println(ZAnsi.green(" Added -cp classpath for main class lookup"));
LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup"));
}
// Main class from version.json
@@ -151,9 +155,9 @@ public class LaunchCommandBuilder {
}
if (mainClass == null || mainClass.isEmpty()) {
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
} else {
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass));
LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
}
command.add(mainClass);
@@ -167,9 +171,9 @@ public class LaunchCommandBuilder {
command.add(resolved);
}
}
System.out.println(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
} else {
System.out.println(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
command.addAll(getVanillaGameArguments(options));
command.addAll(getModloaderLaunchArgs());
}
@@ -183,24 +187,24 @@ public class LaunchCommandBuilder {
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
if ("fabric".equals(loaderType)) {
System.out.println(ZAnsi.cyan(" Fabric: using vanilla classpath"));
LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainClass();
System.out.println(ZAnsi.cyan(" Main class from manifest: " + mainClass));
LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass));
}
if (mainClass == null || mainClass.isEmpty()) {
mainClass = getVanillaMainClass();
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
}
command.add(mainClass);
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest, true);
if (classpath.isEmpty()) {
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass());
@@ -229,20 +233,20 @@ public class LaunchCommandBuilder {
if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
VersionManifest manifest = new VersionManifest(json);
manifest.resolveParent(instance.getPath().resolve("versions"));
if (manifest.getInheritsFrom() != null) {
System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
}
return manifest;
} else {
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName()));
LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath()));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
}
return null;
}
@@ -496,14 +500,14 @@ public class LaunchCommandBuilder {
String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
} else {
// Validate that the index file exists. A wrong/stale asset index
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
// fail to load the base resource pack (grey panorama, no sounds).
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
if (!Files.exists(indexPath)) {
System.out.println(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
java.util.List<Path> existing = stream
.filter(p -> p.getFileName().toString().endsWith(".json"))
@@ -512,16 +516,16 @@ public class LaunchCommandBuilder {
if (!existing.isEmpty()) {
String found = existing.get(existing.size() - 1).getFileName().toString()
.replace(".json", "");
System.out.println(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
assetIndex = found;
} else {
System.out.println(ZAnsi.yellow("No asset index files found in assets/indexes/"));
LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/"));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
}
}
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex));
}
args.add(assetIndex);
args.add("--username");
@@ -612,7 +616,7 @@ public class LaunchCommandBuilder {
List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
String loaderType = instance.getLoaderType().toLowerCase();
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
@@ -628,7 +632,7 @@ public class LaunchCommandBuilder {
if (isValidJar(libPath)) {
paths.add(libPath.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(libPath);
}
} else {
@@ -638,7 +642,7 @@ public class LaunchCommandBuilder {
if (isValidJar(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(fallbackPath);
}
} else {
@@ -647,23 +651,23 @@ public class LaunchCommandBuilder {
Path found = scanForJar(librariesDir, artifactName);
if (found != null) {
if (isValidJar(found)) {
System.out.println(ZAnsi.green(" Found by scan: " + lib.name + "" + librariesDir.relativize(found)));
LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + "" + librariesDir.relativize(found)));
paths.add(found.toAbsolutePath().toString());
} else {
System.out.println(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
Files.delete(found);
}
} else {
System.out.println(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
}
}
}
}
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
if (isForgeLike) {
System.out.println(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
System.out.println(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
}
if (includeVersionJar) {
@@ -671,9 +675,9 @@ public class LaunchCommandBuilder {
if (versionJar != null) {
if (isValidJar(versionJar)) {
paths.add(0, versionJar.toAbsolutePath().toString());
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
} else {
System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
Files.delete(versionJar);
}
}
@@ -785,7 +789,7 @@ public class LaunchCommandBuilder {
Path targetJar = targetDir.resolve(versionId + ".jar");
if (Files.exists(targetJar) && isValidJar(targetJar)) {
System.out.println(ZAnsi.green(" Version jar already exists: " + targetJar));
LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar));
return;
}
@@ -795,11 +799,11 @@ public class LaunchCommandBuilder {
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
Files.copy(vanillaJar, targetJar);
System.out.println(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + "" + targetJar));
LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + "" + targetJar));
return;
}
System.out.println(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
}
/**
@@ -833,7 +837,7 @@ public class LaunchCommandBuilder {
.findFirst()
.orElse(null);
if (found != null) {
System.out.println(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
return found;
}
} catch (Exception e) { /* continue */ }
@@ -848,7 +852,7 @@ public class LaunchCommandBuilder {
.findFirst()
.orElse(null);
if (found != null) {
System.out.println(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
return found;
}
} catch (Exception e) { /* continue */ }
@@ -903,12 +907,12 @@ public class LaunchCommandBuilder {
for (String candidate : candidates) {
Path jarPath = librariesDir.resolve(candidate);
if (Files.exists(jarPath) && isValidJar(jarPath)) {
System.out.println(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
return jarPath;
}
}
System.out.println(ZAnsi.yellow(" No Forge client/universal jar found!"));
LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
return null;
}
@@ -1047,7 +1051,7 @@ public class LaunchCommandBuilder {
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
Files.writeString(tempFile, classpath);
tempFiles.add(tempFile);
System.out.println(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
return "@" + tempFile.toAbsolutePath();
}
}
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -75,7 +77,7 @@ public class VersionManifest {
this.parent = new VersionManifest(json);
this.parent.resolveParent(versionsDir);
} catch (Exception e) {
System.out.println("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
}
}
}
@@ -18,6 +18,7 @@ import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.DomainSelector;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Version;
@@ -205,6 +206,77 @@ public class JFXLauncher extends Application {
launch(args);
}
private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
private static javafx.scene.image.Image loadWindowIcon() {
try {
byte[] ico = readAssetBytes("assets/ui/icons/zernmc.ico");
if (ico != null) {
byte[] png = extractIcoPngFrame(ico);
if (png != null) {
return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png));
}
}
byte[] png = readAssetBytes("assets/ui/icons/zernmc.png");
if (png != null) {
return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png));
}
} catch (Exception e) {
log("Window icon load error: " + e.getMessage());
}
return null;
}
private static byte[] readAssetBytes(String resourcePath) {
try (InputStream in = JFXLauncher.class.getResourceAsStream("/" + resourcePath)) {
if (in != null) {
return in.readAllBytes();
}
} catch (Exception ignored) {}
try {
Path file = Paths.get(resourcePath);
if (Files.exists(file)) {
return Files.readAllBytes(file);
}
} catch (Exception ignored) {}
return null;
}
private static byte[] extractIcoPngFrame(byte[] ico) {
if (ico == null || ico.length < 6 || ico[0] != 0 || ico[1] != 0) {
return null;
}
int count = (ico[4] & 0xFF) | ((ico[5] & 0xFF) << 8);
int bestSize = -1;
int bestOffset = -1;
int bestLength = -1;
for (int i = 0; i < count; i++) {
int off = 6 + i * 16;
if (off + 16 > ico.length) break;
int w = ico[off] & 0xFF;
int h = ico[off + 1] & 0xFF;
int size = (w == 0 ? 256 : w) * (h == 0 ? 256 : h);
int len = (ico[off + 8] & 0xFF) | ((ico[off + 9] & 0xFF) << 8)
| ((ico[off + 10] & 0xFF) << 16) | ((ico[off + 11] & 0xFF) << 24);
int imgOff = (ico[off + 12] & 0xFF) | ((ico[off + 13] & 0xFF) << 8)
| ((ico[off + 14] & 0xFF) << 16) | ((ico[off + 15] & 0xFF) << 24);
if (imgOff < 0 || len <= 0 || imgOff + len > ico.length) continue;
boolean isPng = true;
for (int b = 0; b < PNG_SIGNATURE.length; b++) {
if (ico[imgOff + b] != PNG_SIGNATURE[b]) { isPng = false; break; }
}
if (isPng && size > bestSize) {
bestSize = size;
bestOffset = imgOff;
bestLength = len;
}
}
if (bestOffset < 0 || bestLength <= 0) return null;
byte[] png = new byte[bestLength];
System.arraycopy(ico, bestOffset, png, 0, bestLength);
return png;
}
private static void extractAssets() {
try {
Path assetsDir = Paths.get("assets");
@@ -216,7 +288,7 @@ public class JFXLauncher extends Application {
try {
String existingVersion = Files.readString(versionFile).trim();
if (existingVersion.equals(currentVersion)) {
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
log("Assets up to date (v" + currentVersion + ")");
return;
}
} catch (Exception ignored) {}
@@ -233,16 +305,16 @@ public class JFXLauncher extends Application {
String serverVersion = getServerVersion();
if (serverVersion != null && !serverVersion.isEmpty()) {
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
log("Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets loaded via meta");
log("Assets loaded via meta");
Files.writeString(versionFile, currentVersion);
return;
}
System.out.println("[JFX] Meta unavailable, using fallback");
log("Meta unavailable, using fallback");
}
System.out.println("[JFX] Extracting assets from JAR...");
log("Extracting assets from JAR...");
Files.createDirectories(assetsDir);
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
@@ -263,11 +335,11 @@ public class JFXLauncher extends Application {
}
}
}
System.out.println("[JFX] Assets extracted from JAR");
log("Assets extracted from JAR");
Files.writeString(versionFile, currentVersion);
}
} catch (Exception e) {
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
log("Error extracting assets: " + e.getMessage());
}
}
@@ -332,7 +404,7 @@ public class JFXLauncher extends Application {
return true;
}
} catch (Exception e) {
System.out.println("[JFX] Error loading via meta: " + e.getMessage());
log("Error loading assets via meta: " + e.getMessage());
return false;
}
}
@@ -363,6 +435,7 @@ public class JFXLauncher extends Application {
log("Starting background network init...");
Thread netInitThread = new Thread(() -> {
try {
DomainSelector.selectBest();
ZHttpClient.checkAllServicesOnStartup();
log("Network init complete");
} catch (Exception e) {
@@ -374,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);
@@ -391,9 +464,9 @@ public class JFXLauncher extends Application {
engine.setOnAlert(e -> {
String msg = e.getData();
if (msg.startsWith("[LOG] ")) {
System.out.println("[JS] " + msg.substring(6));
log("[JS] " + msg.substring(6));
} else if (msg.startsWith("[ERR] ")) {
System.err.println("[JS] " + msg.substring(6));
log("[JS ERROR] " + msg.substring(6));
} else {
log("[UI] Alert: " + msg);
}
@@ -435,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);"
);
}
@@ -443,6 +519,11 @@ public class JFXLauncher extends Application {
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
engine.load(url);
javafx.scene.image.Image windowIcon = loadWindowIcon();
if (windowIcon != null) {
stage.getIcons().add(windowIcon);
}
stage.setTitle(APP_TITLE);
int winW = Config.getWindowWidth();
int winH = Config.getWindowHeight();
@@ -493,6 +574,8 @@ public class JFXLauncher extends Application {
server.createContext("/api/account", this::handleAccount);
server.createContext("/api/instances", this::handleInstances);
server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/offline/status", this::handleOfflineStatus);
server.createContext("/api/offline-launch", this::handleOfflineLaunch);
server.createContext("/api/install", this::handleInstall);
server.createContext("/api/install/progress", this::handleInstallProgress);
server.createContext("/api/network/status", this::handleNetworkStatus);
@@ -514,6 +597,7 @@ public class JFXLauncher extends Application {
server.createContext("/api/exit-parent", this::handleExitParent);
server.createContext("/api/system-info", this::handleSystemInfo);
server.createContext("/api/open-log-file", this::handleOpenLogFile);
server.createContext("/api/clipboard", this::handleClipboard);
server.createContext("/api/friends/list", this::handleFriendList);
server.createContext("/api/friends/add", this::handleFriendAdd);
server.createContext("/api/friends/remove", this::handleFriendRemove);
@@ -526,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);
@@ -557,21 +647,43 @@ public class JFXLauncher extends Application {
private void handleOpenLogFile(HttpExchange exchange) {
try {
Path target = null;
if (gameLogFile != null && Files.exists(gameLogFile)) {
target = gameLogFile;
} else if (launcherLogFile != null && Files.exists(launcherLogFile)) {
target = launcherLogFile;
}
if (target != null) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().open(gameLogFile.toFile());
sendJson(exchange, Map.of("success", true));
Desktop.getDesktop().open(target.toFile());
sendJson(exchange, Map.of("success", true, "file", target.toString()));
} else {
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
}
} else {
sendJson(exchange, Map.of("success", false, "error", "No game log file found"));
sendJson(exchange, Map.of("success", false, "error", "No log file found"));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleClipboard(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String text = body != null ? body.get("text") : null;
if (text == null) {
sendJson(exchange, Map.of("success", false, "error", "Empty clipboard text"));
return;
}
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents(
new java.awt.datatransfer.StringSelection(text), null);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleOpenUrl(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
@@ -612,9 +724,8 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
log("Login error: " + e.getMessage());
e.printStackTrace();
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
log("Login error: " + AuthManager.friendlyConnectionError(e));
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
}
}
@@ -641,7 +752,6 @@ public class JFXLauncher extends Application {
try {
boolean loggedIn = AuthManager.isLoggedIn();
boolean authExists = AuthManager.authFileExists();
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
if (AuthManager.tryAutoLogin()) {
Map<String, Object> data = new HashMap<>();
@@ -650,15 +760,12 @@ public class JFXLauncher extends Application {
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
System.out.println("[AUTH] Auto-login performed: " + AuthManager.getUsername());
log("Auto-login performed: " + AuthManager.getUsername());
} else {
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
sendJson(exchange, Map.of("success", false, "autoLogin", false));
}
} catch (Exception e) {
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
log("handleAutoLogin error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
@@ -721,6 +828,54 @@ public class JFXLauncher extends Application {
}
}
private void handleOfflineStatus(HttpExchange exchange) {
try {
boolean offline = isOfflineMode();
sendJson(exchange, Map.of("success", true, "offline", offline));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleOfflineLaunch(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String nickname = body.get("nickname");
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return;
}
var result = api.launch().launchOffline(name, nickname);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data));
log("Offline launched: " + name + " pid=" + result.getData().getPid());
} else {
log("Offline launch failed: " + result.getError());
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
log("handleOfflineLaunch error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private boolean isOfflineMode() {
if ("true".equalsIgnoreCase(System.getProperty("zernmc.offline", "false"))) {
return true;
}
if (ZHttpClient.isNetworkInitialized() && !ZHttpClient.isZernServerHealthy()) {
return true;
}
return false;
}
private void handleInstall(HttpExchange exchange) {
if (!installInProgress.compareAndSet(false, true)) {
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
@@ -769,8 +924,8 @@ public class JFXLauncher extends Application {
boolean success = false;
if (!ZHttpClient.isNetworkInitialized()) {
log("Network init not finished, checking Mojang services synchronously...");
ZHttpClient.forceCheckMojangServices();
log("Network init not finished, running full service check (CLI-style)...");
ZHttpClient.checkAllServicesOnStartup();
}
if ("zernmc".equalsIgnoreCase(loader)) {
@@ -895,17 +1050,16 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
// Bounded queue: producers (LauncherLogger on the install thread) must
// never block on a slow/stalled SSE client. If the UI can't keep up,
// lines are dropped here - they still reach the log file and buffer.
final java.util.concurrent.BlockingQueue<String> queue =
new java.util.concurrent.LinkedBlockingQueue<>(5000);
LogConsumer consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeLogConsumer(this);
}
public void onLog(String line) {
queue.offer(line);
}
};
consumerRef[0] = consumer;
@@ -915,9 +1069,13 @@ public class JFXLauncher extends Application {
Thread.currentThread().setName("sse-logs-stream");
while (!Thread.currentThread().isInterrupted()) {
try {
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (line != null) {
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
} else {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
}
os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
}
} catch (Exception ignored) {} finally {
@@ -939,17 +1097,14 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
// Bounded queue: producers must never block on a slow/stalled SSE client.
final java.util.concurrent.BlockingQueue<String> queue =
new java.util.concurrent.LinkedBlockingQueue<>(5000);
LogConsumer consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeGameLogConsumer(this);
}
public void onLog(String line) {
queue.offer(line);
}
};
consumerRef[0] = consumer;
@@ -959,9 +1114,13 @@ public class JFXLauncher extends Application {
Thread.currentThread().setName("sse-game-logs-stream");
while (!Thread.currentThread().isInterrupted()) {
try {
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (line != null) {
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
} else {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
}
os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
}
} catch (Exception ignored) {} finally {
@@ -1080,7 +1239,7 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
}
}
@@ -1100,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");
@@ -1177,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());
@@ -1193,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());
@@ -1624,22 +1832,10 @@ public class JFXLauncher extends Application {
Files.createDirectories(modsDir);
Path targetFile = modsDir.resolve(modName);
// Download mod from server
// Download mod from server (through ZHttpClient so the read timeout is
// actually enforced raw client.send() hangs forever on a stalled body)
String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName;
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMinutes(5))
.header("Authorization", "Bearer " + AuthManager.getAccessToken())
.GET()
.build();
java.net.http.HttpResponse<Path> resp = java.net.http.HttpClient.newHttpClient()
.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile));
if (resp.statusCode() != 200) {
Files.deleteIfExists(targetFile);
sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode()));
return;
}
ZHttpClient.downloadFileWithSmartProxy(url, targetFile);
sendJson(exchange, Map.of("success", true, "message", modName + " installed"));
} catch (Exception e) {
@@ -1647,6 +1843,162 @@ public class JFXLauncher extends Application {
}
}
private void handleModsToggle(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
String enStr = body.get("enable");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
boolean enable = enStr == null || enStr.equals("true");
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot toggle mods in server pack (only whitelist)")); return; }
Path modsDir = inst.getPath().resolve("mods");
Path src = modsDir.resolve(file);
if (!Files.exists(src)) {
// Try alternative name (enable: caller may send base name without .disabled)
String alt = enable ? file + ".disabled" : file.replace(".disabled", "");
Path altSrc = modsDir.resolve(alt);
if (Files.exists(altSrc)) src = altSrc;
else if (enable && file.endsWith(".jar")) {
Path disabledSrc = modsDir.resolve(file + ".disabled");
if (Files.exists(disabledSrc)) src = disabledSrc;
else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
file = file + ".disabled";
} else { sendJson(exchange, Map.of("success", false, "error", "File not found: " + file)); return; }
}
Path dst;
if (enable) {
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
else if (src.getFileName().toString().endsWith(".disabled")) dst = modsDir.resolve(src.getFileName().toString().replace(".disabled", ""));
else { sendJson(exchange, Map.of("success", true)); return; }
} else {
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
else if (src.getFileName().toString().endsWith(".jar")) dst = modsDir.resolve(src.getFileName().toString() + ".disabled");
else { sendJson(exchange, Map.of("success", true)); return; }
}
if (Files.exists(dst)) Files.delete(dst);
Files.move(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handleModsDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot delete mods in server pack")); return; }
Path target = inst.getPath().resolve("mods").resolve(file);
if (!Files.exists(target)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
Files.delete(target);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackOpenFolder(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
Path dir = inst.getPath();
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().open(dir.toFile());
} else {
String os = System.getProperty("os.name").toLowerCase();
String cmd = os.contains("win") ? "explorer" : os.contains("mac") ? "open" : "xdg-open";
new ProcessBuilder(cmd, dir.toString()).start();
}
sendJson(exchange, Map.of("success", true));
} catch (Exception e) {
// fallback xdg
try { new ProcessBuilder("xdg-open", dir.toString()).start(); sendJson(exchange, Map.of("success", true)); }
catch (Exception e2) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackSettings(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String newName = body.get("newName");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
if (name == null) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot edit server pack")); return; }
boolean renamed = false;
if (newName != null && !newName.isBlank() && !newName.equals(name)) {
if (newName.matches(".*[\\\\/:*?\"<>|].*") || newName.length() > 32) { sendJson(exchange, Map.of("success", false, "error", "Invalid name")); return; }
if (InstanceManager.getInstance(newName) != null) { sendJson(exchange, Map.of("success", false, "error", "Name already exists")); return; }
Path src = inst.getPath();
Path dst = src.getParent().resolve(newName);
Files.move(src, dst);
// need to update instance path internally - recreate
inst = InstanceManager.getInstance(newName);
renamed = true;
}
Instance target = renamed ? InstanceManager.getInstance(newName != null ? newName : name) : inst;
if (loader != null && !loader.isBlank()) {
target.setLoaderType(loader);
if (loaderVersion != null) target.setLoaderVersion(loaderVersion);
// if loader changed, reinstall loader libs
if (!"vanilla".equalsIgnoreCase(loader) && loaderVersion != null && !loaderVersion.isBlank()) {
// trigger install in background? For now just save meta; user will reinstall manually via manage
}
}
sendJson(exchange, Map.of("success", true, "renamed", renamed));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
boolean ok = InstanceManager.deleteInstance(name);
sendJson(exchange, Map.of("success", ok, "error", ok ? "" : "Delete failed"));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handleFileManager(HttpExchange exchange) {
try {
String os = System.getProperty("os.name").toLowerCase();
String fm;
if (os.contains("win")) fm = "Explorer";
else if (os.contains("mac")) fm = "Finder";
else {
String desktop = System.getenv("XDG_CURRENT_DESKTOP");
if (desktop == null) desktop = System.getenv("DESKTOP_SESSION");
if (desktop != null) {
String d = desktop.toLowerCase();
if (d.contains("gnome")) fm = "Nautilus";
else if (d.contains("kde")) fm = "Dolphin";
else if (d.contains("xfce")) fm = "Thunar";
else fm = "File Manager ("+desktop+")";
} else fm = "File Manager";
}
sendJson(exchange, Map.of("success", true, "data", Map.of("name", fm, "os", os)));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
// ====================== ADMIN ======================
private void handleAdmin(HttpExchange exchange) {
@@ -1735,7 +2087,7 @@ public class JFXLauncher extends Application {
}
}
private void log(String msg) {
private static void log(String msg) {
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
String entry = "[" + timestamp + "] " + msg;
synchronized (launcherLogBuffer) {
@@ -13,7 +13,7 @@ public class Config {
private static final Properties props = new Properties();
private static volatile int maxMemory = 4096;
private static volatile String serverUrl = "https://api.zernmc.ru";
private static volatile String serverUrl = "https://api.zern.cc";
private static volatile String lastUsername = "Player";
private static volatile int windowWidth = 1280;
private static volatile int windowHeight = 720;
@@ -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");
@@ -144,6 +150,11 @@ public class Config {
return serverUrl;
}
public static void setServerUrl(String url) {
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
save();
}
public static String getLastUsername() {
return lastUsername;
}
@@ -261,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(); }
}
@@ -0,0 +1,133 @@
package me.sashegdev.zernmc.launcher.utils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Picks the fastest reachable ZernMC API domain at launcher startup.
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
*/
public final class DomainSelector {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final List<String> FALLBACK_CANDIDATES = List.of(
"https://api.zern.cc",
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
private static final String MIRRORS_PATH = "/launcher/mirrors";
private static final String VERSION_PATH = "/launcher/version";
private DomainSelector() {
}
/**
* @return выбранный base URL или null, если ни один домен не ответил.
*/
public static String selectBest() {
List<String> candidates = new ArrayList<>();
String configured = Config.getServerUrl();
if (configured != null && !configured.isBlank() && !candidates.contains(configured)) {
candidates.add(configured);
}
for (String c : FALLBACK_CANDIDATES) {
if (!candidates.contains(c)) {
candidates.add(c);
}
}
candidates.addAll(fetchMirrors());
String best = null;
long bestMs = Long.MAX_VALUE;
for (String base : candidates) {
Probe r = probe(base);
if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) {
bestMs = r.latencyMs;
best = base;
}
}
if (best != null) {
// Make every candidate (incl. geo mirrors) available as a fallback proxy base.
for (String c : candidates) {
ZHttpClient.registerProxyBase(c);
}
}
if (best == null) {
// nothing reachable: keep whatever is configured
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
return null;
}
if (!best.equals(configured)) {
ZHttpClient.setBaseUrl(best);
Config.setServerUrl(best);
System.out.println(ZAnsi.cyan("Selected API domain: " + best)
+ " (" + bestMs + " ms)");
}
return best;
}
private static List<String> fetchMirrors() {
List<String> mirrors = new ArrayList<>();
for (String candidate : FALLBACK_CANDIDATES) {
Probe p = probe(candidate);
if (!p.ok || p.body == null) {
continue;
}
try {
org.json.JSONObject json = new org.json.JSONObject(p.body);
var arr = json.optJSONArray("mirrors");
if (arr == null) {
continue;
}
for (int i = 0; i < arr.length(); i++) {
String url = arr.optJSONObject(i) != null
? arr.optJSONObject(i).optString("url", null) : null;
if (url != null && !url.isBlank() && !mirrors.contains(url)) {
mirrors.add(url);
}
}
break; // первый живой источник списка достаточен
} catch (Exception ignored) {
// continue to next candidate
}
}
return mirrors;
}
private static Probe probe(String baseUrl) {
long t0 = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + MIRRORS_PATH))
.timeout(Duration.ofSeconds(8))
.GET()
.header("User-Agent", "ZernMC-Launcher/DomainSelector")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
boolean ok = response.statusCode() == 200;
return new Probe(ok, ms, ok ? response.body() : null);
} catch (Exception e) {
return new Probe(false, System.currentTimeMillis() - t0, null);
}
}
private record Probe(boolean ok, long latencyMs, String body) {
}
}
@@ -15,6 +15,7 @@ import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
public class LauncherLogger {
@@ -25,6 +26,7 @@ public class LauncherLogger {
private static final int STDOUT_QUEUE_CAPACITY = 2048;
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
private static final Thread stdoutWriterThread;
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
static {
stdoutWriterThread = new Thread(() -> {
@@ -51,11 +53,10 @@ public class LauncherLogger {
try {
Path logsDir = Paths.get("").toAbsolutePath().resolve("logs");
Files.createDirectories(logsDir);
logFile = logsDir.resolve("launcher.log");
rotateLogs(logsDir);
logFile = logsDir.resolve("latest.log");
Files.writeString(logFile,
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
Files.writeString(logFile, sessionHeader(), StandardOpenOption.CREATE);
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
} catch (Exception e) {
@@ -64,6 +65,62 @@ public class LauncherLogger {
}
}
private static String sessionHeader() {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String os = System.getProperty("os.name") + " " + System.getProperty("os.version")
+ " " + System.getProperty("os.arch");
String java = System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")";
String mem = (Runtime.getRuntime().maxMemory() / (1024 * 1024)) + " MB";
return "== ZernMC Launcher =======================================================\n"
+ "== Started: " + ts + "\n"
+ "== OS: " + os + "\n"
+ "== Java: " + java + "\n"
+ "== Max memory: " + mem + "\n"
+ "====================================================================\n";
}
/**
* Minecraft-style rotation: compress the previous latest.log into a dated
* .log.gz archive (unlimited history), then remove the legacy launcher.log.
*/
private static void rotateLogs(Path logsDir) {
// Legacy one-instance log from older builds: delete it, it is replaced by latest.log.
try {
Files.deleteIfExists(logsDir.resolve("launcher.log"));
} catch (Exception ignored) {
}
Path latest = logsDir.resolve("latest.log");
try {
if (Files.exists(latest) && Files.size(latest) > 0) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
int n = 1;
Path archive;
do {
archive = logsDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz");
n++;
} while (Files.exists(archive));
gzip(logsDir, archive, latest);
Files.deleteIfExists(latest);
}
} catch (Exception e) {
System.err.println("[LauncherLogger] rotate error: " + e.getMessage());
}
}
private static void gzip(Path logsDir, Path archive, Path source) {
try (var in = Files.newInputStream(source);
var gz = new java.util.zip.GZIPOutputStream(Files.newOutputStream(archive))) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) > 0) {
gz.write(buf, 0, n);
}
} catch (Exception e) {
System.err.println("[LauncherLogger] gzip error: " + e.getMessage());
}
}
public static Path getLogFile() {
return logFile;
}
@@ -97,9 +154,12 @@ public class LauncherLogger {
// block application threads.
offerStdout(line);
// File and UI log never get ANSI escape codes; the console keeps them.
String plainLine = ANSI_PATTERN.matcher(line).replaceAll("");
// Forward to the JFX UI log so installer/progress output is visible
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
JFXBridge.appendLauncherLogFromLogger(line);
JFXBridge.appendLauncherLogFromLogger(plainLine);
if (t != null) {
StringWriter sw = new StringWriter();
@@ -120,7 +180,7 @@ public class LauncherLogger {
}
if (acquired) {
try {
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
@@ -4,14 +4,27 @@ import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
import java.text.DecimalFormat;
/**
* Progress reporting for installs/downloads.
*
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
* once that pipe fills (or the exe stops reading), a synchronous
* System.out.write/flush blocks the calling (install/UI) thread forever.
* All console/progress output must go through LauncherLogger, which is
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
*/
public class ProgressBar {
private static final int BAR_LENGTH = 40;
private static final DecimalFormat DF = new DecimalFormat("#.##");
private static String currentLabel = "";
private static long currentTotal = 0;
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
private static volatile long lastDownloadLogMs = 0;
private static volatile long lastIndeterminateLogMs = 0;
public static void show(String label, long current, long total, String unit) {
currentLabel = label;
currentTotal = total;
@@ -19,19 +32,11 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label, (int) current, (int) total);
if (total <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
LauncherLogger.info(label + " ...");
return;
}
double progress = (double) current / total;
int filled = (int) (progress * BAR_LENGTH);
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
int percent = (int) (progress * 100);
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
ZAnsi.cyan(label), bar, percent, current, total, unit);
System.out.print("\r" + text);
System.out.flush();
int percent = (int) ((double) current / total * 100);
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
}
public static void showDownload(String label, long downloaded, long totalBytes) {
@@ -40,25 +45,13 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
if (totalBytes <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return;
// Throttle console/file logging: called once per chunk during downloads.
long now = System.currentTimeMillis();
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastDownloadLogMs = now;
LauncherLogger.info(label + " " + formatBytes(downloaded)
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
}
double progress = (double) downloaded / totalBytes;
int filled = (int) (progress * BAR_LENGTH);
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
String percent = DF.format(progress * 100);
String text = String.format("%s [%s] %6s%% %s / %s",
ZAnsi.cyan(label),
bar,
percent,
formatBytes(downloaded),
formatBytes(totalBytes));
System.out.print("\r" + text);
System.out.flush();
}
public static void showAnimated(String label, long current, long total, String unit) {
@@ -67,18 +60,51 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
if (total <= 0) {
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (current / 1024) % 4;
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
} else {
show(label, (int) ((current * 100) / total), 100, unit);
long now = System.currentTimeMillis();
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastDownloadLogMs = now;
LauncherLogger.info(label + ": " + formatBytes(current)
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
}
}
public static void finish(String message) {
System.out.println("\r" + ZAnsi.brightGreen(message + " done"));
System.out.flush();
LauncherLogger.info(message + " done");
}
/**
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric
* installer subprocess). Always updates the UI label so the user sees what the
* launcher is doing right now; console/file logging is throttled to avoid spam.
*/
public static void showIndeterminate(String label) {
currentLabel = label;
currentTotal = 0;
JFXBridge.setInstallProgress(label, 0, 0);
long now = System.currentTimeMillis();
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastIndeterminateLogMs = now;
LauncherLogger.info(label + " ...");
}
}
/**
* Strips ANSI codes, indentation and [category] prefixes from a child-process
* output line so it can be used as a short, human-readable progress label.
*/
public static String cleanLine(String line) {
if (line == null) return "";
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
int bracket = s.indexOf("] ");
if (s.startsWith("[") && bracket > 0) {
s = s.substring(bracket + 2).trim();
}
if (s.length() > 60) {
s = s.substring(0, 57) + "...";
}
return s;
}
public static void setStage(String stageName, int stageIndex, int stageCount) {
@@ -86,8 +112,7 @@ public class ProgressBar {
}
public static void clearLine() {
System.out.print("\r" + " ".repeat(110) + "\r");
System.out.flush();
// No-op: console progress now goes through LauncherLogger.
}
public static String formatBytes(long bytes) {
@@ -11,6 +11,7 @@ import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -19,18 +20,54 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public class ZHttpClient {
/**
* Dedicated daemon executor for the shared HttpClient. sendAsync() work runs
* here (never the common ForkJoinPool, so a stalled download cannot starve
* JFX's pools), and daemon threads never keep the launcher JVM alive.
*/
private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "zern-http-client");
t.setDaemon(true);
return t;
});
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_1_1)
.executor(CLIENT_EXECUTOR)
.build();
private static String BASE_URL = "https://api.zernmc.ru";
private static String BASE_URL = "https://api.zern.cc";
/**
* Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with
* a connection error (e.g. CDN "Connection reset" from the current region).
* Stay in sync with LAUNCHER_MIRRORS in server/main.py.
*/
private static final List<String> GEO_PROXY_BASES = List.of(
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
/**
* Extra proxy bases discovered at runtime (e.g. mirrors fetched from
* /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated.
*/
private static final List<String> extraProxyBases = new ArrayList<>();
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
@@ -40,12 +77,44 @@ public class ZHttpClient {
BASE_URL = url;
}
/**
* Registers an additional proxy base URL (mirror/geo domain) that the smart
* proxy fallback may rotate through. No-op if already present.
*/
public static void registerProxyBase(String url) {
if (url == null || url.isBlank()) return;
String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
synchronized (extraProxyBases) {
if (!extraProxyBases.contains(normalized)) {
extraProxyBases.add(normalized);
}
}
}
/**
* Ordered, deduplicated list of proxy endpoints to attempt: the primary
* BASE_URL first, then geo mirrors and any runtime-discovered mirrors.
*/
private static List<String> getProxyBases() {
List<String> bases = new ArrayList<>();
bases.add(BASE_URL);
for (String geo : GEO_PROXY_BASES) {
if (!bases.contains(geo)) bases.add(geo);
}
synchronized (extraProxyBases) {
for (String m : extraProxyBases) {
if (!bases.contains(m)) bases.add(m);
}
}
return bases;
}
public static String getBaseUrl() {
return BASE_URL;
}
public enum ServiceType {
ZERN_SERVER("https://api.zernmc.ru", true),
ZERN_SERVER("https://api.zern.cc", true),
FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false),
@@ -129,12 +198,19 @@ public class ZHttpClient {
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
}
}
LauncherLogger.info("[NET] " + service.name() + ": "
+ (isHealthy ? "OK" : "unavailable") +
(serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)"));
}
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
if (verbose) {
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
}
LauncherLogger.warn("[NET] Critical: Zern server unreachable");
} else {
String ip = getClientPublicIp();
LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown"));
}
proxyTested.set(true);
@@ -144,6 +220,29 @@ public class ZHttpClient {
}
}
/**
* Asks the Zern server what public IP it sees for this client (used to
* attribute user logs when login/registration fails). Returns null on failure.
*/
public static String getClientPublicIp() {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/launcher/ip"))
.timeout(Duration.ofMillis(15000))
.GET()
.header("User-Agent", "ZernMC-Launcher/IPCheck")
.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15);
if (response.statusCode() == 200) {
JSONObject json = new JSONObject(response.body());
return json.optString("ip", null);
}
} catch (Exception e) {
return null;
}
return null;
}
public static void forceCheckMojangServices() {
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
@@ -164,6 +263,9 @@ public class ZHttpClient {
}
private static boolean checkServiceHealth(ServiceType service) {
if (service == ServiceType.ZERN_SERVER) {
return checkDirectConnection(BASE_URL);
}
return checkDirectConnection(service.getBaseUrl());
}
@@ -219,7 +321,7 @@ public class ZHttpClient {
if (isHealthy && serviceProxyMode.get(service)) {
serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0);
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection");
} else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails);
@@ -227,7 +329,7 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled");
}
}
}
@@ -271,6 +373,7 @@ public class ZHttpClient {
msg.contains("connection") ||
msg.contains("timeout") ||
msg.contains("refused") ||
msg.contains("received within") ||
msg.contains("closed") ||
msg.contains("reset") ||
msg.contains("abort");
@@ -296,23 +399,46 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy");
}
}
/**
* Synchronous HTTP send with a hard timeout. Uses client.send() (blocking on the
* calling thread) rather than sendAsync(...).get(...) the latter submits work
* to the HttpClient's executor and, when that was a non-daemon CachedThreadPool,
* kept the JVM alive / could starve the common ForkJoinPool (the v1.0.14.2
* "JFX hang (ForkJoinPool)" regression, re-introduced in v1.0.14.3).
* The request-level .timeout(Duration) bounds the actual network I/O.
* HTTP send with a hard, actually-enforced timeout.
*
* NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers
* (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as
* response headers arrive, so a download that stalls mid-body blocks
* client.send() forever (no exception, no retry, no proxy fallback the
* exact "install hangs at 0%" bug). Therefore we drive the exchange through
* sendAsync() + future.get(timeout) and abort it with cancel() on timeout.
*
* The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so
* this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation /
* non-daemon executor keeping the JVM alive) that forced the old sync-send
* implementation.
*/
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true)
.timeout(Duration.ofSeconds(timeoutSeconds))
.build();
return client.send(timed, handler);
CompletableFuture<HttpResponse<T>> future = client.sendAsync(timed, handler);
try {
return future.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri());
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException) throw (IOException) cause;
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
throw new IOException("HTTP request failed: " + timed.uri(), cause);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw e;
}
}
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
@@ -342,11 +468,15 @@ public class ZHttpClient {
}
}
List<String> proxyBases = getProxyBases();
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint.
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
@@ -366,8 +496,10 @@ public class ZHttpClient {
} catch (Exception e) {
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
@@ -376,6 +508,11 @@ public class ZHttpClient {
}
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
Path tmp = target.resolveSibling(target.getFileName() + ".part");
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (!shouldUseProxyForUrl(url)) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
@@ -393,12 +530,14 @@ public class ZHttpClient {
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45);
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
if (response.statusCode() == 200) {
moveIntoPlace(tmp, target);
directSuccessCount++;
return;
}
// non-200 response: fall through to proxy below
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
@@ -410,11 +549,19 @@ public class ZHttpClient {
}
}
List<String> proxyBases = getProxyBases();
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// Rotate through every proxy base so one region's Connection Reset does not
// burn all retries on the same endpoint.
String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size());
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl;
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
@@ -423,24 +570,45 @@ public class ZHttpClient {
.GET()
.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300);
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300);
if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
}
moveIntoPlace(tmp, target);
proxySuccessCount++;
return;
} catch (Exception e) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Proxy download failed: " + e.getMessage(), e);
throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries
+ " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base");
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
}
/**
* Atomically replaces the destination with the fully-downloaded temp file.
* Prevents a Connection Reset mid-transfer from leaving a corrupt partial
* file at the final path that later "file exists" checks would treat as installed.
*/
private static void moveIntoPlace(Path tmp, Path target) throws IOException {
Files.createDirectories(target.getParent());
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Retries a request against the Zern server. The server proxies are slow on cold cache
* and may return transient 502/5xx when upstreams are flaky, so a single failure should
@@ -455,7 +623,7 @@ public class ZHttpClient {
last = e;
if (!isRetryableError(e)) throw e;
if (attempt < maxAttempts) {
System.out.println(ZAnsi.yellow("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint));
LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")");
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ie;
@@ -501,7 +669,7 @@ public class ZHttpClient {
try {
return getMojangVersionManifestViaServer();
} catch (Exception e) {
System.out.println(ZAnsi.yellow("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
LauncherLogger.warn("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
}
}
@@ -523,17 +691,30 @@ public class ZHttpClient {
}
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
long deadline = System.currentTimeMillis() + 90_000;
LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy...");
try {
return getMojangVersionViaServer(versionId);
JSONObject result = getMojangVersionViaServer(versionId);
LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)");
return result;
} catch (Exception e) {
System.out.println(ZAnsi.yellow("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
if (System.currentTimeMillis() >= deadline) {
throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
JSONObject manifest = getMojangVersionManifest();
JSONArray versions = manifest.getJSONArray("versions");
for (int i = 0; i < versions.length(); i++) {
JSONObject v = versions.getJSONObject(i);
if (v.getString("id").equals(versionId)) {
return new JSONObject(getWithSmartProxy(v.getString("url")));
if (System.currentTimeMillis() >= deadline) {
throw new IOException("Version " + versionId + " fetch timed out after 90s");
}
LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly...");
JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url")));
LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly");
return result;
}
}
throw new IOException("Version " + versionId + " not found");
@@ -574,12 +755,12 @@ public class ZHttpClient {
try {
repairLibrariesFromJson(versionJson, minecraftDir);
} catch (Exception e) {
System.out.println(ZAnsi.yellow("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage()));
LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage());
}
}
});
} catch (IOException e) {
System.out.println(ZAnsi.yellow("[LIB] Repair scan failed: " + e.getMessage()));
LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage());
}
}
@@ -619,15 +800,92 @@ public class ZHttpClient {
}
} catch (Exception e) {
String libName = lib.optString("name", lib.optString("path", "?"));
System.out.println(ZAnsi.yellow("[LIB] Repair failed for " + libName + ": " + e.getMessage()));
LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage());
}
}
if (repaired > 0) {
System.out.println(ZAnsi.green("[LIB] Repaired " + repaired + " missing libraries"));
LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries");
}
}
/**
* Pre-downloads every library referenced by a loader installer jar (Forge/NeoForge).
* The installer later runs as a separate JVM subprocess that downloads libraries
* directly from the loader's maven repository, bypassing this launcher's smart proxy.
* On blocked networks (e.g. maven.minecraftforge.net unreachable) that subprocess
* fails with "Connection reset". Because the installer reuses any library already
* present with a matching checksum ("File exists: Checksum validated"), pre-seeding
* those files through the proxy lets the install complete without direct downloads.
*
* @return number of new library files downloaded
*/
public static int prefetchInstallerLibraries(Path installerJar, Path minecraftDir) {
int prefetched = 0;
try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(installerJar.toFile())) {
for (String jsonName : List.of("version.json", "install_profile.json")) {
java.util.zip.ZipEntry entry = zip.getEntry(jsonName);
if (entry == null) continue;
String content = new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8);
JSONObject root = new JSONObject(content);
JSONArray libs = root.optJSONArray("libraries");
if (libs == null) continue;
for (int i = 0; i < libs.length(); i++) {
JSONObject lib = libs.optJSONObject(i);
if (lib == null) continue;
JSONObject downloads = lib.optJSONObject("downloads");
if (downloads == null) continue;
JSONObject artifact = downloads.optJSONObject("artifact");
if (artifact == null) continue;
String url = artifact.optString("url", null);
String path = artifact.optString("path", null);
String sha1 = artifact.optString("sha1", null);
if (url == null || path == null) continue;
Path target = minecraftDir.resolve("libraries").resolve(path);
try {
if (Files.exists(target)) {
if (sha1 == null || sha1.isEmpty() || sha1Matches(target, sha1)) {
continue;
}
Files.delete(target);
}
Files.createDirectories(target.getParent());
downloadFileWithSmartProxy(url, target);
if (sha1 != null && !sha1.isEmpty() && !sha1Matches(target, sha1)) {
Files.delete(target);
LauncherLogger.warn("[LIB] Checksum mismatch after prefetch: " + path);
continue;
}
prefetched++;
LauncherLogger.info("[LIB] Prefetched " + path);
} catch (Exception e) {
LauncherLogger.warn("[LIB] Prefetch failed for " + path + ": " + e.getMessage());
}
}
}
} catch (Exception e) {
LauncherLogger.warn("[LIB] Installer prefetch scan failed: " + e.getMessage());
}
return prefetched;
}
private static boolean sha1Matches(Path file, String expectedSha1) throws Exception {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1");
try (java.io.InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) {
sb.append(String.format("%02x", b));
}
return sb.toString().equalsIgnoreCase(expectedSha1);
}
private static String resolveCoordinatesUrl(JSONObject lib) {
String name = lib.getString("name");
String base = lib.getString("url");
@@ -724,6 +982,10 @@ public class ZHttpClient {
return proxyTested.get();
}
public static boolean isZernServerHealthy() {
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
}
public static Map<String, Object> getNetworkStatus() {
Map<String, Object> status = new HashMap<>();
status.put("initialized", proxyTested.get());
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+87 -12
View File
@@ -39,6 +39,7 @@
<div class="field">
<label for="password" data-i18n="login.password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
</div>
<div class="field hidden" id="confirm-field">
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
@@ -57,6 +58,38 @@
</div>
</div>
<!-- Offline Screen -->
<div id="offline-screen" class="screen hidden">
<div class="offline-container">
<div class="offline-badge">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h2 class="offline-title" data-i18n="offline.title">Offline Mode</h2>
<p class="offline-subtitle" data-i18n="offline.subtitle">The Zern server is unreachable. You can still launch locally installed packs without an account.</p>
<form id="offline-form" class="login-form">
<div class="field">
<label for="offline-nickname" data-i18n="offline.nickname">Nickname</label>
<input type="text" id="offline-nickname" maxlength="16" autocomplete="off">
</div>
<div class="field">
<label for="offline-instance" data-i18n="offline.instance">Pack</label>
<select id="offline-instance"></select>
</div>
<p id="offline-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="offline-launch-btn">
<span class="btn-label" data-i18n="offline.launch">Launch Offline</span>
<div class="spinner hidden"></div>
</button>
</form>
<div class="offline-footer">
<button type="button" class="btn-ghost" id="offline-retry-btn" data-i18n="offline.retry">Try again online</button>
<button type="button" class="btn-ghost" id="offline-exit-btn" data-i18n="offline.exit">Exit</button>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div>
@@ -108,24 +141,30 @@
</div>
</nav>
<div class="sidebar-section">
<div class="section-header">
<div class="sidebar-section section-collapsible" id="server-packs-section">
<div class="section-header" id="server-packs-header" title="Toggle">
<span class="section-title-wrap">
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
</span>
<button class="btn-icon" id="add-server-pack-btn" title="Install server pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div id="server-packs-list" class="pack-list"></div>
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div>
</div>
<div class="sidebar-section" id="local-packs-section">
<div class="section-header">
<div class="sidebar-section section-collapsible" id="local-packs-section">
<div class="section-header" id="local-packs-header" title="Toggle">
<span class="section-title-wrap">
<svg class="section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div id="local-packs-list" class="pack-list"></div>
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div>
</div>
</div>
@@ -206,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>
@@ -480,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>
@@ -511,7 +569,7 @@
<h3 data-i18n="logViewer.title">Game Log</h3>
<div class="log-viewer-actions">
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
<button class="btn-secondary btn-sm" onclick="app.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</span></button>
<button class="btn-secondary btn-sm" onclick="app.openLogFile()"><span data-i18n="logViewer.openFile">Open File</span></button>
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">&times;</button>
</div>
</div>
@@ -531,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">
@@ -551,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">
File diff suppressed because it is too large Load Diff
+137 -4
View File
@@ -29,6 +29,11 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { -webkit-user-select: none; user-select: none; }
.pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
.pack-entry { -webkit-touch-callout: default; }
html { font-size: 14px; }
body {
@@ -42,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; }
@@ -99,6 +107,14 @@ body {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.field input.invalid {
border-color: var(--error);
box-shadow: 0 0 0 2px rgba(248,113,113,0.15);
}
.field .field-hint {
font-size: 12px; color: var(--error); line-height: 1.4;
}
.field .field-hint.hidden { display: none; }
.field select {
width: 100%; padding: 10px 12px; font-size: 15px;
background: var(--bg-surface); border: 1px solid var(--border);
@@ -211,12 +227,38 @@ body {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; padding: 0 4px;
}
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.section-collapsible .section-header { cursor: pointer; user-select: none; }
.section-collapsible .section-header:hover .section-title { color: var(--text-secondary); }
.section-title-wrap {
display: flex; align-items: center; gap: 6px; min-width: 0;
}
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); }
.section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); }
.section-collapsible { display: flex; flex-direction: column; min-height: 0; }
.section-collapsible:not(.collapsed) { flex: 1 1 auto; }
.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); }
.section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); }
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
.section-collapsible.collapsed .section-title { color: var(--text-muted); }
.pack-list-collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .3s var(--ease, ease);
min-height: 0;
overflow: hidden;
}
.section-collapsible:not(.collapsed) .pack-list-collapse {
grid-template-rows: 1fr;
flex: 1;
}
.pack-list-collapse > .pack-list {
overflow-y: auto;
min-height: 0;
}
.pack-list {
display: flex; flex-direction: column; gap: 3px;
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
min-height: 40px;
}
.pack-list:empty::after {
content: 'No packs'; display: block; padding: 12px 8px;
@@ -294,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;
@@ -642,6 +687,15 @@ body {
background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease;
}
.progress-fill.indeterminate {
width: 30%;
transition: none;
animation: indeterminate 1.3s ease-in-out infinite;
}
@keyframes indeterminate {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
@@ -900,3 +954,82 @@ body {
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
/* ========== OFFLINE MODE ========== */
.offline-container {
position: relative; z-index: 1;
background: var(--bg-elevated);
border-radius: var(--radius-md);
padding: 32px 32px 24px;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
text-align: center;
}
.offline-badge {
width: 64px; height: 64px;
margin: 0 auto 14px;
display: flex; align-items: center; justify-content: center;
background: rgba(233,69,96,0.12);
border: 1px solid rgba(233,69,96,0.3);
border-radius: 14px;
color: var(--accent);
}
.offline-title { font-size: 22px; font-weight: 700; color: var(--text); }
.offline-subtitle {
color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5;
}
.offline-container .login-form { text-align: left; }
.offline-footer {
display: flex; gap: 8px; justify-content: center; margin-top: 18px;
}
.btn-ghost {
padding: 9px 14px; font-size: 13px;
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
cursor: pointer; font-family: var(--font);
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; }
+8 -2
View File
@@ -14,12 +14,13 @@
<modules>
<module>bootstrap</module>
<module>diag</module>
<module>launcher</module>
</modules>
<properties>
<revision>1.0.16</revision>
<hotfix>2</hotfix>
<revision>1.1.1</revision>
<hotfix>5</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -95,6 +96,11 @@
<version>23.0.1</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>3.6.5</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
+9
View File
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
allowed, wait = check_rate_limit(ip)
if not allowed:
logger.warning("register rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
).fetchone()
if existing:
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
raise HTTPException(409, "Пользователь с таким именем уже существует")
uuid = generate_uuid()
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
)
user_id = cursor.lastrowid
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
# Создаем сессию
session_token = secrets.token_urlsafe(32)
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
allowed, wait = check_rate_limit(ip)
if not allowed:
logger.warning("login rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
).fetchone()
if not user or not verify_password(body.password, user["password_hash"]):
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
record_login_attempt(ip, False)
raise HTTPException(401, "Неверное имя пользователя или пароль")
if not user["is_active"]:
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time():
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт забанен")
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
record_login_attempt(ip, True)
now = time.time()
+274 -11
View File
@@ -12,7 +12,8 @@ import json
import structlog
from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging
@@ -42,14 +43,26 @@ logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
BUILDS_DIR = Path(__file__).parent / "builds"
VERSIONS_DIR = BUILDS_DIR / "versions"
WHITELIST_DIR = Path(__file__).parent / "whitelist"
# Website (landing) static files
SITE_DIR = Path(__file__).parent / "site"
# SkinRestorer skins (shared with TG bot)
SKINS_DIR = Path("/root/python/site/skins")
# Minecraft server status (same host the TG bot pings)
MC_STATUS_HOST = os.environ.get("MC_STATUS_HOST", "mc.zernmc.ru")
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "https://api.zernmc.ru",
"mirror-1": "https://api.zernmc.online",
"main": "https://api.zern.cc", # primary
"mirror-1": "https://api.zernmc.ru", # legacy
"mirror-2": "https://api.zernmc.online", # legacy
"geo-pl": "https://api.pl.zern.cc",
"geo-swe": "https://api.swe.zern.cc",
}
# Server role: "main" or "mirror"
@@ -71,6 +84,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
# Crash reports directory
CRASH_REPORTS_DIR = Path("data/crash_reports")
# Network diagnostics reports directory
DIAG_REPORTS_DIR = Path("data/diag_reports")
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -832,7 +848,7 @@ class CacheControlMiddleware:
path = scope.get("path", "")
# Skip caching for dynamic endpoints
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install", "/skin/"])
if skip_cache:
await self.app(scope, receive, send)
return
@@ -947,6 +963,17 @@ app.include_router(admin_router)
app.include_router(friends_router)
app.include_router(playtime_router)
# Static assets for the landing site
for sub in ("css", "js", "img"):
mount_dir = SITE_DIR / sub
mount_dir.mkdir(parents=True, exist_ok=True)
app.mount(f"/{sub}", StaticFiles(directory=mount_dir), name=f"site-{sub}")
# Server highlights media (screenshots / clips for the landing carousel)
HIGHLIGHTS_DIR = SITE_DIR / "highlights"
HIGHLIGHTS_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/highlights", StaticFiles(directory=HIGHLIGHTS_DIR), name="site-highlights")
# Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received
@@ -984,8 +1011,10 @@ HttpToolsProtocol.data_received = patched_data_received
@app.get("/")
async def root():
"""Root endpoint"""
logger.info("Root endpoint accessed")
"""Root endpoint - serves the launcher landing site"""
index_path = SITE_DIR / "index.html"
if index_path.exists():
return HTMLResponse(index_path.read_text(encoding="utf-8"))
return {
"status": "ok",
"message": "ZernMC Launcher Server is running",
@@ -994,6 +1023,83 @@ async def root():
}
@app.get("/skin/{filename}")
@app.head("/skin/{filename}")
async def get_skin(filename: str, request: Request):
"""Serve SkinRestorer skins (compatibility with TG bot direct links)"""
if not filename or ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=404, detail="Скин не найден")
filepath = SKINS_DIR / filename
if not filepath.is_file():
raise HTTPException(status_code=404, detail="Скин не найден")
try:
content = filepath.read_bytes()
size = len(content)
headers = {
"Content-Length": str(size),
"Content-Type": "image/png",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
"Access-Control-Allow-Origin": "*",
"Content-Encoding": "identity",
}
if request.method == "HEAD":
return Response(status_code=200, headers=headers)
return Response(
content=content,
media_type="image/png",
headers=headers
)
except Exception as e:
logger.error("Skin read error", error=str(e))
raise HTTPException(status_code=500, detail="Ошибка чтения скина")
@app.get("/api/status")
async def api_server_status():
"""Live Minecraft server status via mcstatus ping (same host as TG bot)"""
try:
from mcstatus import JavaServer
server = JavaServer.lookup(f"{MC_STATUS_HOST}")
status = await server.async_status()
players_list = []
try:
query = await server.async_query()
if query.players.names:
players_list = query.players.names
elif query.players.online > 0 and query.players.sample:
players_list = [p.name for p in query.players.sample]
except Exception:
if status.players.sample:
players_list = [p.name for p in status.players.sample]
version = getattr(status.version, "name", None) or "unknown"
motd = status.description
return {
"online": True,
"players_online": status.players.online,
"players_max": status.players.max,
"players_list": ", ".join(players_list) if players_list else "никого нет",
"players": players_list,
"motd": motd,
"version": version if isinstance(version, str) else str(version),
"updated": datetime.now().strftime("%H:%M:%S")
}
except asyncio.TimeoutError:
return {"online": False, "error": "Таймаут подключения к серверу", "updated": datetime.now().strftime("%H:%M:%S")}
except Exception as e:
return {"online": False, "error": f"{type(e).__name__}: {e}", "updated": datetime.now().strftime("%H:%M:%S")}
@app.get("/health")
async def health():
"""Health check endpoint"""
@@ -1204,6 +1310,32 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
)
@app.get("/pack/{pack_name}/zip")
async def download_pack_zip(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Download whole pack as zip for manual installation (requires pass)"""
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(403, "Requires active pass")
if is_pack_disabled(pack_name):
raise HTTPException(403, "Pack is disabled")
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise HTTPException(404, "Pack not found")
import tempfile, zipfile
tmp = Path(tempfile.gettempdir()) / f"{pack_name}.zip"
# re-use if fresh (<5 min)
if tmp.exists() and (datetime.utcnow().timestamp() - tmp.stat().st_mtime) < 300:
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
# create zip
def _zip():
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in pack_path.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(pack_path))
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _zip)
return await send_file_async(tmp, request, content_type="application/zip", cache=False)
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user), preset: Optional[str] = None):
"""Get a file from a pack. If preset is specified, try preset dir first, fall back to base."""
@@ -1350,6 +1482,14 @@ def generate_launcher_builds_meta():
# CLI exe is not shipped to users
if rel_path == "zernmc-cli.exe":
continue
# zernmc.exe embeds the launcher version, so its hash changes on
# every build even when the bootstrap code is unchanged. Exclude it
# from the incremental update: a new exe is only delivered via a
# full ZIP reinstall. Without this the bootstrap would re-download
# and re-stage its own exe on every launcher version bump, and the
# detached .cmd helper fails with "file not found".
if rel_path == "zernmc.exe":
continue
stat = file_path.stat()
# Calculate hash
@@ -1391,6 +1531,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
for file_path in version_path.rglob("*"):
if file_path.is_file() and file_path.name != "meta.json":
rel_path = str(file_path.relative_to(version_path))
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
continue
stat = file_path.stat()
file_hash = calculate_file_hash(file_path)
files.append({
@@ -1488,13 +1630,18 @@ def extract_new_format_versions():
# Find all ZernMC-win-*.zip files
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
total = len(new_format_zips)
extracted = 0
skipped = 0
failed = 0
for zip_file in new_format_zips:
version = zip_file.stem.replace("ZernMC-win-", "")
extract_dir = VERSIONS_DIR / version
# Skip if already extracted and meta exists
if extract_dir.exists() and (extract_dir / "meta.json").exists():
logger.debug(f"Version {version} already extracted")
skipped += 1
continue
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
@@ -1511,10 +1658,14 @@ def extract_new_format_versions():
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
generate_version_meta(extract_dir, version)
extracted += 1
logger.info(f"Extracted {zip_file.name} successfully")
except Exception as e:
failed += 1
logger.error(f"Failed to extract {zip_file.name}: {e}")
logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed")
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
@@ -1594,6 +1745,14 @@ def get_legacy_zips() -> list:
return zips
@app.get("/launcher/ip")
async def get_launcher_client_ip(request: Request):
"""Return the public IP that the server sees for this client."""
from middleware import get_client_ip
client_ip = get_client_ip(request)
return {"ip": client_ip}
@app.get("/launcher/version")
async def get_launcher_version():
"""Return launcher version information"""
@@ -1665,6 +1824,64 @@ async def download_launcher_exe(request: Request = None):
)
@app.get("/launcher/download/jre")
async def download_jre(request: Request):
"""Download JRE zip for online installer (47M, Range supported)"""
candidates = [
BUILDS_DIR / ".." / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip", # /root/launcher/OpenJDK...
Path(__file__).parent.parent / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip",
Path("/root/launcher/OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"),
BUILDS_DIR / "jre.zip",
]
for p in candidates:
pp = p.resolve()
if pp.exists() and pp.is_file():
return await send_file_async(pp, request, content_type="application/zip", cache=True)
raise HTTPException(404, "JRE zip not found on server")
def _find_latest_setup(pattern: str) -> Path | None:
"""Find latest Go setup exe by pattern (Online/Offline)"""
cands = list(BUILDS_DIR.glob(pattern))
if not cands:
return None
# sort by version key from filename
def ver_key(p: Path):
# ZernMC-Online-Setup-1.1.1.2.exe -> 1.1.1.2
name = p.stem
ver = name.split("-")[-1]
try:
return tuple(int(x) for x in ver.split("."))
except:
return (0,)
cands.sort(key=ver_key, reverse=True)
return cands[0]
@app.get("/launcher/download/online")
async def download_online_setup(request: Request):
"""Download online Go installer (5.8M, STANDALONE EXE without bundled JRE)"""
p = _find_latest_setup("ZernMC-Online-Setup-*.exe")
if p and p.exists():
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
return resp
raise HTTPException(404, "Online installer not found")
@app.get("/launcher/download/offline-setup")
async def download_offline_setup(request: Request):
"""Download offline Go installer (STANDALONE EXE with embedded build, ~100M)"""
p = _find_latest_setup("ZernMC-Offline-Setup-*.exe")
if p and p.exists():
resp = await send_file_async(p, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
return resp
# fallback: try legacy offline zip embed name without -Setup
p2 = _find_latest_setup("ZernMC-Offline-*.exe")
if p2 and p2.exists():
resp = await send_file_async(p2, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
resp.headers["Content-Disposition"] = f'attachment; filename="{p2.name}"'
return resp
raise HTTPException(404, "Offline installer not found")
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive"""
@@ -1965,6 +2182,36 @@ async def receive_crash_report(request: Request):
return {"status": "ok", "id": report_id}
@app.post("/diag/upload")
async def receive_diag_report(request: Request):
"""Receive and store network diagnostics reports from the diag utility"""
try:
body = await request.body()
except Exception:
raise HTTPException(status_code=400, detail="Failed to read body")
if not body:
raise HTTPException(status_code=400, detail="Empty body")
ip = request.client.host if request.client else "unknown"
name = request.query_params.get("name", "").strip()
if not name or "/" in name or "\\" in name or ".." in name:
name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log"
report_path = DIAG_REPORTS_DIR / name
try:
DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
await f.write(body.decode("utf-8", errors="replace"))
logger.info(f"Diag report saved: {report_path.name} from {ip}")
except Exception as e:
logger.error(f"Failed to save diag report: {e}")
raise HTTPException(status_code=500, detail="Failed to save report")
return {"status": "ok", "name": report_path.name}
# ====================== НОВОСТИ ======================
NEWS_DIR = Path(__file__).parent / "news"
@@ -2195,19 +2442,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
# Сначала получаем манифест, чтобы найти URL версии
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
cache_key = f"version_url_{version_id}"
version_url = proxy_cache.get(cache_key)
# Cache the final version JSON, not just the version URL. piston-meta is
# slow/unreliable from some regions, so full serialization of the version
# JSON (plus a warm-up) keeps installs from stalling on install time.
cache_key = f"version_json_{version_id}"
cached = proxy_cache.get(cache_key)
if cached is not None:
logger.info(f"Proxy served from cache: Mojang version {version_id}")
return JSONResponse(content=cached)
version_url = proxy_cache.get(f"version_url_{version_id}")
if not version_url:
try:
# Reuse the manifest already cached by /proxy/mojang/version_manifest
manifest = proxy_cache.get(manifest_url)
if manifest is None:
response = await proxy_client.get(manifest_url)
response.raise_for_status()
manifest = response.json()
proxy_cache[manifest_url] = manifest
logger.info("Proxy success: Mojang manifest")
else:
logger.info("Proxy served manifest from cache")
for version in manifest.get("versions", []):
if version.get("id") == version_id:
version_url = version.get("url")
proxy_cache[cache_key] = version_url
proxy_cache[f"version_url_{version_id}"] = version_url
break
if not version_url:
@@ -2222,6 +2484,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
response.raise_for_status()
data = response.json()
proxy_cache[cache_key] = data
logger.info(f"Proxy success: Mojang version {version_id}")
return JSONResponse(content=data)
+3
View File
@@ -159,6 +159,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
# Skip rate limiting for pack file downloads (direct high-speed serving)
if path.startswith("/pack/") and "/file/" in path:
is_file_download = True
elif path.startswith(("/skin/", "/css/", "/js/", "/img/", "/highlights/")):
# SkinRestorer skins + landing site assets must not be rate-limited
is_file_download = True
else:
is_file_download = False
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#g)"/>
<path d="M82 128 L128 82 L174 128 L128 174 Z" fill="white" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

+582
View File
@@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zern — Awaken from the cold</title>
<meta name="description" content="Zern — a custom Minecraft launcher. Forge, NeoForge, Fabric. Download and play on ZernMC.">
<link rel="icon" type="image/svg+xml" href="/img/zernmc.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav" id="nav">
<div class="nav-inner">
<a class="nav-brand" href="#top">
<img src="/img/zernmc.svg" alt="Zern" width="34" height="34">
<span>Zern</span>
</a>
<ul class="nav-links">
<li><a href="#server" data-i18n="nav.server">Сервер</a></li>
<li><a href="#highlights" data-i18n="nav.highlights">Хайлайты</a></li>
<li><a href="#theme" data-i18n="nav.season">Сезон</a></li>
<li><a href="#features" data-i18n="nav.features">Возможности</a></li>
<li><a href="#showcase" data-i18n="nav.showcase">Лаунчер</a></li>
<li><a href="#download" data-i18n="nav.download">Скачать</a></li>
<li><a href="#news" data-i18n="nav.news">Новости</a></li>
</ul>
<div class="nav-right">
<button class="lang-toggle" id="langToggle" type="button" aria-label="Switch language">
<span class="lang-toggle-label" id="langToggleLabel">EN</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button>
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
</div>
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
</div>
</div>
</nav>
<header class="hero" id="top">
<div class="hero-grid" aria-hidden="true"></div>
<div class="hero-glow" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--1" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--2" aria-hidden="true"></div>
<div class="hero-diamond hero-diamond--3" aria-hidden="true"></div>
<div class="hero-inner" data-parallax="0.15">
<p class="hero-kicker" data-i18n="hero.kicker">КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР</p>
<h1 class="hero-title" data-i18n-html="hero.title">Очнись от<br><span class="hero-title-accent">холода.</span></h1>
<p class="hero-sub" data-i18n="hero.sub">Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#download">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span data-i18n="hero.download">Скачать лаунчер</span>
</a>
<a class="btn btn-ghost" href="#features" data-i18n="hero.explore">Обзор</a>
</div>
<p class="hero-meta">v<span id="hero-version"></span> · <span id="hero-size"></span> · Windows</p>
</div>
<div class="scroll-hint" data-parallax="0.3">
<span data-i18n="hero.scroll">Листай вниз</span>
<div class="scroll-hint-line"><i></i></div>
</div>
</header>
<main>
<section class="section server" id="server">
<div class="section-head">
<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>
<div class="server-stats">
<div class="server-card">
<div class="server-card-label" data-i18n="server.status">Статус</div>
<div class="server-card-value" id="srv-status"><span class="status-dot status-dot--loading"></span> <span data-i18n="server.checking">Проверяем…</span></div>
<div class="server-card-sub" id="srv-version">1.20.1 / ОБТ</div>
</div>
<div class="server-card">
<div class="server-card-label" data-i18n="server.players">Игроков онлайн</div>
<div class="server-card-value" id="srv-players">— / —</div>
<div class="server-card-sub">mc.zernmc.ru</div>
</div>
<div class="server-card">
<div class="server-card-label" data-i18n="server.mode">Режим</div>
<div class="server-card-value">Survival <span class="server-season">· <span data-i18n="server.season">Сезон Prologue</span></span></div>
<div class="server-card-sub">1.21.1 NeoForge · Create: Aeronautics</div>
</div>
</div>
</section>
<section class="section highlights" id="highlights">
<div class="section-head">
<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>
<div class="carousel">
<div class="carousel-track" id="carousel"></div>
<button class="carousel-btn carousel-btn--prev" id="carouselPrev" data-i18n-aria="carousel.prev"></button>
<button class="carousel-btn carousel-btn--next" id="carouselNext" data-i18n-aria="carousel.next"></button>
<div class="carousel-dots" id="carouselDots"></div>
</div>
</section>
<section class="section features" id="features">
<div class="section-head">
<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>
<div class="features-grid">
<article class="feature-card">
<span class="feature-num">01</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<h3 data-i18n="features.forge.title">Forge, NeoForge, Fabric</h3>
<p data-i18n="features.forge.sub">Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.</p>
</article>
<article class="feature-card">
<span class="feature-num">02</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
</div>
<h3 data-i18n="features.ui.title">Тёмный, быстрый нативный UI</h3>
<p data-i18n="features.ui.sub">Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.</p>
</article>
<article class="feature-card">
<span class="feature-num">03</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 18H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M9 22v-4"/><path d="M15 22v-4"/></svg>
</div>
<h3 data-i18n="features.proxy.title">Умные зеркала и прокси</h3>
<p data-i18n="features.proxy.sub">Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.</p>
</article>
<article class="feature-card">
<span class="feature-num">04</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 0-9 14l2 5 5-2A10 10 0 1 0 12 2z"/><path d="M8.5 12h.01"/><path d="M12 12h.01"/><path d="M15.5 12h.01"/></svg>
</div>
<h3 data-i18n="features.news.title">Новости и статус сервера</h3>
<p data-i18n="features.news.sub">Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».</p>
</article>
<article class="feature-card">
<span class="feature-num">05</span>
<div class="feature-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3 data-i18n="features.pack.title">Установка сборки в один клик</h3>
<p data-i18n="features.pack.sub">Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.</p>
</article>
</div>
</section>
<section class="section showcase" id="showcase">
<div class="section-head section-head--center">
<span class="section-index">04 / 07</span>
<h2 class="section-title" data-i18n-html="showcase.title">Показываем <span class="accent">лаунчер</span></h2>
<p class="section-sub" data-i18n="showcase.sub">Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.</p>
</div>
<div class="launcher-preview" id="launcherPreview" role="img" aria-label="ZernMC Launcher preview, auto-rotating screens" data-i18n-aria="preview.aria">
<div class="launcher-window">
<div class="launcher-titlebar">
<div class="win-dots" aria-hidden="true"><span></span><span></span><span></span></div>
<span class="launcher-titlebar-name">
<svg class="win-icon" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
ZernMC Launcher <span id="preview-version"></span>
</span>
</div>
<div class="launcher-preview-body" id="launcherPreviewBody">
<div class="preview-scene scene-login is-active" data-key="login">
<div class="scene-login-inner">
<div class="pv-brand">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
</div>
<h3 class="pv-title" data-i18n="preview.signIn">Вход</h3>
<div class="pv-field"><label data-i18n="preview.username">Логин</label><div class="pv-input"></div></div>
<div class="pv-field"><label data-i18n="preview.password">Пароль</label><div class="pv-input"></div></div>
<div class="pv-btn" data-i18n="preview.signIn">Вход</div>
</div>
</div>
<div class="preview-scene scene-packs" data-key="packs">
<div class="pv-sidebar">
<div class="pv-sb-brand">
<svg width="24" height="24" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
<span>ZernMC<em>v<span class="pv-sb-ver">2.3.1</span></em></span>
</div>
<div class="pv-sb-nav">
<span class="pv-nav-item is-active" title="Packs">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span data-i18n="preview.nav.packs">Сборки</span>
</span>
<span class="pv-nav-item" title="News">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
<span data-i18n="preview.nav.news">Новости</span>
</span>
<span class="pv-nav-item" title="Friends">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span data-i18n="preview.nav.friends">Друзья</span>
</span>
</div>
<div class="pv-sb-section">
<span class="pv-sb-section-title" data-i18n="preview.serverPacks">Серверные сборки</span>
<div class="pv-pack-item">
<span class="pv-pack-name">Zern-OBT</span>
<span class="pv-pack-meta">1.20.1 · forge</span>
</div>
<div class="pv-pack-item is-active">
<span class="pv-pack-name">Zern-Prologue</span>
<span class="pv-pack-meta">1.21.1 · neoforge</span>
</div>
</div>
<div class="pv-sb-section">
<span class="pv-sb-section-title" data-i18n="preview.localPacks">Локальные сборки</span>
<div class="pv-pack-item">
<span class="pv-pack-name">Vanilla</span>
<span class="pv-pack-meta">1.20.1</span>
</div>
</div>
<div class="pv-sb-user">
<span class="pv-avatar">Z</span>
<span class="pv-user-meta"><em>zernplayer</em><i class="pv-badge">FREE</i></span>
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="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>
<svg class="pv-sb-action" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</div>
</div>
<div class="pv-main">
<div class="pv-main-head">
<div>
<h3>Zern-Prologue</h3>
<div class="pv-tags"><span class="pv-tag pv-tag-mc">1.21.1</span><span class="pv-tag">neoforge</span></div>
</div>
<div class="pv-stats">
<span><b>47.4</b><i data-i18n="preview.stat.loader">Загрузчик</i></span>
<span><b>130</b><i data-i18n="preview.stat.files">Файлы</i></span>
<span><b>1.8 ГБ</b><i data-i18n="preview.stat.size">Размер</i></span>
</div>
</div>
<div class="pv-gallery" aria-hidden="true">
<span class="pv-gallery-item pv-gallery-item--1"></span>
<span class="pv-gallery-item pv-gallery-item--2"></span>
<span class="pv-gallery-item pv-gallery-item--3"></span>
</div>
<div class="pv-playbar">
<span>Zern-Prologue · <em data-i18n="preview.ready">Готов к запуску</em></span>
<span class="pv-play">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<i data-i18n="preview.play">Играть</i>
</span>
</div>
</div>
</div>
<div class="preview-scene scene-news" data-key="news">
<div class="pv-panel">
<h3 class="pv-view-title" data-i18n="preview.nav.news">Новости</h3>
<div class="pv-news-item">
<span class="pv-news-tag pv-tag-update" data-i18n="preview.news.update">Обновление</span>
<b data-i18n="preview.news1.title">Сезон Prologue запущен</b>
<p data-i18n="preview.news1.body">Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.</p>
</div>
<div class="pv-news-item">
<span class="pv-news-tag pv-tag-event" data-i18n="preview.news.event">Событие</span>
<b data-i18n="preview.news2.title">Война в Заре</b>
<p data-i18n="preview.news2.body">«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.</p>
</div>
</div>
</div>
<div class="preview-scene scene-settings" data-key="settings">
<div class="pv-panel">
<h3 class="pv-view-title" data-i18n="preview.nav.settings">Настройки</h3>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.ram">Выделено ОЗУ</div>
<div class="pv-setting-control">
<div class="pv-range" aria-hidden="true"><span class="pv-range-fill"></span><span class="pv-range-knob"></span></div>
<span class="pv-range-value">4 ГБ</span>
</div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.resolution">Разрешение</div>
<div class="pv-setting-control"><span class="pv-chip">1280</span><span class="pv-times">×</span><span class="pv-chip">720</span></div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.language">Язык</div>
<div class="pv-setting-control"><span class="pv-chip pv-chip--wide">Русский</span></div>
</div>
<div class="pv-setting-row">
<div class="pv-setting-label" data-i18n="preview.settings.server">Сервер</div>
<div class="pv-setting-control"><span class="pv-badge pv-badge-online"><i></i><span data-i18n="preview.online">Онлайн</span></span></div>
</div>
</div>
</div>
<div class="preview-scene scene-install" data-key="install">
<div class="scene-install-inner">
<div class="pv-brand">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none" aria-hidden="true"><rect width="56" height="56" rx="14" fill="#e94560"/><path d="M18 28 L28 18 L38 28 L28 38 Z" fill="#fff" opacity="0.9"/></svg>
</div>
<h3 class="pv-title" data-i18n="preview.install.title">Установка сборки…</h3>
<div class="pv-progress" aria-hidden="true"><span class="pv-progress-fill"></span></div>
<p class="pv-stage" data-i18n="preview.install.stage">Загрузка модов 130/130 · 1.8 ГБ</p>
</div>
</div>
</div>
</div>
<div class="preview-dots" id="previewDots" aria-hidden="true"></div>
<p class="preview-caption" id="previewCaption" data-i18n="preview.login">Вход</p>
</div>
</section>
<section class="section theme" id="theme">
<div class="section-head section-head--center">
<span class="section-index">05 / 07</span>
<h2 class="section-title" data-i18n-html="theme.title">Что внутри <span class="accent">сезона.</span></h2>
<p class="section-sub" data-i18n="theme.sub">Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.</p>
</div>
<div class="theme-grid">
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.zarya.tag">ОБТ · 1.20.1 Forge</span>
<h3 data-i18n="theme.zarya.title">Война в Заре</h3>
<p data-i18n="theme.zarya.sub">Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.obt.tag">ОБТ · тестовая стадия</span>
<h3 data-i18n="theme.obt.title">Открытый бета-сервер</h3>
<p data-i18n="theme.obt.sub">Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.air.tag">Сезон · 1.21.1 NeoForge</span>
<h3 data-i18n="theme.air.title">Корабли в небе</h3>
<p data-i18n="theme.air.sub">Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.year.tag">Сезон · Prologue</span>
<h3 data-i18n="theme.year.title">Марафон на год</h3>
<p data-i18n="theme.year.sub">Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.lore.tag">Сервер</span>
<h3 data-i18n="theme.lore.title">Свой лор и плагины</h3>
<p data-i18n="theme.lore.sub">Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.</p>
</article>
<article class="theme-card">
<span class="theme-tag" data-i18n="theme.community.tag">Комьюнити</span>
<h3 data-i18n="theme.community.title">Голос и друзья</h3>
<p data-i18n="theme.community.sub">Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.</p>
</article>
</div>
</section>
<section class="section download" id="download">
<div class="download-inner">
<div class="section-head section-head--center">
<span class="section-index">06 / 07</span>
<h2 class="section-title" data-i18n-html="download.title">Скачать <span class="accent">Zern.</span></h2>
<p class="section-sub" data-i18n="download.sub">Скачай лаунчер для Windows и заходи на сервер ZernMC.</p>
</div>
<div class="download-card">
<div class="download-card-top">
<img src="/img/zernmc.svg" alt="" width="64" height="64">
<div>
<h3 class="download-name">Zern Launcher <span id="download-version"></span></h3>
<p class="download-desc">Windows · <span id="download-size"></span></p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/online" download>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span>Online Setup (5.8М)</span>
</a>
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/offline-setup">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Offline ZIP (98М)</span>
</a>
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:10px;text-align:center">Online — выбери папку (по умолчанию C:\ZernMC) + ярлык ZernMC Launcher на рабочем столе · Offline — выбери куда распаковать · Данные в %USERPROFILE%\.zernmc</p>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span data-i18n="download.latest">Скачать актуальную версию</span>
</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)">main</strong></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <span id="gh-version">v1.1.1.2</span></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11v2"/><path d="M17 11v2"/></svg> <span id="gh-commit">19a1633</span></span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" class="btn btn-ghost" style="flex:1;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> Открыть в Gitea</a>
</div>
<!-- Languages bar like GitHub -->
<div style="margin-top:4px">
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border);gap:1px" id="lang-bar" title="Java 49.4% · Python 25.4% · JavaScript 12.7% · CSS 7.3% · HTML 3.5% · Go 1.7%">
<div style="flex:49.4;background:#b07219" title="Java 49.4%"></div>
<div style="flex:25.4;background:#3572A5" title="Python 25.4%"></div>
<div style="flex:12.7;background:#f1e05a" title="JavaScript 12.7%"></div>
<div style="flex:7.3;background:#563d7c" title="CSS 7.3%"></div>
<div style="flex:3.5;background:#e34c26" title="HTML 3.5%"></div>
<div style="flex:1.7;background:#00ADD8" title="Go 1.7%"></div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:12px;color:var(--text-secondary)" id="lang-legend">
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#b07219;display:inline-block"></i> Java 49.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#3572A5;display:inline-block"></i> Python 25.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#f1e05a;display:inline-block"></i> JavaScript 12.7%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#563d7c;display:inline-block"></i> CSS 7.3%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#e34c26;display:inline-block"></i> HTML 3.5%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#00ADD8;display:inline-block"></i> Go 1.7%</span>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:6px">32 120 строк · Java 15 858 · Python 8 172 · JS 4 083 · CSS 2 345 · HTML 1 110 · Go 552</p>
</div>
<code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code>
</div>
<!-- Manual Zern Prologue pack -->
<div class="manual-pack-card" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<div>
<h4 style="font-size:15px;font-weight:700">Zern Prologue — ручная установка</h4>
<p style="font-size:12px;color:var(--text-secondary);margin-top:2px">1.21.1 NeoForge <strong style="color:var(--text)">21.1.228</strong> · Create: Aeronautics · без лаунчера</p>
</div>
<span style="margin-left:auto;font-size:11px;color:var(--accent);background:rgba(233,69,96,0.12);padding:4px 8px;border-radius:999px;white-space:nowrap">v4 · 1.8 ГБ</span>
</div>
<p style="font-size:13px;color:var(--text-secondary);line-height:1.5">Скачай архив модпака и распакуй вручную. Подходит если лаунчер не качает или хочешь поставить на Prism / MultiMC / ATLauncher.</p>
<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.2);border-radius:8px;padding:8px 10px;font-size:12px;color:#fbbf24;display:flex;gap:8px;align-items:center"><span style="font-size:14px">⚠️</span> <span><strong>Важно:</strong> используй только <strong>NeoForge 21.1.228</strong> для 1.21.1 — на новейшей (21.5+) сборка крашится, мир не загрузится.</span></div>
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-family:var(--mono);font-size:12px;color:var(--text-secondary)">
%USERPROFILE%\.zernmc\instances\ZernPrologue
<span style="color:var(--text-muted)"> · ~/.zernmc/instances/ZernPrologue на Linux</span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="/pack/ZernPrologue/zip" class="btn btn-primary" style="flex:1;justify-content:center;min-width:180px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать Prologue.zip
</a>
<button class="btn btn-ghost" onclick="navigator.clipboard.writeText('%USERPROFILE%\\.zernmc\\instances');var t=document.createElement('div');t.textContent='Скопировано';t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);border:1px solid var(--border);padding:8px 16px;border-radius:8px;font-size:13px;z-index:9999';document.body.appendChild(t);setTimeout(()=>t.remove(),1500)" style="flex:0 0 auto">Копировать путь</button>
</div>
<details style="font-size:12px;color:var(--text-secondary)"><summary style="cursor:pointer;color:var(--text)">Инструкция</summary>
<ol style="margin:8px 0 0 18px;display:flex;flex-direction:column;gap:4px">
<li>Нажми «Скачать Prologue.zip» (требует проходку — войди в лаунчере хотя бы раз, иначе 403).</li>
<li>Распаковать в папку выше (создай если нет). Если там уже есть Prologue — замени.</li>
<li>Для Prism/MultiMC: Импорт → «Import from zip» или «Add instance → Import from folder» укажи распакованную папку, Java 21.</li>
<li>Запусти через ZernMC лаунчер или свой — вход на <strong>mc.zernmc.ru</strong></li>
</ol>
</details>
</div>
</div>
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
</div>
</section>
<section class="section news" id="news">
<div class="section-head">
<span class="section-index">07 / 07</span>
<h2 class="section-title" data-i18n-html="news.title">Что <span class="accent">нового.</span></h2>
<p class="section-sub" data-i18n="news.sub">Последние чейнджлоги и обновления команды Zern.</p>
</div>
<div class="news-grid" id="news-grid">
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
<div class="news-skeleton"></div>
</div>
</section>
</main>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<img src="/img/zernmc.svg" alt="Zern" width="28" height="28">
<span>ZernMC</span>
</div>
<p class="footer-note">© <span id="year">2026</span> ZernMC. <span data-i18n="footer.note">Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.</span></p>
</div>
</footer>
<!-- Auth modal (launcher style) -->
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
<button class="modal-close" id="auth-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:8px;margin-bottom:4px">
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
</div>
<div class="field">
<label for="auth-username" data-i18n="auth.username">Логин</label>
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field">
<label for="auth-password" data-i18n="auth.password">Пароль</label>
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field hidden" id="auth-confirm-field">
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
</div>
</div>
</div>
<!-- Cabinet (personal area) -->
<section class="section hidden" id="cabinet">
<div class="section-head">
<span class="section-index">08 / 08</span>
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
</div>
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
<div>
<div style="font-weight:700" id="cab-username"></div>
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role"></span> · <span id="cab-pass"></span></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px"></p></div></div>
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2"></p></div></div>
</div>
</div>
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
</div>
</section>
<script src="/js/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+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