177 Commits

Author SHA1 Message Date
SashegDev 0ae1db3582 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 60d5094b5a site: add launcher showcase — auto-rotating non-interactive UI tour 2026-08-20 09:41:38 +00:00
SashegDev cb6c952175 site: add RU/EN language toggle with full i18n on landing 2026-08-20 09:31:16 +00:00
SashegDev c628397850 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 b4bd76ed35 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 943211314e crash dialog: auto-close 1.2s after successful send to server 2026-08-19 21:21:49 +00:00
SashegDev 566559d639 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 2d6355ea57 v1.0.16.18 — accordion sidebar pack lists, versionInfo copyright/trademarks 2026-08-19 17:57:36 +00:00
SashegDev 3a16e56000 v1.0.16.17 — re-embed bootstrap jar, switch exe signing to jsign 2026-08-19 14:49:55 +00:00
sasheg ce61b111e9 v1.0.16.16 — exe в корне, иконка для GUI, чистка bin 2026-08-19 12:47:56 +00:00
SashegDev 46d5211150 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 fdd4e24e3c 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 cdc81e4e8d 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 4f5bd5387a 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 dcd5e91ebe 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 553aad41f9 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 f706e8393c 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 26e0280d84 v1.0.15.2 — fix launcher 'no packs available' (NPE uri is null in sendBounded) 2026-08-13 14:09:22 +00:00
SashegDev 421fb04ab5 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 7d30caba74 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 a87a871e43 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 c51d441743 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 7235017493 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 4acbdedf70 v1.0.14.2 — fix JFX hang (ForkJoinPool), retry logic for connection reset, library logging 2026-07-30 16:09:44 +00:00
SashegDev e962adbb58 v1.0.14.1 — bump hotfix, proxy allows all Mojang CDN domains 2026-07-30 15:37:43 +00:00
SashegDev 22b056614b 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 6024df5093 fix: inject real version into UI, add timeout to FabricInstaller process 2026-07-30 15:30:32 +00:00
SashegDev 1307d10e81 v1.0.14 — bridge release for older launchers 2026-07-30 15:17:38 +00:00
SashegDev cffa6519ca v1.0.13.1 — pack download stability, proxy for MC/Forge downloads, build.version priority 2026-07-30 14:31:13 +00:00
SashegDev ce8cf32ddd fix: preserve username case in register/login validators 2026-07-30 13:03:48 +00:00
SashegDev 72c58aced3 feat: include hotfix in manifest and build.version (1.0.13.0) 2026-07-30 11:52:40 +00:00
SashegDev ffc491c333 chore: ignore todo.txt 2026-07-30 11:29:48 +00:00
SashegDev 2846e3edd1 feat: auto-detect new ZIP archives in builds/ + fix exe download path 2026-07-30 11:29:45 +00:00
SashegDev 7ae81b3c91 chore: remove accidental todo.txt 2026-07-30 11:28:33 +00:00
SashegDev 375b98586d feat: robust update with .update suffix + bootstrap self-update 2026-07-30 11:28:30 +00:00
SashegDev 348969e79c chore: remove accidental todo.txt 2026-07-30 11:12:11 +00:00
SashegDev 15532bf341 fix: support 4-part Windows version in version comparison 2026-07-30 11:12:08 +00:00
SashegDev 424cf9bc25 chore: bump hotfix to 1 2026-07-30 11:06:49 +00:00
SashegDev ef7d5edef3 feat: add hotfix property for Windows 4-part version 2026-07-30 11:05:10 +00:00
SashegDev d9c527b8db feat: login/register mode toggle with wave background animation 2026-07-30 10:25:26 +00:00
SashegDev f32ac1ef98 bump: version 1.0.11 → 1.0.12 2026-07-30 10:07:35 +00:00
SashegDev 6763d0144a feat: Discord-style login page redesign 2026-07-30 10:05:53 +00:00
SashegDev 37ec2bc342 chore: add .flattened-pom.xml to .gitignore 2026-07-29 06:16:46 +00:00
SashegDev 6a0c59f032 refactor: use ${revision} for CI-friendly version management 2026-07-29 06:16:32 +00:00
SashegDev 599e9d5e67 bump: version 1.0.10 → 1.0.11, fix Forge/NeoForge launch 2026-07-29 06:15:26 +00:00
SashegDev 929d5a4ad6 debug: print classpath entries to identify _1._20._1.forge module source 2026-07-14 11:02:28 +00:00
SashegDev 9a853a6134 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 6368c32a83 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 d7ba06f24c 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 dcb3412a58 bump: version 1.0.9 → 1.0.10 2026-07-14 07:50:54 +00:00
SashegDev c2150aa7d2 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 03a1a1533e 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 03c908c80c fix: Forge split-package ResolutionException - remove ignoreList manipulation and call ensureVersionJarForForge 2026-07-13 22:07:31 +00:00
SashegDev b9bd642605 refactor: deduplicate classpath, improve forge jar detection, clean up comments 2026-07-13 20:29:53 +00:00
SashegDev f58db7f941 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 0a388c90fd 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 6cbf8a6549 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 d4b6ea081e 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 96e68dab89 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 e6a6392565 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 1efd283234 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 546652f44c 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 5c8e93fd95 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 1d069811e3 fix: Forge/NeoForge split-package ResolutionException — skip version jar on classpath, fix ${classpath} variable resolution 2026-07-13 13:32:34 +00:00
SashegDev 201269efea ForgeFix и новые фичи в интерфейсе 2026-07-13 13:16:51 +00:00
SashegDev 0d61ad1107 DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню 2026-07-01 19:36:52 +00:00
SashegDev e49e630afe fix: автодетект --cli в Bootstrap, отображение локальных сборок в JFX, обработка ошибок установки и друзей 2026-06-30 13:44:26 +00:00
SashegDev 0a2b80ed06 fix: критичные баги — отсутствие auth на pack файлах, сломанный proxy fallback, race condition installInProgress, JSON парсинг, падение loadMetadata 2026-06-30 10:58:24 +00:00
SashegDev b493b3278b minor fixes 2026-06-07 16:36:50 +03:00
SashegDev ec7ef01760 иним чиним чиним чиним а так же новая система друзей и бутстраппера 2026-06-07 12:32:34 +00:00
SashegDev 166dbf8935 чиним cli + ui | Cli 99% готовность, UI примерно 70% 2026-05-24 18:38:16 +00:00
SashegDev 7014c4a455 fix: использовать java.exe вместо javaw.exe для отладки, inheritIO вместо ручного чтения 2026-05-11 12:21:29 +00:00
SashegDev d956bce921 fix: добавить UTF-8 параметры при запуске процессов и исправить обработку стрелок в ArrowMenu 2026-05-10 23:53:45 +00:00
SashegDev a765d064c4 чиним cli + ui..... ДА БЛЯ НУ СКОЛЬКО МОЖНО ТО А 2026-05-10 02:48:13 +00:00
SashegDev 1d5241075b ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ а так же фикс CLI 2026-05-10 01:46:38 +00:00
SashegDev 2c670b1103 попытка оптимизации и ДЖЛЫВОСШФРСЖДЛВОФЖДЛОВСМДЖЛФ ИНТЕРФЕЙС ФИКСЕСССС БЛЯЯЯ 2026-05-10 01:24:47 +00:00
SashegDev 389280f7f1 Fix: JFX launcher inherit console, no game output capture, SSE log optimization 2026-05-10 00:25:49 +00:00
SashegDev ee1e4fa8d2 Real-time log streaming via SSE 2026-05-10 00:05:10 +00:00
SashegDev e17b1d073a 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 a8f3ca5049 Launcher UI redesign + server mirror sync + file download optimization 2026-05-09 23:47:04 +00:00
SashegDev 59480217aa Server: generate meta.json for builds/ on startup for incremental updates 2026-05-08 18:51:15 +00:00
SashegDev 4697b16ab4 Bootstrap: incremental update via meta, server: fix file endpoint paths 2026-05-08 18:45:42 +00:00
SashegDev 099df80cc6 Pass launcher.server system property from Bootstrap to JFXLauncher 2026-05-08 18:38:18 +00:00
SashegDev 74cd5ffdf3 Assets: try meta download first, fallback to JAR extract 2026-05-08 18:37:24 +00:00
SashegDev 01668dd3bf Extract UI assets from JAR on first launch 2026-05-08 18:33:55 +00:00
SashegDev b2dbbac6ca Fix: NPE in AuthManager, game logs display in UI 2026-05-08 17:58:18 +00:00
SashegDev e32a057684 Fix: use vanilla classpath for modloaders (fabric/forge/neoforge), add JS debug logging 2026-05-08 17:50:33 +00:00
SashegDev d4dc35aac3 Debug: classpath for modloaders, game logs in UI 2026-05-08 17:43:12 +00:00
SashegDev 1e7231af57 Debug: add stdout/stderr capture, log game logs to console 2026-05-08 17:36:49 +00:00
SashegDev fd6e292d6e Add game log file writing, debug modloader launch 2026-05-08 17:23:38 +00:00
SashegDev 1e876ffe28 Clean up debug logging 2026-05-08 15:49:33 +00:00
SashegDev 2d515108f0 Debug: log server version response 2026-05-08 15:45:23 +00:00
SashegDev 13c9f67f6e Simplify: read version only from JAR manifest, remove .version file 2026-05-08 15:09:59 +00:00
SashegDev 659265c2f0 Fix version reading - fallback to JAR manifest, fix server version URL 2026-05-08 14:51:53 +00:00
SashegDev d8f189558a 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 6f56012e3a 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 3a0570e7da 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 985abf7440 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 ec551ab2e3 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 e5948b5337 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 5a826c8511 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 ce12854e1b 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 e566703332 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 aaa19df5e4 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 0ee8077787 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 fba944b4b8 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 d39b40053a 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 1199ca9e21 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 50080d890f 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 f6fbb66cdc 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 d7a928cce4 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 3bd3d1d0e8 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 df9fa7b867 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 81fbe028e8 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 513c07666b 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 04f97c3c80 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 f40cf7afed 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 0cef411125 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 523f659269 коммит последних действий 2026-05-06 15:49:14 +00:00
SashegDev 04620d76c4 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 d0b4e187c8 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 f2d3de82f7 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 b4431702dc 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 cd2cf44d9c 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 8939e24e69 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 c0310ed573 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 c96b502ad4 fix(server,security): add ban check to validate_token, replace rate_limit DB with TTLCache 2026-05-04 21:12:35 +00:00
SashegDev bfcffdd88d 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 331fc9a863 refactor(server): clean main.py — remove duplicate imports, dead code, unify logging, fix proxy lifecycle 2026-05-04 21:09:10 +00:00
SashegDev e347c042d5 feat(server): add /auth/pass/activate endpoint for pass code activation 2026-05-04 21:06:56 +00:00
SashegDev bb564e6e9b feat(server): connect admin_router to FastAPI app 2026-05-04 21:06:02 +00:00
SashegDev 6f53002266 fix(server): add role aliases in roles.py to fix broken admin_router imports 2026-05-04 21:04:44 +00:00
SashegDev 9688509df5 fix(pom.xml): correct launch4j JAR path for exe build 2026-05-04 20:52:28 +00:00
SashegDev efc4b086d1 fix(TUI): proper arrow key handling — parse ESC sequences instead of treating as Esc 2026-05-04 20:39:29 +00:00
SashegDev 2cdc438411 just workin on the todo 2026-05-04 20:26:27 +00:00
Sashegdev b60e414d37 last commit to uuuuh idl 2026-05-04 15:19:46 +00:00
Sashegdev 10ec8625b9 The fuck was hapanned тут 2026-04-22 12:54:57 +00:00
Sashegdev f24cc078c5 Merge branch 'main' into alpha 2026-04-22 15:26:39 +03:00
Sashegdev adde40d921 Коммит, для того что бы если что роллбекать 2026-04-22 12:23:51 +00:00
Sashegdev 6bf6c1634a Фиксы проходок (нормально, в отличии от main ветки)
ОНО РАБОТАЕТ СУКАААА
2026-04-20 19:30:17 +00:00
Sashegdev 98462ba4a3 Update issue templates 2026-04-20 19:59:07 +03:00
Sashegdev 11ec84fe24 Create LICENSE 2026-04-20 19:57:52 +03:00
Sashegdev 8b56652a73 test penis 2026-04-09 18:13:21 +00:00
Sashegdev d7a6eb760e fixes 2026-04-09 18:03:00 +00:00
Sashegdev c6dd215e9b рефакторинг + новая система модерации 2026-04-09 17:28:48 +00:00
Sashegdev a3f9871d6e СУКА ЛАСТ ФИКСЫ ДЛЯ ПРОХОДОК (логин работает) 2026-04-08 20:22:47 +00:00
Sashegdev 2b6cb6b3ad ДА БЛЯ Я ЗАБЕАЛСЯ ФИКСИТЬ ПОМОГИТЕ Я КОНЧЕННЫЫЫЙ 2026-04-08 20:16:51 +00:00
Sashegdev cca6ef3eca SuperMinor Fixes (надеюсь последние для аккаунтов) 2026-04-08 20:04:52 +00:00
Sashegdev 8733e359e6 Minor fixes(важные блять) 2026-04-08 20:02:09 +00:00
Sashegdev 89c0057759 Server Fixes 2026-04-08 19:56:38 +00:00
Sashegdev bf26baaf93 1.0.7 типоооо и фиксы 2026-04-08 19:45:15 +00:00
Sashegdev 13a43a01ef utf-8 рефактор чутка 2026-04-07 19:00:42 +00:00
Sashegdev 296f564b39 небольшой рефактор 2026-04-07 18:54:16 +00:00
Sashegdev de703a4ddd ВАЖНИ ФИКСЕС 2026-04-07 18:40:51 +00:00
Sashegdev a501329956 Изменил версию и немного фиксов 2026-04-07 18:28:26 +00:00
Sashegdev 5516aeb12f Readme модификейшин 2026-04-07 18:08:11 +00:00
Sashegdev 7b48ae2ab6 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-07 18:01:49 +00:00
Sashegdev 9bee361ea4 Попытка добавления проходок, аккаунтов, а так же доработка прокси 2026-04-07 17:50:29 +00:00
Sashegdev 8cbad9af96 Update README.md 2026-04-07 18:11:54 +03:00
Sashegdev 33cb7830e3 Орфографическая правка 2026-04-07 12:24:06 +03:00
Sashegdev 920330bbab REAMDE.md update, мяу 2026-04-07 11:17:36 +03:00
Sashegdev c03d7a788f ДОБАВЛЕНИЕ ПРОКСИ РЕЖИМА ЙОООУ 1.0.5 2026-04-06 19:57:32 +00:00
Sashegdev b47793b618 Багфиксы ClassPath 2026-04-06 18:17:07 +00:00
Sashegdev 94968e8e77 Smol Fixes Yoooo 2026-04-06 17:00:39 +00:00
Sashegdev 0b4af1353d Both | БЛЯЯЯ ЗАГРУЗКА ПАКОВ С СЕРВЕРА СЮДААА 2026-04-06 00:32:36 +00:00
Sashegdev 4edbe7e910 Server BugFixes + убрал генерацию sevrer команды т.к это уже в клиенте лол 2026-04-05 22:25:43 +00:00
Sashegdev 3d8313f7d2 Немного рефактора 2026-04-05 18:45:31 +00:00
Sashegdev e21fd922ab Попытка заставить работать Forge 2026-04-05 16:18:39 +00:00
Sashegdev b29222af68 ФАБРИК ПОДДЕРЖКАААААААА 2026-04-05 15:43:16 +00:00
Sashegdev ac3ce1800f Починил загрузку ассетов, добавлена оптимизация
запуск Vanilla версий работает
2026-04-05 14:56:01 +00:00
Sashegdev 2babe53e99 uuuh почему бы и нет 2026-04-05 10:47:28 +00:00
Sashegdev 369f8c2f9d Изменение получения self-version 2026-04-05 10:46:32 +00:00
Sashegdev c0a85658f4 КЛИЕНТ ЛАУНЧЕРА ЙОООО 2026-04-05 00:18:57 +00:00
Sashegdev 7568e34d91 перенос readme т.к я криворукий 2026-04-04 14:59:00 +00:00
Sashegdev 7670edbff7 server update 2026-04-04 14:57:15 +00:00
Sashegdev cf4a5c74e5 test 2026-04-04 14:55:18 +00:00
Sashegdev 1a5336a996 first commit 2026-04-04 14:49:24 +00:00
15 changed files with 79 additions and 1786 deletions
+16 -24
View File
@@ -21,21 +21,15 @@
## Состав дистрибутива
**Онлайн-установщик `ZernMC-Online-Setup.exe` (Go, ~5.7М, без Java):**
- Спрашивает папку установки (default `C:\ZernMC`) + чекбокс `Ярлык ZernMC Launcher.lnk на рабочем столе`
- Качает `JRE 47М``lib/jre21`, затем `meta/file` для `lib/javafx`, `bin/`, `assets/`, `zernmc.exe`
- Пишет `build.version`, создаёт `uninstall.exe` (`C:\ZernMC\uninstall.exe`), ярлык
- Локальные файлы `~/.zernmc` не трогает
В архиве `ZernMC-win-<версия>.zip`:
**Оффлайн `ZernMC-win-<версия>.zip` (или `ZernMC-Offline-Setup.exe` — тот же выбор папки + ярлык):**
- `zernmc.exe`основная версия с GUI (JavaFX)
- `zernmc-cli.exe` — консольная версия (TUI)
- `zernmc-cli-jfx.exe` — консоль + JavaFX UI
- `lib/jre21` — встроенный JRE 21
- `lib/javafx` — модули JavaFX 23.0.1
- `zernmc.exe` — GUI (JavaFX)
- `zernmc-cli.exe` — TUI
- `zernmc-cli-jfx.exe` — консоль + JavaFX
- `lib/jre21` — JRE 21 (только оффлайн)
- `lib/javafx` — JavaFX 23.0.1
Bootstrap встроен в `.exe`.
Bootstrap-модуль встроен прямо в `.exe`, отдельный jar рядом не нужен.
## Чего пока нет в лаунчере
@@ -92,19 +86,17 @@ Bootstrap встроен в `.exe`.
## Как скачать и запустить
**Скачать лаунчер (выбери один):**
- **Онлайн-установщик (рекомендуется)** — `ZernMC-Online-Setup.exe` (~5.7М, Go, без Java): скачай → выбери папку установки (по умолчанию `C:\ZernMC`) → [x] Создать ярлык ZernMC Launcher на рабочем столе → Установить. Первый запуск докачает JRE 47М + файлы лаунчера 50М. Данные сборок `~/.zernmc` остаются в профиле пользователя. Деинсталляция — `C:\ZernMC\uninstall.exe` или удаление папки.
- **Оффлайн ZIP** — `ZernMC-win-<версия>.zip` (98М, с JRE): скачай → выбери папку куда распаковать (диалог в `ZernMC-Offline-Setup.exe` или вручную) → [x] Ярлык → готово. Для air-gapped.
Скачать последнюю версию можно по ссылкам:
**Зеркала (оба варианта):**
- https://api.zern.cc/launcher/download/jre — JRE для онлайн
- https://api.zernmc.ru/launcher/download/latest / https://api.zernmc.online/launcher/download/latest — ZIP
**Скачать лаунчер:**
- https://api.zernmc.ru/launcher/download/latest
- https://api.zernmc.online/launcher/download/latest
**Инструкция (оба):**
1. Скачай online exe **или** offline zip
2. Запусти установщик → выбери папку (по умолчанию `C:\ZernMC`, можно `D:\Games\ZernMC`) → ярлык
3. Запусти `zernmc.exe` (или `zernmc-cli.exe`) из папки установки
4. Войди в аккаунт и нажми **«Начать игру»**
**Инструкция:**
1. Скачайте zip-архив
2. Распакуйте в удобную папку (например `C:\ZernMC`)
3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима)
4. Войдите в аккаунт и нажмите **«Начать игру»**
### Установка первой сборки
-8
View File
@@ -1,8 +0,0 @@
module zernmc-installer
go 1.21
require (
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac // indirect
)
-4
View File
@@ -1,4 +0,0 @@
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I=
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac h1:/QqP+ajFMma4hNWQyBDVaQQhz9Z1kDyXScNWMO3owx0=
github.com/sqweek/dialog v0.0.0-20260123140253-64c163d53aac/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
-552
View File
@@ -1,552 +0,0 @@
package main
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/sqweek/dialog"
)
var (
installDirFlag = flag.String("dir", "", "Installation directory (default C:\\ZernMC or chosen via dialog)")
onlineFlag = flag.Bool("online", true, "Online mode (download JRE + files)")
offlineFlag = flag.Bool("offline", false, "Offline mode (unpack from adjacent zip)")
offlineZipFlag = flag.String("offline-zip", "", "Path to offline zip for offline mode")
shortcutFlag = flag.Bool("shortcut", true, "Create desktop shortcut")
)
const (
defaultInstallDir = "C:\\ZernMC"
serverBase = "https://api.zern.cc"
jreZipName = "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"
)
func main() {
flag.Parse()
// Offline flag overrides online
isOnline := *onlineFlag && !*offlineFlag
dir := *installDirFlag
if dir == "" {
dir = askInstallDir()
}
if dir == "" {
fmt.Println("Installation cancelled")
os.Exit(0)
}
fmt.Printf("Install dir: %s (online=%v)\n", dir, isOnline)
if err := os.MkdirAll(dir, 0755); err != nil {
fatal("Cannot create directory: %v", err)
}
createShortcut := *shortcutFlag
if !isFlagSet("shortcut") {
// ask via dialog if not explicitly flagged
createShortcut = askShortcut()
}
if isOnline {
if err := installOnline(dir, createShortcut); err != nil {
fatal("Online install failed: %v", err)
}
} else {
zipPath := *offlineZipFlag
if zipPath == "" {
// try to find adjacent offline zip
zipPath = findAdjacentOfflineZip()
if zipPath == "" {
// ask user
zipPath, _ = dialog.File().Title("Select ZernMC-Offline.zip").Filter("ZIP (*.zip)", "zip").Load()
}
}
if zipPath == "" {
fatal("Offline zip not specified")
}
if err := installOffline(dir, zipPath, createShortcut); err != nil {
fatal("Offline install failed: %v", err)
}
}
fmt.Println("\nInstallation complete!")
fmt.Printf("Launcher: %s\\zernmc.exe\n", dir)
fmt.Printf("Data dir: %s\\.zernmc (local files, instances)\n", os.Getenv("USERPROFILE"))
if createShortcut {
fmt.Println("Shortcut on desktop: ZernMC Launcher.lnk")
}
fmt.Println("Uninstall: run", filepath.Join(dir, "uninstall.exe"), "or delete folder")
}
func isFlagSet(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
func askInstallDir() string {
// Try GUI dialog first (Windows)
if runtime.GOOS == "windows" {
dir, err := dialog.Directory().Title("Куда установить ZernMC").Browse()
if err == nil && dir != "" {
// dialog.Directory returns chosen dir; append ZernMC if not already
if !strings.Contains(strings.ToLower(dir), "zernmc") {
dir = filepath.Join(dir, "ZernMC")
}
return dir
}
// fallback to default if dialog fails/cancelled with empty
fmt.Printf("Dialog cancelled or error (%v), using default %s\n", err, defaultInstallDir)
}
// CLI fallback
fmt.Printf("Куда установить ZernMC [%s]: ", defaultInstallDir)
var input string
fmt.Scanln(&input)
input = strings.TrimSpace(input)
if input == "" {
input = defaultInstallDir
}
return input
}
func askShortcut() bool {
if runtime.GOOS != "windows" {
return false
}
// simple yes/no dialog
// sqweek/dialog Message does not return bool, so use question
// fallback to true by default if dialog unavailable
yes := dialog.Message("Создать ярлык на рабочем столе (ZernMC Launcher)?").Title("Ярлык").YesNo()
return yes
}
func fatal(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(os.Stderr, msg)
// also show dialog on Windows
if runtime.GOOS == "windows" {
dialog.Message(msg).Title("ZernMC Installer — Ошибка").Error()
}
os.Exit(1)
}
func installOnline(dir string, createShortcut bool) error {
fmt.Println("Online installation — downloading JRE + launcher files...")
// Step 1: download JRE zip if missing
jreDir := filepath.Join(dir, "lib", "jre21")
javaExe := filepath.Join(jreDir, "bin", "java.exe")
if _, err := os.Stat(javaExe); err != nil {
fmt.Println("Downloading JRE 21 (47 MB)...")
jreURL := serverBase + "/launcher/download/jre"
tmpZip := filepath.Join(os.TempDir(), jreZipName)
if err := downloadFile(jreURL, tmpZip); err != nil {
// fallback to direct zip from builds if server endpoint not ready
// try alternative mirrors
mirrors := []string{
"https://api.zernmc.ru/launcher/download/jre",
"https://api.zernmc.online/launcher/download/jre",
}
var lastErr error
for _, m := range mirrors {
fmt.Printf("Retry mirror %s\n", m)
if err := downloadFile(m, tmpZip); err == nil {
lastErr = nil
break
} else {
lastErr = err
}
}
if lastErr != nil {
return fmt.Errorf("JRE download failed: %w", lastErr)
}
}
fmt.Printf("Unpacking JRE to %s ...\n", jreDir)
if err := unzip(tmpZip, dir); err != nil {
// try alt: unzip should contain jre21/ prefix or lib/jre21?
// our zip contains jre21/ at root, need to move to lib/jre21
return fmt.Errorf("unzip JRE: %w", err)
}
// handle both zip layouts: some zips contain jre21/, some lib/jre21/
// if tmp unzip created dir/jre21, move to lib/jre21
candidate := filepath.Join(dir, "jre21")
if _, err := os.Stat(candidate); err == nil {
os.MkdirAll(filepath.Join(dir, "lib"), 0755)
os.RemoveAll(jreDir)
if err := os.Rename(candidate, jreDir); err != nil {
// fallback copy
if err := copyDir(candidate, jreDir); err != nil {
return err
}
os.RemoveAll(candidate)
}
}
os.Remove(tmpZip)
if _, err := os.Stat(javaExe); err != nil {
return fmt.Errorf("JRE unpack failed, %s not found", javaExe)
}
fmt.Println("JRE ready")
} else {
fmt.Println("JRE already present, skipping")
}
// Step 2: fetch meta and download launcher files
fmt.Println("Fetching launcher meta...")
meta, err := fetchMeta()
if err != nil {
return fmt.Errorf("fetch meta: %w", err)
}
fmt.Printf("Latest: %s (%d files)\n", meta.Version, len(meta.Files))
for i, f := range meta.Files {
rel := f.Path
// skip JRE entries (should not be in meta, but just in case)
if strings.HasPrefix(rel, "jre21/") || strings.HasPrefix(rel, "lib/jre21") {
continue
}
dest := filepath.Join(dir, filepath.FromSlash(rel))
need := true
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
hash, _ := fileSHA256(dest)
if hash == strings.TrimPrefix(f.Hash, "sha256:") {
need = false
}
}
if !need {
continue
}
fmt.Printf("[%d/%d] %s (%.1f KB)\n", i+1, len(meta.Files), rel, float64(f.Size)/1024)
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
url := fmt.Sprintf("%s/launcher/file/%s/%s", serverBase, meta.Version, rel)
// try mirrors on failure
if err := downloadFile(url, dest); err != nil {
// try versioned fallback (some servers use builds/ vs versions/)
alt := fmt.Sprintf("%s/launcher/download/jar", serverBase) // not generic
_ = alt
return fmt.Errorf("download %s: %w", rel, err)
}
// verify hash
hash, _ := fileSHA256(dest)
if hash != strings.TrimPrefix(f.Hash, "sha256:") {
fmt.Printf(" WARNING hash mismatch for %s\n", rel)
}
}
// write build.version
if err := os.WriteFile(filepath.Join(dir, "build.version"), []byte(meta.Version), 0644); err != nil {
fmt.Printf("warning write build.version: %v\n", err)
}
// also ensure bin/zernmclauncher.jar etc are present
fmt.Println("Launcher files ready")
if createShortcut {
if err := createDesktopShortcut(dir); err != nil {
fmt.Printf("shortcut failed: %v\n", err)
}
}
if err := createUninstaller(dir); err != nil {
fmt.Printf("uninstall.exe failed: %v\n", err)
}
return nil
}
func installOffline(dir, zipPath string, createShortcut bool) error {
fmt.Printf("Offline installation from %s to %s\n", zipPath, dir)
if _, err := os.Stat(zipPath); err != nil {
return fmt.Errorf("offline zip not found: %s", zipPath)
}
fmt.Println("Unpacking (98 MB, ~200 MB unpacked)...")
if err := unzip(zipPath, dir); err != nil {
return fmt.Errorf("unzip: %w", err)
}
// zip contains zernmc.exe, lib/, bin/, assets/ at root — already correct relative to dir
// if zip contained top-level folder, handle it
// we unzip directly to dir, so should be fine
if createShortcut {
if err := createDesktopShortcut(dir); err != nil {
fmt.Printf("shortcut failed: %v\n", err)
}
}
if err := createUninstaller(dir); err != nil {
fmt.Printf("uninstall.exe failed: %v\n", err)
}
fmt.Println("Offline unpack complete")
fmt.Printf("Data will be in %s\\.zernmc\n", os.Getenv("USERPROFILE"))
return nil
}
func downloadFile(url, dest string) error {
// support resume? simple GET
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// ensure parent
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return err
}
tmp := dest + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
defer out.Close()
total := resp.ContentLength
var written int64
buf := make([]byte, 64*1024)
start := time.Now()
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
return werr
}
written += int64(n)
if total > 0 {
pct := float64(written) / float64(total) * 100
elapsed := time.Since(start).Seconds()
speed := float64(written) / 1024 / 1024 / (elapsed + 0.001)
fmt.Printf("\r %.1f%% %.1f/%.1f MB %.1f MB/s ", pct, float64(written)/1024/1024, float64(total)/1024/1024, speed)
} else {
fmt.Printf("\r %.1f MB ", float64(written)/1024/1024)
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
fmt.Println()
out.Close()
// verify tmp exists
if err := os.Rename(tmp, dest); err != nil {
// fallback copy
if err2 := copyFile(tmp, dest); err2 != nil {
return err2
}
os.Remove(tmp)
}
return nil
}
func unzip(src, dest string) error {
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(dest, f.Name)
// ZipSlip protection
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", fpath)
}
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, f.Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return err
}
out, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
_, err = io.Copy(out, rc)
out.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(path, src)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
return copyFile(path, target)
})
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type Meta struct {
Version string `json:"version"`
Files []struct {
Path string `json:"path"`
Size int64 `json:"size"`
Hash string `json:"hash"`
} `json:"files"`
}
func fetchMeta() (*Meta, error) {
// try /launcher/meta/{version} flow: first get version, then meta
resp, err := http.Get(serverBase + "/launcher/version")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var verResp struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
return nil, err
}
ver := verResp.Version
if ver == "" {
ver = "1.1.1"
}
metaResp, err := http.Get(serverBase + "/launcher/meta/" + ver)
if err != nil {
return nil, err
}
defer metaResp.Body.Close()
if metaResp.StatusCode != 200 {
return nil, fmt.Errorf("meta %d", metaResp.StatusCode)
}
var meta Meta
if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil {
return nil, err
}
if meta.Version == "" {
meta.Version = ver
}
return &meta, nil
}
func findAdjacentOfflineZip() string {
exe, _ := os.Executable()
dir := filepath.Dir(exe)
// look for ZernMC-Offline*.zip or ZernMC-win*.zip nearby
matches, _ := filepath.Glob(filepath.Join(dir, "ZernMC-Offline*.zip"))
if len(matches) > 0 {
return matches[0]
}
matches, _ = filepath.Glob(filepath.Join(dir, "ZernMC-win*.zip"))
if len(matches) > 0 {
return matches[0]
}
// also check current dir
matches, _ = filepath.Glob("ZernMC-Offline*.zip")
if len(matches) > 0 {
return matches[0]
}
return ""
}
func createDesktopShortcut(installDir string) error {
if runtime.GOOS != "windows" {
return nil
}
desktop := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
if _, err := os.Stat(desktop); err != nil {
desktop = filepath.Join(os.Getenv("USERPROFILE"), "OneDrive", "Desktop")
}
if _, err := os.Stat(desktop); err != nil {
return fmt.Errorf("desktop not found")
}
lnk := filepath.Join(desktop, "ZernMC Launcher.lnk")
target := filepath.Join(installDir, "zernmc.exe")
ps := fmt.Sprintf(`$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WorkingDirectory = '%s'; $Shortcut.IconLocation = '%s'; $Shortcut.Save()`, lnk, target, installDir, target)
tmp := filepath.Join(os.TempDir(), "zernmc_shortcut.ps1")
if err := os.WriteFile(tmp, []byte(ps), 0644); err != nil {
return err
}
defer os.Remove(tmp)
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", tmp)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func createUninstaller(dir string) error {
// create uninstall.bat and copy self as uninstall.exe
exe, _ := os.Executable()
uninstExe := filepath.Join(dir, "uninstall.exe")
if exe != "" && exe != uninstExe {
_ = copyFile(exe, uninstExe)
}
bat := filepath.Join(dir, "uninstall.bat")
content := fmt.Sprintf(`@echo off
echo Uninstalling ZernMC from %s
timeout /t 2 >nul
del "%%USERPROFILE%%\Desktop\ZernMC Launcher.lnk" 2>nul
del "%%USERPROFILE%%\OneDrive\Desktop\ZernMC Launcher.lnk" 2>nul
echo Data in %%USERPROFILE%%\.zernmc will be kept.
echo To remove completely, delete %%USERPROFILE%%\.zernmc manually.
`, dir)
_ = os.WriteFile(bat, []byte(content), 0644)
return nil
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
-38
View File
@@ -402,44 +402,6 @@ How to use:
basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,zernmc-cli-jfx.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/>
<!-- Go online/offline инсталляторы (нативные, без JRE) -->
<exec executable="bash" failonerror="false">
<arg value="-c"/>
<arg value="if command -v go >/dev/null 2>&amp;1; then echo 'Building Go installers...'; mkdir -p ../../server/builds; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../installer 2>&amp;1 || echo 'Go build skipped (no go)'; cp ../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe ../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe 2>/dev/null || true; fi"/>
</exec>
<!-- Подпись Go инсталляторов -->
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Online-Setup-${revision}.${hotfix}.exe"/>
</exec>
<exec executable="java" failonerror="false">
<arg value="-jar"/>
<arg value="${user.home}/tools/jsign/jsign.jar"/>
<arg value="--keystore"/>
<arg value="/root/cert/codesign/zerncc.pfx"/>
<arg value="--storepass"/>
<arg value="${pfxpass}"/>
<arg value="--alias"/>
<arg value="1"/>
<arg value="--tsaurl"/>
<arg value="http://timestamp.digicert.com"/>
<arg value="--alg"/>
<arg value="SHA-256"/>
<arg value="../../server/builds/ZernMC-Offline-Setup-${revision}.${hotfix}.exe"/>
</exec>
</target>
</configuration>
</execution>
@@ -447,7 +447,7 @@ public class JFXLauncher extends Application {
startServer();
WebView webView = new WebView();
webView.setContextMenuEnabled(true);
webView.setContextMenuEnabled(false);
WebEngine engine = webView.getEngine();
engine.setJavaScriptEnabled(true);
@@ -508,9 +508,6 @@ public class JFXLauncher extends Application {
" req.open('POST', '/api/open-url', false);" +
" req.send(JSON.stringify({url: target.href}));" +
" }" +
"}, true);" +
"document.addEventListener('contextmenu', function(e) {" +
" e.preventDefault();" +
"}, true);"
);
}
@@ -610,12 +607,6 @@ public class JFXLauncher extends Application {
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
server.createContext("/api/mods/toggle", this::handleModsToggle);
server.createContext("/api/mods/delete", this::handleModsDelete);
server.createContext("/api/pack/open-folder", this::handlePackOpenFolder);
server.createContext("/api/pack/settings", this::handlePackSettings);
server.createContext("/api/pack/delete", this::handlePackDelete);
server.createContext("/api/filemanager", this::handleFileManager);
server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic);
@@ -1259,55 +1250,14 @@ public class JFXLauncher extends Application {
Path dir = instance.getPath();
Map<String, Object> info = new HashMap<>();
List<Map<String, Object>> modsList = new ArrayList<>();
int modsCount = 0;
Path modsDir = dir.resolve("mods");
if (Files.exists(modsDir)) {
try (var files = Files.list(modsDir)) {
files.forEach(p -> {
String fn = p.getFileName().toString();
boolean isJar = fn.endsWith(".jar");
boolean isDisabled = fn.endsWith(".jar.disabled");
if (!isJar && !isDisabled) return;
String display = isDisabled ? fn.substring(0, fn.length()-9) : fn;
Map<String, Object> m = new HashMap<>();
m.put("fileName", fn);
// try parse mod metadata
String modId = null, modName = null, modVer = null;
try (var zf = new java.util.jar.JarFile(p.toFile())) {
var e1 = zf.getEntry("fabric.mod.json");
if (e1 != null) {
String json = new String(zf.getInputStream(e1).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var jo = new org.json.JSONObject(json);
modId = jo.optString("id", null);
modName = jo.optString("name", null);
modVer = jo.optString("version", null);
} else {
var e2 = zf.getEntry("META-INF/mods.toml");
if (e2 == null) e2 = zf.getEntry("META-INF/neoforge.mods.toml");
if (e2 == null) e2 = zf.getEntry("mods.toml");
if (e2 != null) {
String toml = new String(zf.getInputStream(e2).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
var mid = java.util.regex.Pattern.compile("modId\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (mid.find()) modId = mid.group(1);
var dname = java.util.regex.Pattern.compile("displayName\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (dname.find()) modName = dname.group(1);
var ver = java.util.regex.Pattern.compile("version\\s*=\\s*\"([^\"]+)\"").matcher(toml);
if (ver.find()) modVer = ver.group(1);
}
}
} catch (Exception ignored) {}
m.put("displayName", modName != null ? modName : (modId != null ? modId : display));
m.put("modId", modId != null ? modId : display.replace(".jar",""));
m.put("version", modVer != null ? modVer : "?");
m.put("enabled", !isDisabled);
try { m.put("size", Files.size(p)); } catch (Exception e) { m.put("size", 0L); }
modsList.add(m);
});
} catch (Exception ignored) {}
modsList.sort((a,b) -> String.valueOf(a.get("displayName")).compareToIgnoreCase(String.valueOf(b.get("displayName"))));
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count();
}
}
info.put("modsCount", modsList.size());
info.put("mods", modsList);
info.put("modsCount", modsCount);
List<String> screenshots = new ArrayList<>();
Path screenshotsDir = dir.resolve("screenshots");
@@ -1377,12 +1327,6 @@ public class JFXLauncher extends Application {
if (body.containsKey("systemBasedJvm")) {
Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm")));
}
if (body.containsKey("onboardedVersion")) {
Config.setOnboardedVersion(body.get("onboardedVersion"));
}
if (body.containsKey("completedOnboardBlocks")) {
Config.setCompletedOnboardBlocks(body.get("completedOnboardBlocks"));
}
Map<String, Object> res = new HashMap<>();
res.put("success", true);
res.put("maxMemory", Config.getMaxMemory());
@@ -1399,8 +1343,6 @@ public class JFXLauncher extends Application {
data.put("javaPath", Config.getJavaPath());
data.put("locale", Config.getLocale());
data.put("systemBasedJvm", Config.isSystemBasedJvm());
data.put("onboardedVersion", Config.getOnboardedVersion());
data.put("completedOnboardBlocks", Config.getCompletedOnboardBlocks());
data.put("cpuCores", Config.getSystemCpuCores());
data.put("totalRamMB", Config.getSystemTotalRamMB());
data.put("systemJvmFlags", Config.getSystemJvmFlags());
@@ -1843,148 +1785,6 @@ public class JFXLauncher extends Application {
}
}
private void handleModsToggle(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
String enStr = body.get("enable");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
boolean enable = enStr == null || enStr.equals("true");
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot toggle mods in server pack (only whitelist)")); return; }
Path modsDir = inst.getPath().resolve("mods");
Path src = modsDir.resolve(file);
if (!Files.exists(src)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
Path dst;
if (enable) {
if (file.endsWith(".disabled")) dst = modsDir.resolve(file.substring(0, file.length()-9));
else { sendJson(exchange, Map.of("success", true)); return; }
} else {
if (file.endsWith(".jar")) dst = modsDir.resolve(file + ".disabled");
else { sendJson(exchange, Map.of("success", true)); return; }
}
Files.move(src, dst);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handleModsDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String instName = body.get("instance");
String file = body.get("file");
if (instName == null || file == null) { sendJson(exchange, Map.of("success", false, "error", "Missing params")); return; }
Instance inst = InstanceManager.getInstance(instName);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot delete mods in server pack")); return; }
Path target = inst.getPath().resolve("mods").resolve(file);
if (!Files.exists(target)) { sendJson(exchange, Map.of("success", false, "error", "File not found")); return; }
Files.delete(target);
sendJson(exchange, Map.of("success", true));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackOpenFolder(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
Path dir = inst.getPath();
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().open(dir.toFile());
} else {
String os = System.getProperty("os.name").toLowerCase();
String cmd = os.contains("win") ? "explorer" : os.contains("mac") ? "open" : "xdg-open";
new ProcessBuilder(cmd, dir.toString()).start();
}
sendJson(exchange, Map.of("success", true));
} catch (Exception e) {
// fallback xdg
try { new ProcessBuilder("xdg-open", dir.toString()).start(); sendJson(exchange, Map.of("success", true)); }
catch (Exception e2) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackSettings(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String newName = body.get("newName");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
if (name == null) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
Instance inst = InstanceManager.getInstance(name);
if (inst == null) { sendJson(exchange, Map.of("success", false, "error", "Pack not found")); return; }
if (inst.isServerPack()) { sendJson(exchange, Map.of("success", false, "error", "Cannot edit server pack")); return; }
boolean renamed = false;
if (newName != null && !newName.isBlank() && !newName.equals(name)) {
if (newName.matches(".*[\\\\/:*?\"<>|].*") || newName.length() > 32) { sendJson(exchange, Map.of("success", false, "error", "Invalid name")); return; }
if (InstanceManager.getInstance(newName) != null) { sendJson(exchange, Map.of("success", false, "error", "Name already exists")); return; }
Path src = inst.getPath();
Path dst = src.getParent().resolve(newName);
Files.move(src, dst);
// need to update instance path internally - recreate
inst = InstanceManager.getInstance(newName);
renamed = true;
}
Instance target = renamed ? InstanceManager.getInstance(newName != null ? newName : name) : inst;
if (loader != null && !loader.isBlank()) {
target.setLoaderType(loader);
if (loaderVersion != null) target.setLoaderVersion(loaderVersion);
// if loader changed, reinstall loader libs
if (!"vanilla".equalsIgnoreCase(loader) && loaderVersion != null && !loaderVersion.isBlank()) {
// trigger install in background? For now just save meta; user will reinstall manually via manage
}
}
sendJson(exchange, Map.of("success", true, "renamed", renamed));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handlePackDelete(HttpExchange exchange) {
try {
Map<String,String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
if (name == null) {
Map<String,String> q = parseQuery(exchange.getRequestURI().getQuery());
name = q.get("name");
}
if (name == null || name.isBlank()) { sendJson(exchange, Map.of("success", false, "error", "Missing name")); return; }
boolean ok = InstanceManager.deleteInstance(name);
sendJson(exchange, Map.of("success", ok, "error", ok ? "" : "Delete failed"));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
private void handleFileManager(HttpExchange exchange) {
try {
String os = System.getProperty("os.name").toLowerCase();
String fm;
if (os.contains("win")) fm = "Explorer";
else if (os.contains("mac")) fm = "Finder";
else {
String desktop = System.getenv("XDG_CURRENT_DESKTOP");
if (desktop == null) desktop = System.getenv("DESKTOP_SESSION");
if (desktop != null) {
String d = desktop.toLowerCase();
if (d.contains("gnome")) fm = "Nautilus";
else if (d.contains("kde")) fm = "Dolphin";
else if (d.contains("xfce")) fm = "Thunar";
else fm = "File Manager ("+desktop+")";
} else fm = "File Manager";
}
sendJson(exchange, Map.of("success", true, "data", Map.of("name", fm, "os", os)));
} catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); }
}
// ====================== ADMIN ======================
private void handleAdmin(HttpExchange exchange) {
@@ -22,8 +22,6 @@ public class Config {
private static volatile boolean ramManuallySet = false;
private static volatile String locale = "en";
private static volatile boolean systemBasedJvm = false;
private static volatile String onboardedVersion = "";
private static volatile String completedOnboardBlocks = "";
static {
load();
@@ -63,8 +61,6 @@ public class Config {
javaPath = props.getProperty("javaPath", "java");
locale = props.getProperty("locale", "en");
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
onboardedVersion = props.getProperty("onboardedVersion", "");
completedOnboardBlocks = props.getProperty("completedOnboardBlocks", "");
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
@@ -83,8 +79,6 @@ public class Config {
props.setProperty("javaPath", javaPath);
props.setProperty("locale", locale);
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
props.setProperty("onboardedVersion", onboardedVersion);
props.setProperty("completedOnboardBlocks", completedOnboardBlocks);
try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration");
@@ -272,9 +266,4 @@ public class Config {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
}
public static String getOnboardedVersion() { return onboardedVersion; }
public static void setOnboardedVersion(String v) { onboardedVersion = v != null ? v : ""; save(); }
public static String getCompletedOnboardBlocks() { return completedOnboardBlocks; }
public static void setCompletedOnboardBlocks(String s) { completedOnboardBlocks = s != null ? s : ""; save(); }
}
+5 -41
View File
@@ -245,16 +245,6 @@
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
</div>
</div>
<div id="local-mods-section" class="mods-section hidden">
<div class="section-header">
<span data-i18n="mods.title">Mods</span>
<span id="mods-count" class="mods-count"></span>
<input type="text" id="mods-search" class="mods-search" placeholder="Search mods..." data-i18n-placeholder="mods.search" oninput="app.filterLocalMods()">
</div>
<div id="local-mods-list" class="mods-list">
<div class="mods-loading" data-i18n="mods.loading">Loading...</div>
</div>
</div>
</div>
</div>
@@ -529,15 +519,6 @@
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div>
</div>
<div class="setting-card" id="onboard-settings-card">
<div class="setting-info">
<h4 data-i18n="onboard.showAgain">Show tutorial again</h4>
<p data-i18n="onboard.infoDesc">You can replay the tutorial in Settings.</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="replay-onboard-btn" onclick="app.replayOnboarding()"><span data-i18n="onboard.showAgain">Show tutorial again</span></button>
</div>
</div>
</div>
</div>
</main>
@@ -589,7 +570,7 @@
<div class="modal-body">
<div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span></button>
<button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
@@ -609,28 +590,11 @@
</div>
<div id="tab-custom" class="modal-tab-content">
<div class="field">
<label data-i18n="install.custom.name">Pack Name</label>
<input type="text" id="custom-instance-name" placeholder="MyPack" maxlength="32">
<div class="disabled-tab">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<h3 data-i18n="install.custom.unavailable">Not available yet</h3>
<p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
</div>
<div class="field">
<label data-i18n="install.custom.mcVersion">Minecraft Version</label>
<select id="mc-version-select"><option>Loading...</option></select>
</div>
<div class="field">
<label data-i18n="install.custom.loader">Loader</label>
<select id="loader-select">
<option value="vanilla">Vanilla</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
<div class="field hidden" id="loader-ver-field">
<label data-i18n="install.custom.loaderVersion">Loader Version</label>
<select id="loader-ver-select"><option data-i18n="select.selectMCFirst">Select MC version first</option></select>
</div>
<button id="install-custom-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div>
<div id="install-progress" class="install-progress hidden">
+29 -658
View File
@@ -66,10 +66,6 @@ const LOCALES = {
'install.serverPack.label': 'Server Pack',
'install.localName.label': 'Local Name',
'install.downloadBtn': 'Download & Install',
'install.custom.name': 'Pack Name',
'install.custom.mcVersion': 'Minecraft Version',
'install.custom.loader': 'Loader',
'install.custom.loaderVersion': 'Loader Version',
'install.custom.unavailable': 'Not available yet',
'install.custom.desc': 'Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.',
'install.progress.installing': 'Installing...',
@@ -168,18 +164,6 @@ const LOCALES = {
'whitelist.done': 'Mods installed!',
'whitelist.selectNone': 'Select mods first',
'whitelist.installError': 'Failed to install mod',
'mods.title': 'Mods',
'mods.loading': 'Loading mods...',
'mods.empty': 'No mods installed',
'mods.search': 'Search mods...',
'mods.enabled': 'Enabled',
'mods.disabled': 'Disabled',
'mods.delete': 'Delete',
'mods.enable': 'Enable',
'mods.disable': 'Disable',
'mods.noResults': 'No mods found',
'mods.deleteConfirm': 'Delete {name}?',
'mods.count': '{count} mods',
'nav.admin': 'Admin',
'admin.title': 'Admin Panel',
'admin.clients': 'Clients',
@@ -227,42 +211,6 @@ const LOCALES = {
'install.preset.label': 'Preset (optional)',
'install.preset.default': 'Default',
'pack.disabled': 'Disabled',
'ctx.openFolder': 'Open in files',
'ctx.settings': 'Settings',
'ctx.delete': 'Delete',
'ctx.deleteConfirm': 'Delete pack {name}? This will delete all files.',
'ctx.openLogs': 'Open logs',
'ctx.name': 'Name',
'ctx.loader': 'Loader',
'ctx.loaderVersion': 'Loader version',
'ctx.save': 'Save',
'ctx.cancel': 'Cancel',
'ctx.filemanager': 'File manager',
'onboard.title.install': 'Where to install packs',
'onboard.desc.install': 'Click + in sidebar or Install pack — choose server or custom.',
'onboard.title.pass': 'Where to activate pass',
'onboard.desc.pass': 'Pass unlocks server packs. Activate in Settings → Activate pass.',
'onboard.title.settings': 'Where settings are',
'onboard.desc.settings': 'Gear at bottom — RAM, resolution, JVM, Java, language, network.',
'onboard.title.manage': 'How to manage a pack',
'onboard.desc.manage': 'Click to open card. Right-click for Open in files / Settings / Delete. Card has Play, Update, delete.',
'onboard.title.mods': 'Mods in pack',
'onboard.desc.mods': 'Server packs: toggle only additional mods. Custom: toggle/delete any mods, search on top.',
'onboard.title.logs': 'Where game logs are',
'onboard.desc.logs': 'Settings → Game Log — live output. Or right-click → Open in files → logs folder.',
'onboard.next': 'Next',
'onboard.back': 'Back',
'onboard.skip': 'Skip',
'onboard.done': 'Got it',
'onboard.progress': 'Step {current} of {total}',
'onboard.showAgain': 'Show tutorial again',
'onboard.neverShow': "Don't show again",
'onboard.confirmTitle': 'Skip tutorial?',
'onboard.confirmDesc': 'Are you sure you want to skip the tutorial?',
'onboard.yes': 'Yes',
'onboard.no': 'No',
'onboard.infoTitle': 'Tutorial',
'onboard.infoDesc': 'You can replay the tutorial in Settings.',
},
ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
@@ -329,10 +277,6 @@ const LOCALES = {
'install.serverPack.label': 'Серверная сборка',
'install.localName.label': 'Локальное имя',
'install.downloadBtn': 'Скачать и установить',
'install.custom.name': 'Название сборки',
'install.custom.mcVersion': 'Версия Minecraft',
'install.custom.loader': 'Загрузчик',
'install.custom.loaderVersion': 'Версия загрузчика',
'install.custom.unavailable': 'Пока недоступно',
'install.custom.desc': 'Установка своей сборки отключена в этой версии. Используйте вкладку "Серверная сборка".',
'install.progress.installing': 'Установка...',
@@ -431,18 +375,6 @@ const LOCALES = {
'whitelist.done': 'Моды установлены!',
'whitelist.selectNone': 'Выберите моды',
'whitelist.installError': 'Ошибка установки мода',
'mods.title': 'Моды',
'mods.loading': 'Загрузка модов...',
'mods.empty': 'Модов не установлено',
'mods.search': 'Поиск модов...',
'mods.enabled': 'Включен',
'mods.disabled': 'Отключен',
'mods.delete': 'Удалить',
'mods.enable': 'Включить',
'mods.disable': 'Выключить',
'mods.noResults': 'Ничего не найдено',
'mods.deleteConfirm': 'Удалить {name}?',
'mods.count': '{count} модов',
'nav.admin': 'Админка',
'admin.title': 'Панель администратора',
'admin.clients': 'Клиенты',
@@ -490,42 +422,6 @@ const LOCALES = {
'install.preset.label': 'Пресет (опционально)',
'install.preset.default': 'Стандартный',
'pack.disabled': 'Отключена',
'ctx.openFolder': 'Открыть в файлах',
'ctx.settings': 'Настройки',
'ctx.delete': 'Удалить',
'ctx.deleteConfirm': 'Удалить сборку {name}? Это удалит все файлы.',
'ctx.openLogs': 'Открыть логи',
'ctx.name': 'Название',
'ctx.loader': 'Загрузчик',
'ctx.loaderVersion': 'Версия загрузчика',
'ctx.save': 'Сохранить',
'ctx.cancel': 'Отмена',
'ctx.filemanager': 'Файловый менеджер',
'onboard.title.install': 'Где устанавливать сборки',
'onboard.desc.install': 'Нажми + в боковой панели или кнопку «Установить сборку» — выбери серверную или свою кастомную.',
'onboard.title.pass': 'Где активировать проходку',
'onboard.desc.pass': 'Проходка даёт доступ к серверным сборкам. Активируй её в Настройках → Активировать проходку или через серверные сборки.',
'onboard.title.settings': 'Где настройки',
'onboard.desc.settings': 'Шестерёнка внизу — память, разрешение, JVM, Java, язык, сеть. Настрой под своё железо.',
'onboard.title.manage': 'Как управлять сборкой',
'onboard.desc.manage': 'Клик — открыть карточку. ПКМ — открыть в файлах / настройки / удалить. В карточке — Играть, Обновить, удалить, описание.',
'onboard.title.mods': 'Моды в сборке',
'onboard.desc.mods': 'В серверных — управляй только дополнительными модами. В своих — включай/выключай и удаляй любые моды, поиск сверху.',
'onboard.title.logs': 'Где логи игры',
'onboard.desc.logs': 'Настройки → Лог игры — смотри вывод в реальном времени. Или ПКМ → Открыть в файлах → папка logs.',
'onboard.next': 'Далее',
'onboard.back': 'Назад',
'onboard.skip': 'Пропустить',
'onboard.done': 'Понятно',
'onboard.progress': 'Шаг {current} из {total}',
'onboard.showAgain': 'Показать обучение снова',
'onboard.neverShow': 'Не показывать снова',
'onboard.confirmTitle': 'Точно пропустить туториал?',
'onboard.confirmDesc': 'Вы уверены, что хотите пропустить обучение?',
'onboard.yes': 'Да',
'onboard.no': 'Нет',
'onboard.infoTitle': 'Туториал',
'onboard.infoDesc': 'Туториал можно будет пройти в настройках.',
}
};
@@ -589,157 +485,49 @@ class ZernMCLauncher {
}
}
// ==================== BACKGROUND — hollow diamonds ====================
// ==================== BACKGROUND ====================
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
let mode = 'login'; // login=blue, register=orange
let modeLerp = 0; // 0=login, 1=register
let exitProgress = 0; // 0..1 fly-out
let exitActive = false;
let rafId = 0;
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let t = 0, mode = 'login', modePulse = 0;
const PALETTES = {
login: {
far: { r: 59, g: 130, b: 246, a: 0.18 },
mid: { r: 59, g: 130, b: 246, a: 0.32 },
near: { r: 96, g: 165, b: 255, a: 0.42 }
},
register: {
far: { r: 249, g: 115, b: 22, a: 0.18 },
mid: { r: 249, g: 115, b: 22, a: 0.34 },
near: { r: 255, g: 162, b: 77, a: 0.44 }
}
};
this.setWaveMode = m => { mode = m; modePulse = 1; };
const diamonds = [];
const COUNT = 44;
const seed = () => Math.random();
const lerp = (a,b,t) => a + (b-a)*t;
const lerpColor = (ca, cb, t) => ({
r: lerp(ca.r, cb.r, t),
g: lerp(ca.g, cb.g, t),
b: lerp(ca.b, cb.b, t),
a: lerp(ca.a, cb.a, t)
});
const rebuild = () => {
diamonds.length = 0;
const w = c.width / dpr, h = c.height / dpr;
// distribute with jitter, keep margins so diamonds not clipped
for (let i = 0; i < COUNT; i++) {
const layerRoll = seed();
let layer, size;
if (layerRoll < 0.35) { layer = 'far'; size = 12 + seed()*6; }
else if (layerRoll < 0.70) { layer = 'mid'; size = 22 + seed()*10; }
else { layer = 'near'; size = 36 + seed()*16; }
// avoid center where login form sits (roughly 360x420 centered)
let x, y, attempts = 0;
do {
x = seed() * (w - size*1.4) + size*0.7;
y = seed() * (h - size*1.4) + size*0.7;
attempts++;
} while (attempts < 5 && Math.abs(x - w/2) < 190 && Math.abs(y - h/2) < 220);
diamonds.push({
x, y, ox: x, oy: y,
size,
layer,
rot: 45 * Math.PI/180,
driftX: (seed()-0.5)*0.6,
driftY: 0.15 + seed()*0.45,
phase: seed()*Math.PI*2,
depthMul: layer==='far'?0.15 : layer==='mid'?0.45 : 1.0
});
}
};
const resize = () => {
const w = window.innerWidth, h = window.innerHeight;
c.width = w * dpr; c.height = h * dpr;
c.style.width = w + 'px'; c.style.height = h + 'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
rebuild();
};
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
window.addEventListener('resize', resize);
// public API
this.setDiamondMode = (m) => { mode = m==='register'?'register':'login'; };
// keep compat
this.setWaveMode = this.setDiamondMode;
this.animateDiamondExit = () => {
if (reduceMotion || exitActive) return Promise.resolve();
exitActive = true;
exitProgress = 0;
return new Promise(res => {
const start = performance.now();
const dur = 900;
const ease = t => t<0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; // easeInOutCubic
const step = (now) => {
const p = Math.min(1, (now - start)/dur);
exitProgress = ease(p);
if (p < 1) requestAnimationFrame(step);
else { exitActive = false; res(); }
};
requestAnimationFrame(step);
});
};
let tick = 0;
const draw = () => {
const w = c.width / dpr, h = c.height / dpr;
ctx.clearRect(0, 0, w, h);
// lerp palette between modes
const target = mode === 'register' ? 1 : 0;
modeLerp += (target - modeLerp) * 0.06;
const palFar = lerpColor(PALETTES.login.far, PALETTES.register.far, modeLerp);
const palMid = lerpColor(PALETTES.login.mid, PALETTES.register.mid, modeLerp);
const palNear = lerpColor(PALETTES.login.near, PALETTES.register.near, modeLerp);
ctx.clearRect(0, 0, c.width, c.height);
const w = c.width, h = c.height;
modePulse += (0 - modePulse) * 0.03;
// exit parallax offset (vh in px)
const farExit = reduceMotion ? 0 : exitProgress * (h*0.40);
const midExit = reduceMotion ? 0 : exitProgress * (h*0.70);
const nearExit = reduceMotion ? 0 : exitProgress * (h*1.10);
const baseAlpha = 0.04 + modePulse * 0.03;
const amp = 20 + modePulse * 10;
const freq = 0.008 + modePulse * 0.003;
const speed = 0.008;
const lines = 5;
for (const d of diamonds) {
const pal = d.layer==='far'? palFar : d.layer==='mid'? palMid : palNear;
// idle drift
const t = tick * 0.001 + d.phase;
const dx = Math.sin(t * 0.6 + d.ox*0.01) * d.driftX;
const dyBase = reduceMotion ? 0 : (tick * 0.015 * d.depthMul * d.driftY);
// exit offset + breathing scale
const exitOff = d.layer==='far'? farExit : d.layer==='mid'? midExit : nearExit;
const breath = reduceMotion ? 1 : 1 + Math.sin(t*0.8)*0.03;
const x = d.ox + dx;
let y = (d.oy + dyBase - exitOff) % (h + d.size*2);
if (y < -d.size) y += h + d.size*2;
const s = d.size * breath * (exitActive && d.layer==='near' ? (1 + exitProgress*0.06) : 1);
const alpha = pal.a * (exitActive ? (1 - exitProgress*0.7) : 1);
if (alpha < 0.02) continue;
ctx.save();
ctx.translate(x, y);
ctx.rotate(d.rot);
ctx.strokeStyle = `rgba(${Math.round(pal.r)},${Math.round(pal.g)},${Math.round(pal.b)},${alpha})`;
ctx.lineWidth = d.layer==='far'? 1 : d.layer==='mid'? 1.1 : 1.4;
// hollow diamond: centered square
ctx.strokeRect(-s/2, -s/2, s, s);
ctx.restore();
for (let i = 0; i < lines; i++) {
const yOff = (h / (lines + 1)) * (i + 1);
const alpha = baseAlpha * (1 - i * 0.12);
ctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
const y = yOff + wave + wave2;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
tick += 16;
rafId = requestAnimationFrame(draw);
t++;
};
// pause when hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelAnimationFrame(rafId);
else draw();
});
const anim = () => { draw(); requestAnimationFrame(anim); };
resize();
draw();
anim();
}
// ==================== API ====================
@@ -843,22 +631,6 @@ class ZernMCLauncher {
if (r.success) {
this.state.account = r.data;
// hollow diamond exit — parallax fly-out
if (this.animateDiamondExit) await this.animateDiamondExit();
const loginScreen = document.getElementById('login-screen');
const loginContainer = document.getElementById('login-container');
if (loginContainer && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
loginContainer.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
loginContainer.style.transform = 'translateY(-110vh) scale(0.92)';
loginContainer.style.opacity = '0';
loginContainer.style.filter = 'blur(6px)';
await new Promise(res => setTimeout(res, 900));
loginContainer.style.transition = '';
loginContainer.style.transform = '';
loginContainer.style.opacity = '';
loginContainer.style.filter = '';
}
if (loginScreen) loginScreen.classList.add('hidden');
this.enterMain();
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
this.toast(tr(key, null, {username: r.data.username}), 'success');
@@ -867,21 +639,6 @@ class ZernMCLauncher {
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) {
this.state.account = reg.data;
if (this.animateDiamondExit) await this.animateDiamondExit();
const loginScreen2 = document.getElementById('login-screen');
const loginContainer2 = document.getElementById('login-container');
if (loginContainer2 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
loginContainer2.style.transition = 'transform 0.9s cubic-bezier(0.16,1,0.3,1), opacity 0.9s ease, filter 0.9s ease';
loginContainer2.style.transform = 'translateY(-110vh) scale(0.92)';
loginContainer2.style.opacity = '0';
loginContainer2.style.filter = 'blur(6px)';
await new Promise(res => setTimeout(res, 900));
loginContainer2.style.transition = '';
loginContainer2.style.transform = '';
loginContainer2.style.opacity = '';
loginContainer2.style.filter = '';
}
if (loginScreen2) loginScreen2.classList.add('hidden');
this.enterMain();
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
return;
@@ -971,8 +728,7 @@ class ZernMCLauncher {
container.style.transform = '';
container.style.opacity = '';
if (this.setDiamondMode) this.setDiamondMode(isReg ? 'register' : 'login');
else if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
}, 150);
}
@@ -983,8 +739,6 @@ class ZernMCLauncher {
}
showLogin() {
const c = document.getElementById('login-container');
if (c) { c.style.transition=''; c.style.transform=''; c.style.opacity=''; c.style.filter=''; }
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
@@ -1017,7 +771,6 @@ class ZernMCLauncher {
this.startFriendStatusHeartbeat();
this.enhanceSelects();
this.updateAdminNav();
setTimeout(()=> this.checkOnboarding(), 600);
}
async loadServerPacksList() {
@@ -1354,7 +1107,6 @@ class ZernMCLauncher {
`;
if (!isDisabled) {
el.addEventListener('click', () => this.selectPack(inst));
el.addEventListener('contextmenu', (e) => { e.preventDefault(); this.showCtxMenu(inst, e.clientX, e.clientY); });
}
targetList.appendChild(el);
});
@@ -1510,9 +1262,6 @@ class ZernMCLauncher {
} else {
whitelistSection.classList.add('hidden');
}
// Local mods for any pack (server = readOnly except whitelist)
this.loadLocalMods(inst.name, inst.isServerPack);
}
async loadPackInfo(name) {
@@ -1581,83 +1330,6 @@ class ZernMCLauncher {
btn.disabled = false;
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
this.toast(t('whitelist.done'), 'success');
// refresh local mods
this.loadLocalMods(instanceName, true);
}
async loadLocalMods(instanceName, isServerPack) {
const section = document.getElementById('local-mods-section');
const listEl = document.getElementById('local-mods-list');
const countEl = document.getElementById('mods-count');
if (!section || !listEl) return;
section.classList.remove('hidden');
listEl.innerHTML = '<div class="mods-loading">' + t('mods.loading') + '</div>';
const r = await this.req('/pack-info?name=' + encodeURIComponent(instanceName));
if (!r.success || !r.data) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
return;
}
const mods = r.data.mods || [];
// cache for filtering
this._localModsCache = mods;
this._localModsIsServer = !!isServerPack;
this._localModsInstance = instanceName;
if (countEl) countEl.textContent = tr('mods.count', null, {count: String(mods.length)});
this.renderLocalMods(mods, isServerPack);
}
renderLocalMods(mods, isServerPack) {
const listEl = document.getElementById('local-mods-list');
if (!listEl) return;
if (!mods || mods.length === 0) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.empty') + '</div>';
return;
}
const q = (document.getElementById('mods-search')?.value || '').toLowerCase();
const filtered = q ? mods.filter(m => (m.displayName||m.fileName||'').toLowerCase().includes(q) || (m.modId||'').toLowerCase().includes(q)) : mods;
if (filtered.length === 0) {
listEl.innerHTML = '<div class="mods-loading">' + t('mods.noResults') + '</div>';
return;
}
let html = '';
filtered.forEach(m => {
const sizeStr = m.size > 1048576 ? (m.size/1048576).toFixed(1)+' MB' : Math.ceil(m.size/1024)+' KB';
const enabled = m.enabled !== false;
const badge = enabled ? t('mods.enabled') : t('mods.disabled');
const badgeCls = enabled ? 'mod-badge-enabled' : 'mod-badge-disabled';
const canToggle = !isServerPack; // server: read-only for local mods
const canDelete = !isServerPack; // server: only whitelist mods can be managed
html += '<div class="mod-item' + (enabled?'':' mod-disabled') + '">'
+ '<div class="mod-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg></div>'
+ '<div class="mod-info"><div class="mod-name">' + this.esc(m.displayName || m.fileName) + '</div><div class="mod-meta">' + this.esc(m.modId || m.fileName) + ' · ' + this.esc(m.version || '?') + ' · ' + sizeStr + '</div></div>'
+ '<span class="mod-badge ' + badgeCls + '">' + badge + '</span>'
+ (canToggle ? '<button class="btn-secondary btn-sm" onclick="app.toggleLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\',' + !enabled + ')">' + (enabled? t('mods.disable'):t('mods.enable')) + '</button>' : '')
+ (canDelete ? '<button class="btn-secondary btn-sm btn-danger" onclick="app.deleteLocalMod(\'' + this.esc(m.fileName).replace(/'/g,"\\'") + '\')">' + t('mods.delete') + '</button>' : '')
+ '</div>';
});
listEl.innerHTML = html;
}
filterLocalMods() {
if (!this._localModsCache) return;
this.renderLocalMods(this._localModsCache, this._localModsIsServer);
}
async toggleLocalMod(fileName, enable) {
const inst = this._localModsInstance;
if (!inst) return;
const r = await this.req('/mods/toggle', { method:'POST', body: JSON.stringify({instance: inst, file: fileName, enable: enable}) });
if (r.success) { this.toast(enable? t('mods.enabled'):t('mods.disabled'), 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
else this.toast(r.error||'Error', 'error');
}
async deleteLocalMod(fileName) {
const inst = this._localModsInstance;
if (!inst) return;
if (!confirm(tr('mods.deleteConfirm', null, {name: fileName}))) return;
const r = await this.req('/mods/delete', { method:'POST', body: JSON.stringify({instance: inst, file: fileName}) });
if (r.success) { this.toast(t('mods.delete')+': '+fileName, 'success'); this.loadLocalMods(inst, this._localModsIsServer); }
else this.toast(r.error||'Error', 'error');
}
// ==================== ADMIN ====================
@@ -2923,313 +2595,12 @@ class ZernMCLauncher {
}
}
replayOnboarding() {
localStorage.removeItem('zern_onboardBlocks');
localStorage.removeItem('zern_onboardVersion');
// force show all blocks
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
this.showOnboarding(BLOCKS, curVer, []);
}
esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ==================== CTX MENU (PKM) ====================
async showCtxMenu(inst, x, y) {
this.hideCtxMenu();
const isServer = !!inst.isServerPack;
// fetch fm name once
let fmName = this._fmName;
if (!fmName) {
try { const r = await this.req('/filemanager'); if (r.success) fmName = r.data?.name || 'File Manager'; } catch(e) {}
this._fmName = fmName || 'File Manager';
fmName = this._fmName;
}
const menu = document.createElement('div');
menu.id = 'ctx-menu';
menu.className = 'ctx-menu';
menu.innerHTML = `
<button class="ctx-item" data-act="open"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> ${t('ctx.openFolder')} <span style="margin-left:auto;opacity:0.5;font-size:11px">${this.esc(fmName)}</span></button>
<button class="ctx-item" data-act="settings"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg> ${t('ctx.settings')}</button>
<div class="ctx-sep"></div>
<button class="ctx-item danger" data-act="delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('ctx.delete')}</button>
`;
const backdrop = document.createElement('div');
backdrop.id = 'ctx-backdrop';
backdrop.className = 'ctx-backdrop';
backdrop.addEventListener('click', () => this.hideCtxMenu());
backdrop.addEventListener('contextmenu', (e)=>{ e.preventDefault(); this.hideCtxMenu(); });
document.body.appendChild(backdrop);
document.body.appendChild(menu);
// position with flip
const pad = 8;
let mx = x, my = y;
const rect = menu.getBoundingClientRect();
if (mx + rect.width + pad > window.innerWidth) mx = window.innerWidth - rect.width - pad;
if (my + rect.height + pad > window.innerHeight) my = window.innerHeight - rect.height - pad;
menu.style.left = mx + 'px';
menu.style.top = my + 'px';
menu.querySelector('[data-act="open"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackFolder(inst.name); });
menu.querySelector('[data-act="settings"]').addEventListener('click', () => { this.hideCtxMenu(); this.openPackSettings(inst); });
menu.querySelector('[data-act="delete"]').addEventListener('click', () => { this.hideCtxMenu(); this.deletePack(inst.name); });
const onKey = (e)=>{ if(e.key==='Escape') this.hideCtxMenu(); };
document.addEventListener('keydown', onKey, {once:true});
this._ctxKeyHandler = onKey;
}
hideCtxMenu() {
document.getElementById('ctx-menu')?.remove();
document.getElementById('ctx-backdrop')?.remove();
if (this._ctxKeyHandler) { document.removeEventListener('keydown', this._ctxKeyHandler); this._ctxKeyHandler=null; }
}
async openPackFolder(name) {
const r = await this.req('/pack/open-folder', { method:'POST', body: JSON.stringify({name}) });
if (!r.success) this.toast(r.error||'Failed to open folder','error');
}
openPackSettings(inst) {
const isServer = !!inst.isServerPack;
let modal = document.getElementById('pack-settings-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'pack-settings-modal';
modal.className = 'modal-backdrop';
modal.innerHTML = `
<div class="modal">
<div class="modal-head"><h3>${t('ctx.settings')} ${this.esc(inst.name)}</h3><button class="modal-close" id="ps-close">&times;</button></div>
<div class="modal-body pack-settings-form">
<div class="field"><label>${t('ctx.name')}</label><input type="text" id="ps-name" value="${this.esc(inst.name)}" ${isServer?'disabled':''}></div>
<div class="field"><label>${t('ctx.loader')}</label>
<select id="ps-loader" ${isServer?'disabled':''}>
<option value="vanilla" ${inst.loaderType==='vanilla'?'selected':''}>Vanilla</option>
<option value="fabric" ${inst.loaderType==='fabric'?'selected':''}>Fabric</option>
<option value="forge" ${inst.loaderType==='forge'?'selected':''}>Forge</option>
<option value="neoforge" ${inst.loaderType==='neoforge'?'selected':''}>NeoForge</option>
</select>
</div>
<div class="field ${inst.loaderType==='vanilla'?'hidden':''}" id="ps-loader-ver-field"><label>${t('ctx.loaderVersion')}</label><input type="text" id="ps-loader-ver" value="${this.esc(inst.loaderVersion||'')}" placeholder="0.16.0" ${isServer?'disabled':''}></div>
${isServer?'<p style="font-size:12px;color:var(--text-muted)">'+t('mods.disabled')+' — server pack is read-only</p>':''}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px">
<button class="btn-secondary" id="ps-cancel">${t('ctx.cancel')}</button>
<button class="btn-primary" id="ps-save" ${isServer?'disabled':''}>${t('ctx.save')}</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e=>{ if(e.target===modal) modal.remove(); });
document.getElementById('ps-close').addEventListener('click', ()=>modal.remove());
document.getElementById('ps-cancel').addEventListener('click', ()=>modal.remove());
const loaderSel = document.getElementById('ps-loader');
loaderSel?.addEventListener('change', e=>{
const f=document.getElementById('ps-loader-ver-field');
if(e.target.value==='vanilla') f.classList.add('hidden'); else f.classList.remove('hidden');
});
document.getElementById('ps-save').addEventListener('click', async ()=>{
const newName = document.getElementById('ps-name').value.trim();
const loader = document.getElementById('ps-loader').value;
const loaderVer = document.getElementById('ps-loader-ver').value.trim();
const body = {name: inst.name};
if (newName && newName!==inst.name) body.newName=newName;
if (loader) body.loader=loader;
if (loaderVer) body.loaderVersion=loaderVer;
const r = await this.req('/pack/settings', {method:'POST', body: JSON.stringify(body)});
if (r.success) { modal.remove(); this.toast(t('ctx.save')+' ✓','success'); await this.loadInstances(); if(newName) this.state.selectedPack=null; }
else this.toast(r.error||'Error','error');
});
}
async deletePack(name) {
if (!confirm(tr('ctx.deleteConfirm', null, {name}))) return;
const r = await this.req('/pack/delete', {method:'POST', body: JSON.stringify({name})});
if (r.success) { this.toast(t('ctx.delete')+': '+name,'success'); if(this.state.selectedPack?.name===name) this.showEmptyState(); await this.loadInstances(); }
else this.toast(r.error||'Error','error');
}
// ==================== ONBOARDING ====================
async checkOnboarding() {
// show only on first login or version diff, highlight-only — persist via Config + localStorage
const curVer = (document.getElementById('version')?.textContent || document.getElementById('header-version')?.textContent || '1.1.1').trim() || '1.1.1';
let storedVer = localStorage.getItem('zern_onboardVersion') || '';
let completed = (localStorage.getItem('zern_onboardBlocks') || '').split(',').filter(Boolean);
try {
const r = await this.req('/settings');
if (r.success && r.data) {
const srvVer = r.data.onboardedVersion || '';
const srvBlocks = (r.data.completedOnboardBlocks || '').split(',').filter(Boolean);
if (!storedVer && srvVer) { storedVer = srvVer; localStorage.setItem('zern_onboardVersion', srvVer); }
if (completed.length===0 && srvBlocks.length) { completed = srvBlocks; localStorage.setItem('zern_onboardBlocks', srvBlocks.join(',')); }
// if local newer, sync to server
if (storedVer && !srvVer) { this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: storedVer, completedOnboardBlocks: completed.join(',')})}); }
}
} catch(e){}
const firstLogin = !storedVer; // no onboard yet
const BLOCKS = [
{id:'install', titleKey:'onboard.title.install', descKey:'onboard.desc.install', sel:['#add-server-pack-btn','#add-pack-btn','#server-packs-header .btn-icon','#local-packs-header .btn-icon'], addedIn:'1.1.1'},
{id:'settings', titleKey:'onboard.title.settings', descKey:'onboard.desc.settings', sel:'#settings-btn', addedIn:'1.1.1'},
{id:'pass', titleKey:'onboard.title.pass', descKey:'onboard.desc.pass', sel:'#pass-code', addedIn:'1.1.1', requiresView:'settings'},
{id:'manage', titleKey:'onboard.title.manage', descKey:'onboard.desc.manage', sel:['.pack-entry','#selected-pack-title','#pack-empty-state'], addedIn:'1.1.1'},
{id:'mods', titleKey:'onboard.title.mods', descKey:'onboard.desc.mods', sel:'#local-mods-section', addedIn:'1.1.1'},
{id:'logs', titleKey:'onboard.title.logs', descKey:'onboard.desc.logs', sel:'#show-log-viewer-btn', addedIn:'1.1.1'},
];
let toShow;
if (firstLogin) toShow = BLOCKS;
else {
// version diff: show only blocks added after storedVer
const isNewer = (a,b)=> a.localeCompare(b, undefined, {numeric:true})>0;
toShow = BLOCKS.filter(b=> !completed.includes(b.id) && isNewer(b.addedIn, storedVer));
if (toShow.length===0 && storedVer !== curVer) {
// version bump but all blocks already completed -> no show
localStorage.setItem('zern_onboardVersion', curVer);
return;
}
}
if (toShow.length===0) return;
this.showOnboarding(toShow, curVer, completed);
}
showOnboarding(blocks, curVer, completed) {
let idx = 0;
const overlay = document.createElement('div');
overlay.className = 'onboard-overlay';
overlay.id = 'onboard-overlay';
document.body.appendChild(overlay);
const card = document.createElement('div');
card.className = 'onboard-card';
card.id = 'onboard-card';
document.body.appendChild(card);
const spot = document.createElement('div');
spot.className = 'onboard-spot';
spot.id = 'onboard-spot';
const label = document.createElement('div');
label.className = 'onboard-spot-label';
label.id = 'onboard-spot-label';
document.body.appendChild(spot);
document.body.appendChild(label);
const resolveTarget = (b) => {
const sels = Array.isArray(b.sel) ? b.sel : [b.sel];
for (const s of sels) {
for (const el of document.querySelectorAll(s)) {
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
const visible = r.width>0 && r.height>0 && style.display!=='none' && style.visibility!=='hidden' && el.offsetParent!==null;
if (!visible) continue;
if (el.closest('.hidden')) continue;
// check collapsed accordion
if (el.closest('.pack-list-collapse') && el.closest('.section-collapsible.collapsed')) continue;
return el;
}
}
return null;
};
const ensureView = async (b) => {
if (b.requiresView==='settings' || b.id==='pass' || b.id==='logs') {
this.switchView('settings');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
} else if (b.id==='install' || b.id==='manage' || b.id==='mods') {
this.switchView('packs');
await new Promise(r=> requestAnimationFrame(()=> requestAnimationFrame(r)));
}
};
const persist = (vers, blocksArr) => {
const v = vers || curVer;
const b = blocksArr.join(',');
localStorage.setItem('zern_onboardBlocks', b);
localStorage.setItem('zern_onboardVersion', v);
// persist to backend Config so WebView localStorage volatility doesn't resurrect tutorial
this.req('/settings', {method:'POST', body: JSON.stringify({onboardedVersion: v, completedOnboardBlocks: b})}).catch(()=>{});
};
const goNext = async () => {
if (document.getElementById('onboard-never')?.checked) {
const all = [...new Set(blocks.map(x=>x.id).concat(completed))];
persist(curVer, all);
closeAll(); return;
}
if (idx===blocks.length-1) {
const newCompleted = [...new Set([...completed, ...blocks.map(x=>x.id)])];
persist(curVer, newCompleted);
closeAll();
} else { idx++; await render(); }
};
const goBack = async () => { if(idx>0){ idx--; await render(); } };
const showConfirm = (onYes) => {
let m = document.createElement('div');
m.className='modal-backdrop'; m.style.zIndex='410';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.confirmTitle')||'Точно пропустить туториал?'}</h3><button class="modal-close" id="c-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.confirmDesc')||'Вы уверены?'}</p><div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px"><button class="btn-secondary btn-sm" id="c-no">${t('onboard.no')||'Нет'}</button><button class="btn-primary btn-sm" id="c-yes">${t('onboard.yes')||'Да'}</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#c-close').onclick=close;
m.querySelector('#c-no').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
m.querySelector('#c-yes').onclick=()=>{ close(); onYes(); };
};
const showInfo = () => {
let m=document.createElement('div'); m.className='modal-backdrop'; m.style.zIndex='411';
m.innerHTML=`<div class="modal modal-sm"><div class="modal-head"><h3>${t('onboard.infoTitle')||'Туториал'}</h3><button class="modal-close" id="i-close">&times;</button></div><div class="modal-body"><p style="font-size:13px;color:var(--text-secondary)">${t('onboard.infoDesc')||'Туториал можно будет пройти в настройках.'}</p><div style="display:flex;justify-content:flex-end;margin-top:16px"><button class="btn-primary btn-sm" id="i-ok">OK</button></div></div></div>`;
document.body.appendChild(m);
const close=()=>m.remove();
m.querySelector('#i-close').onclick=close;
m.querySelector('#i-ok').onclick=close;
m.addEventListener('click', e=>{ if(e.target===m) close(); });
};
const requestClose = () => {
showConfirm(()=>{ closeAll(); showInfo(); });
};
const render = async () => {
const b = blocks[idx];
await ensureView(b);
card.innerHTML = `
<div class="onboard-progress">${tr('onboard.progress', null, {current:String(idx+1), total:String(blocks.length)})}</div>
<h3>${t(b.titleKey)}</h3>
<p>${t(b.descKey)}</p>
<div class="onboard-actions">
<button class="btn-secondary btn-sm" id="onboard-skip">${t('onboard.skip')}</button>
${idx>0?'<button class="btn-secondary btn-sm" id="onboard-back">'+t('onboard.back')+'</button>':''}
<button class="btn-primary btn-sm" id="onboard-next">${idx===blocks.length-1? t('onboard.done'): t('onboard.next')}</button>
</div>
<label style="display:flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--text-muted)"><input type="checkbox" id="onboard-never"> ${t('onboard.neverShow')}</label>
`;
const el = resolveTarget(b);
if (el) {
const r = el.getBoundingClientRect();
spot.style.left = (r.left-4)+'px';
spot.style.top = (r.top-4)+'px';
spot.style.width = (r.width+8)+'px';
spot.style.height = (r.height+8)+'px';
spot.style.display='block';
label.textContent = t(b.titleKey);
label.style.left = (r.left + r.width/2)+'px';
label.style.top = (r.top)+'px';
label.style.display='block';
el.scrollIntoView({block:'center', behavior:'smooth'});
} else {
spot.style.display='none'; label.style.display='none';
}
document.getElementById('onboard-skip').addEventListener('click', requestClose);
document.getElementById('onboard-next').addEventListener('click', goNext);
const backBtn=document.getElementById('onboard-back');
if(backBtn) backBtn.addEventListener('click', goBack);
};
const closeAll = ()=>{
overlay.remove(); card.remove(); spot.remove(); label.remove();
document.removeEventListener('keydown', escHandler);
};
spot.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
label.addEventListener('click', (e)=>{ e.stopPropagation(); goNext(); });
const escHandler = (e)=>{ if(e.key==='Escape') requestClose(); };
document.addEventListener('keydown', escHandler);
overlay.addEventListener('click', (e)=>{ if(e.target===overlay) requestClose(); });
render();
}
}
function renderFriendItem(f) {
+3 -53
View File
@@ -29,10 +29,8 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { -webkit-user-select: none; user-select: none; }
.pack-entry, .pack-entry *, .ctx-menu, .ctx-menu * { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], .log-viewer-content, .news-modal-body, .pack-description-text { -webkit-user-select: text; user-select: text; }
.pack-entry { -webkit-touch-callout: default; }
html, body, * { -webkit-user-select: none; user-select: none; }
input, textarea { -webkit-user-select: text; user-select: text; }
html { font-size: 14px; }
@@ -47,11 +45,8 @@ body {
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.14; pointer-events: none;
z-index: 0; opacity: 0.08; pointer-events: none;
}
#login-screen.exiting { pointer-events: none; }
.login-container.exit { will-change: transform, opacity, filter; }
#bg-canvas.bg-exit { will-change: transform; }
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
@@ -336,9 +331,6 @@ body {
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
.view.active { display: flex; }
#view-packs { overflow: hidden; }
#view-packs .pack-detail { flex: 1; overflow-y: auto; min-height: 0; }
#view-packs .play-bar { position: sticky; bottom: 0; z-index: 5; flex-shrink: 0; margin-top: 12px; }
.view-header {
display: flex; align-items: flex-start; justify-content: space-between;
@@ -991,45 +983,3 @@ body {
transition: border-color 150ms ease, color 150ms ease;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
/* ========== LOCAL MODS ========== */
.mods-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
.mods-section .section-header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:11px; font-weight:600; text-transform:uppercase; color:var(--text-muted); padding:4px; margin-bottom:8px; letter-spacing:0.5px; }
.mods-count { font-size:11px; color:var(--text-secondary); margin-left:auto; }
.mods-search { margin-left:auto; min-width:160px; padding:6px 10px; border-radius:var(--radius-sm); background:var(--bg-surface); border:1px solid var(--border-light); color:var(--text); font-size:12px; outline:none; }
.mods-search:focus { border-color:var(--accent); }
.mods-list { display:flex; flex-direction:column; gap:4px; }
.mods-loading { text-align:center; padding:20px; color:var(--text-muted); font-size:12px; }
.mod-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); background:var(--bg-card); border:1px solid var(--border); transition:var(--transition); }
.mod-item:hover { background:var(--bg-card-hover); }
.mod-item.mod-disabled { opacity:0.6; }
.mod-icon { width:32px; height:32px; border-radius:6px; display:flex; align-items:center; justify-content:center; background:var(--bg-inset); color:var(--text-muted); flex-shrink:0; }
.mod-info { flex:1; min-width:0; }
.mod-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mod-badge { font-size:10px; font-weight:700; padding:2px 6px; border-radius:4px; flex-shrink:0; }
.mod-badge-enabled { background:rgba(74,222,128,0.15); color:var(--success); }
.mod-badge-disabled { background:rgba(248,113,113,0.12); color:var(--error); }
/* ========== CONTEXT MENU (PKM) ========== */
.ctx-menu { position:fixed; z-index:300; min-width:200px; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-sm); box-shadow:0 12px 32px rgba(0,0,0,0.5); padding:4px; display:flex; flex-direction:column; gap:2px; animation:ctxIn 0.12s ease; }
@keyframes ctxIn { from { opacity:0; transform:translateY(4px) scale(0.98); } to { opacity:1; transform:none; } }
.ctx-item { display:flex; align-items:center; gap:10px; padding:8px 12px; border-radius:4px; font-size:13px; color:var(--text-secondary); cursor:pointer; transition:var(--transition); border:none; background:transparent; font-family:var(--font); text-align:left; width:100%; }
.ctx-item:hover { background:var(--bg-card); color:var(--text); }
.ctx-item.danger { color:var(--error); }
.ctx-item.danger:hover { background:rgba(248,113,113,0.08); }
.ctx-sep { height:1px; background:var(--border); margin:2px 0; }
.ctx-backdrop { position:fixed; inset:0; z-index:299; }
/* ========== ONBOARDING SPOTLIGHT ========== */
.onboard-overlay { position:fixed; inset:0; z-index:400; background:rgba(7,7,10,0.72); backdrop-filter:blur(2px); animation:fadeIn 0.2s ease; }
.onboard-card { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:405; max-width:420px; width:90%; background:var(--bg-elevated); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.6); animation:floatIn 0.25s ease; }
.onboard-card h3 { font-size:18px; font-weight:700; margin-bottom:8px; }
.onboard-card p { font-size:13px; color:var(--text-secondary); line-height:1.6; margin-bottom:16px; }
.onboard-progress { font-size:11px; color:var(--text-muted); letter-spacing:0.5px; text-transform:uppercase; margin-bottom:12px; }
.onboard-actions { display:flex; gap:8px; justify-content:flex-end; }
.onboard-spot { position:fixed; z-index:401; border:2px solid var(--accent); border-radius:var(--radius-sm); box-shadow:0 0 0 9999px rgba(7,7,10,0.55), 0 0 24px var(--accent-glow); pointer-events:auto; cursor:pointer; transition:all 0.35s ease; }
.onboard-spot-label { position:fixed; z-index:402; background:var(--accent); color:#fff; font-size:12px; font-weight:600; padding:6px 10px; border-radius:999px; pointer-events:auto; cursor:pointer; transform:translate(-50%, -100%); margin-top:-8px; white-space:nowrap; }
/* ========== PACK SETTINGS MODAL ========== */
.pack-settings-form { display:flex; flex-direction:column; gap:16px; }
+2 -2
View File
@@ -19,8 +19,8 @@
</modules>
<properties>
<revision>1.1.1</revision>
<hotfix>2</hotfix>
<revision>1.1.0</revision>
<hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-15
View File
@@ -1798,21 +1798,6 @@ async def download_launcher_exe(request: Request = None):
)
@app.get("/launcher/download/jre")
async def download_jre(request: Request):
"""Download JRE zip for online installer (47M, Range supported)"""
candidates = [
BUILDS_DIR / ".." / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip", # /root/launcher/OpenJDK...
Path(__file__).parent.parent / "OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip",
Path("/root/launcher/OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip"),
BUILDS_DIR / "jre.zip",
]
for p in candidates:
pp = p.resolve()
if pp.exists() and pp.is_file():
return await send_file_async(pp, request, content_type="application/zip", cache=True)
raise HTTPException(404, "JRE zip not found on server")
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive"""
+5 -54
View File
@@ -70,7 +70,7 @@
<section class="section server" id="server">
<div class="section-head">
<span class="section-index">01 / 07</span>
<span class="section-index">01 / 06</span>
<h2 class="section-title" data-i18n-html="server.title">Сервер <span class="accent">жив.</span></h2>
<p class="section-sub" data-i18n="server.sub">ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.</p>
</div>
@@ -96,7 +96,7 @@
<section class="section highlights" id="highlights">
<div class="section-head">
<span class="section-index">02 / 07</span>
<span class="section-index">02 / 06</span>
<h2 class="section-title" data-i18n-html="highlights.title">Хайлайты <span class="accent">сервера.</span></h2>
<p class="section-sub" data-i18n="highlights.sub">Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.</p>
</div>
@@ -111,7 +111,7 @@
<section class="section features" id="features">
<div class="section-head">
<span class="section-index">03 / 07</span>
<span class="section-index">03 / 06</span>
<h2 class="section-title" data-i18n-html="features.title">Всё, что <span class="accent">тебе нужно.</span></h2>
<p class="section-sub" data-i18n="features.sub">Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.</p>
</div>
@@ -382,62 +382,13 @@
<p class="download-desc">Windows · <span id="download-size"></span></p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<a class="btn btn-primary btn-block" id="download-online-btn" href="/launcher/download/jre" download>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span>Online Setup (5.8М)</span>
</a>
<a class="btn btn-ghost btn-block" id="download-offline-btn" href="/launcher/download/latest">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>Offline ZIP (98М)</span>
</a>
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:10px;text-align:center">Online — выбери папку (по умолчанию C:\ZernMC) + ярлык ZernMC Launcher на рабочем столе · Offline — выбери куда распаковать · Данные в %USERPROFILE%\.zernmc</p>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<a class="btn btn-primary btn-block" id="download-btn" href="/launcher/download/latest">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/></svg>
<span data-i18n="download.latest">Скачать актуальную версию</span>
</a>
<div class="download-mirrors" id="mirrors">
<span class="mirrors-label" data-i18n="download.mirrors">Зеркала</span>
</div>
<!-- Git widget (launcher repo) -->
<div class="github-widget" id="github-widget" style="margin-top:20px;background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;color:var(--text);text-decoration:none">
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.51 2.87 8.33 6.84 9.68.5.09.68-.22.68-.48v-1.7c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.1-1.5-1.1-1.5-.9-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.05a9.2 9.2 0 0 1 5 0c1.9-1.32 2.74-1.05 2.74-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.07.36.32.68.94.68 1.9v2.82c0 .26.18.58.69.48A10.02 10.02 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/></svg>
sasheg/launcher
<span style="margin-left:auto;font-size:12px;color:var(--text-secondary);font-weight:500">git.swe.zernmc.ru</span>
</a>
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)" id="github-stats">
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> ветка <strong id="gh-branch" style="color:var(--text)">ui</strong></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <span id="gh-version">v1.1.1.2</span></span>
<span style="display:flex;align-items:center;gap:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11v2"/><path d="M17 11v2"/></svg> <span id="gh-commit">bcd10cc</span></span>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a href="https://git.swe.zernmc.ru/sasheg/launcher" target="_blank" rel="noopener" class="btn btn-ghost" style="flex:1;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> Открыть в Gitea</a>
</div>
<!-- Languages bar like GitHub -->
<div style="margin-top:4px">
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border);gap:1px" id="lang-bar" title="Java 49.4% · Python 25.4% · JavaScript 12.7% · CSS 7.3% · HTML 3.5% · Go 1.7%">
<div style="flex:49.4;background:#b07219" title="Java 49.4%"></div>
<div style="flex:25.4;background:#3572A5" title="Python 25.4%"></div>
<div style="flex:12.7;background:#f1e05a" title="JavaScript 12.7%"></div>
<div style="flex:7.3;background:#563d7c" title="CSS 7.3%"></div>
<div style="flex:3.5;background:#e34c26" title="HTML 3.5%"></div>
<div style="flex:1.7;background:#00ADD8" title="Go 1.7%"></div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:12px;color:var(--text-secondary)" id="lang-legend">
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#b07219;display:inline-block"></i> Java 49.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#3572A5;display:inline-block"></i> Python 25.4%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#f1e05a;display:inline-block"></i> JavaScript 12.7%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#563d7c;display:inline-block"></i> CSS 7.3%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#e34c26;display:inline-block"></i> HTML 3.5%</span>
<span style="display:flex;align-items:center;gap:6px"><i style="width:10px;height:10px;border-radius:50%;background:#00ADD8;display:inline-block"></i> Go 1.7%</span>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:6px">32 120 строк · Java 15 858 · Python 8 172 · JS 4 083 · CSS 2 345 · HTML 1 110 · Go 552</p>
</div>
<code style="font-size:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;color:var(--text-secondary);overflow:auto">git clone ssh://git@git.swe.zernmc.ru:2222/sasheg/launcher.git</code>
</div>
</div>
<p class="download-note" data-i18n="download.note">Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.</p>
+14 -117
View File
@@ -13,7 +13,6 @@
'nav.download': 'Скачать',
'nav.news': 'Новости',
'nav.play': 'Играть сейчас',
'section.of': 'Раздел {n} из {m}',
'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР',
'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>',
'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.',
@@ -127,7 +126,6 @@
'nav.download': 'Download',
'nav.news': 'News',
'nav.play': 'Play now',
'section.of': 'Section {n} of {m}',
'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER',
'hero.title': 'Awaken from<br><span class="hero-title-accent">the cold.</span>',
'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.',
@@ -255,13 +253,7 @@
document.documentElement.lang = currentLang;
const toggle = document.getElementById('langToggleLabel');
if (toggle) toggle.textContent = currentLang === 'ru' ? 'RU' : 'EN';
// Section indices: add a tooltip explaining what "02 / 07" means.
document.querySelectorAll('.section-index').forEach((el) => {
const m = (el.textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/);
if (m) el.setAttribute('title', tr('section.of').replace('{n}', m[1]).replace('{m}', m[2]));
});
if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU';
document.querySelectorAll('[data-i18n]').forEach((el) => {
el.textContent = tr(el.dataset.i18n);
@@ -323,8 +315,6 @@
const dlVersion = document.getElementById('download-version');
const dlSize = document.getElementById('download-size');
const dlBtn = document.getElementById('download-btn');
const dlOnline = document.getElementById('download-online-btn');
const dlOffline = document.getElementById('download-offline-btn');
const pvVersion = document.getElementById('preview-version');
if (heroVersion) heroVersion.textContent = version;
@@ -340,22 +330,6 @@
if (dlBtn && data.new_format && data.new_format.download_url) {
dlBtn.href = API + data.new_format.download_url;
}
// Online/Offline hybrid (Go, 1.1.3)
if (dlOnline) {
// online setup is Go exe (~5.8M) — try to discover via /launcher/version or static name
try {
const v = version && version !== '—' ? version : '1.1.1.2';
dlOnline.href = API + '/launcher/download/jre'; // JRE endpoint validated, exe name is ZernMC-Online-Setup-<ver>.exe
dlOnline.setAttribute('data-version', v);
// probe if online exe exists
fetch(API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe', {method:'HEAD'}).then(r=>{
if (r.ok) dlOnline.href = API + '/launcher/file/' + v + '/ZernMC-Online-Setup-' + v + '.exe';
}).catch(()=>{});
} catch(e){}
}
if (dlOffline && data.new_format && data.new_format.download_url) {
dlOffline.href = API + data.new_format.download_url;
}
} catch (e) {
console.warn('[site] failed to load launcher info', e);
}
@@ -449,10 +423,6 @@
const next = document.getElementById('carouselNext');
if (!track) return;
const AUTO_MS = 6500; // auto-advance when idle
const MANUAL_MS = 22000; // hold much longer after manual navigation
const videos = [];
HIGHLIGHTS.forEach((h, i) => {
const slide = document.createElement('div');
slide.className = 'carousel-slide';
@@ -461,10 +431,11 @@
const vid = document.createElement('video');
vid.src = '/highlights/' + h.file;
vid.controls = true;
vid.autoplay = true;
vid.loop = true;
vid.muted = true;
vid.playsInline = true;
slide.appendChild(vid);
videos[i] = vid;
} else {
const img = document.createElement('img');
img.src = '/highlights/' + h.file;
@@ -485,74 +456,38 @@
const dot = document.createElement('span');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => goto(i, true));
dot.addEventListener('click', () => goto(i));
dotsWrap.appendChild(dot);
});
let index = 0;
let timer = null;
function schedule(delay) {
stop();
timer = setInterval(() => nextSlide(false), delay);
}
function isVideoAt(i) {
const h = HIGHLIGHTS[i];
return h && h.type === 'video';
}
function goto(i, manual) {
function goto(i) {
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
track.style.transform = 'translateX(-' + index * 100 + '%)';
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
d.classList.toggle('active', k === index);
});
// Only the visible video plays; others pause to save bandwidth.
videos.forEach((vid, k) => {
if (vid) {
if (k === index) {
if (vid.paused) {
vid.currentTime = 0;
const p = vid.play();
if (p && p.catch) p.catch(() => {});
}
} else {
vid.pause();
}
}
});
if (isVideoAt(index)) {
// A video slide always advances on its 'ended' event, never on a timer.
stop();
return;
}
// Image slides: manual navigation holds much longer, idle auto-advance restores.
schedule(manual ? MANUAL_MS : AUTO_MS);
}
function nextSlide(manual) { goto(index + 1, !!manual); }
function prevSlide() { goto(index - 1, true); }
function nextSlide() { goto(index + 1); }
function prevSlide() { goto(index - 1); }
function start() { goto(index, false); }
function start() {
stop();
timer = setInterval(nextSlide, 6500);
}
function stop() {
if (timer) { clearInterval(timer); timer = null; }
}
// A video slide advances only once its playback actually finished.
videos.forEach((vid) => {
if (!vid) return;
vid.addEventListener('ended', () => nextSlide(false));
});
if (prev) prev.addEventListener('click', () => { prevSlide(); });
if (next) next.addEventListener('click', () => { nextSlide(true); });
if (prev) prev.addEventListener('click', () => { prevSlide(); start(); });
if (next) next.addEventListener('click', () => { nextSlide(); start(); });
track.addEventListener('mouseenter', stop);
track.addEventListener('mouseleave', start);
goto(0, false);
start();
}
function stripMarkup(text) {
@@ -719,43 +654,6 @@
if (el) el.textContent = new Date().getFullYear();
}
async function loadGithubLanguages() {
const bar = document.getElementById('lang-bar');
const legend = document.getElementById('lang-legend');
if (!bar || !legend) return;
const colors = { Java:'#b07219', Python:'#3572A5', JavaScript:'#f1e05a', TypeScript:'#3178c6', CSS:'#563d7c', HTML:'#e34c26', Go:'#00ADD8', Rust:'#dea584', Shell:'#89e051', Dockerfile:'#384d54' };
const tryFetch = async (url) => {
const r = await fetch(url, {headers:{'Accept':'application/vnd.github.v3+json'}});
if (!r.ok) throw new Error(url);
return r.json();
};
try {
let data = null;
try { data = await tryFetch('https://git.swe.zernmc.ru/api/v1/repos/sasheg/launcher/languages'); } catch(e) {
// Gitea fallback only, GitHub mirror removed
}
if (!data || typeof data !== 'object') return;
const total = Object.values(data).reduce((a,b)=>a+b,0);
if (!total) return;
const entries = Object.entries(data).map(([lang, bytes])=>({lang, bytes, pct: bytes/total*100})).sort((a,b)=>b.bytes-a.bytes).slice(0,6);
// update bar
bar.innerHTML = '';
bar.title = entries.map(e=>`${e.lang} ${e.pct.toFixed(1)}%`).join(' · ');
legend.innerHTML = '';
entries.forEach(e=>{
const seg = document.createElement('div');
seg.style.flex = String(e.pct);
seg.style.background = colors[e.lang] || 'var(--accent)';
seg.title = `${e.lang} ${e.pct.toFixed(1)}%`;
bar.appendChild(seg);
const item = document.createElement('span');
item.style.display='flex'; item.style.alignItems='center'; item.style.gap='6px';
item.innerHTML = `<i style="width:10px;height:10px;border-radius:50%;background:${colors[e.lang]||'var(--accent)'};display:inline-block"></i> ${e.lang} ${e.pct.toFixed(1)}%`;
legend.appendChild(item);
});
} catch(e) { /* keep static fallback */ }
}
document.addEventListener('DOMContentLoaded', () => {
applyI18n();
@@ -767,7 +665,6 @@
loadNews();
loadServerStatus();
setInterval(loadServerStatus, 15000);
loadGithubLanguages();
initCarousel();
initPreview();
initNav();
-4
View File
@@ -1,10 +1,6 @@
TODO / ideas backlog
====================
1.1.1.2 hotfix DONE — WebKit "Reload" + onboarding persist
- Поправлено JFXLauncher.java:512 — document.addEventListener('contextmenu', e=> e.preventDefault(), true) глобально (Reload больше не появляется)
- Onboarding fix: localStorage volatile в WebView → теперь persist в Config (~/.zernmc/launcher.properties onboardedVersion/completedOnboardBlocks) через /api/settings GET/POST, JS sync localStorage<->Config. Прохождение без "Не показывать" теперь не всплывает снова. Версия 1.1.1.2
Java Agent + IPC (low priority, overkill now)
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main