Compare commits

...
Author SHA1 Message Date
layue13andClaude Sonnet 5.5 f176fbd1fd chore: remove the Java runtime that was committed by mistake and ignore /java/
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
A launcher run from the repository root downloads Mojang Java into java/, and it was
picked up by git add -A in e12c7e53f. The files stay in history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:12:22 +08:00
layue13 23811dad7f Merge pull request 'ci: 减少重复构建并加缓存' (#5) from ci/faster-builds into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 0s
2026-11-08 08:30:18 +00:00
layue13andClaude Sonnet 5.5 e792bfc554 ci: try installing xvfb before refreshing the package index
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Failing after 16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:21:40 +08:00
layue13andClaude Sonnet 5.5 d6b4af2d96 ci: one workflow run per change, cancel superseded runs, cache the Gradle distribution
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 0s
Pushes to feature branches built twice (push and pull request) and Check Codes
repeated the compile in a second workflow. Build now runs only on main, tags and
pull requests, cancels older runs of the same ref, folds checkstyle and
checkTranslations into the single Gradle invocation, caches the wrapper download
and skips apt when xvfb is installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:19:50 +08:00
layue13 2a915460ae Merge pull request 'fix: 账号浮层添加账号入口,统一账号页与设置页样式' (#4) from fix/account-add-when-empty into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 7m28s
Java CI / build (push) Failing after 3h0m3s
2026-11-08 08:18:58 +00:00
layue13andClaude Sonnet 5.5 e9b577f66f style: light cards and lists, sans section titles, hairline outlined buttons, quieter sidebar labels
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m7s
Check Codes / build (push) Successful in 5m52s
Check Codes / build (pull_request) Successful in 7m0s
Java CI / build (pull_request) Failing after 3h0m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:08:31 +08:00
layue13andClaude Sonnet 5.5 e12c7e53f6 fix: keep the add-account action in the account popup when there are no accounts
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 2m30s
Check Codes / build (push) Successful in 4m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:50:52 +08:00
layue13 61caf8a925 Merge pull request 'feat: 服务器专用启动器(一键进服、新界面、精简)' (#3) from feat/server-launcher-redesign into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Canceled after 7m7s
Java CI / build (push) Failing after 3h0m4s
2026-11-08 07:31:54 +00:00
layue13andClaude Sonnet 5.5 a7123a389e style: fix checkstyle indentation, unused imports and a Classical Chinese spelling check
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 8m11s
Check Codes / build (pull_request) Successful in 9m17s
Java CI / build (push) Canceled after 53m53s
Java CI / build (pull_request) Canceled after 54m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:03:48 +08:00
layue13andClaude Sonnet 5.5 3a6e80da0e refactor: remove multi-instance list pages and unused wizard pages
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 3m52s
Check Codes / build (pull_request) Failing after 2m45s
Java CI / build (push) Canceled after 1h0m4s
Java CI / build (pull_request) Canceled after 56m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:57:06 +08:00
layue13andClaude Sonnet 5.5 2c8446fa33 refactor: replace the 1190-line theme system with a fixed warm theme; drop theme packs, wallpapers and their tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:56:14 +08:00
layue13andClaude Sonnet 5.5 21fcac1b0c refactor: single settings page; hide inherit-from-global toggles, drop global game settings tab
Check Codes / build (push) Failing after 3m51s
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Canceled after 1h8m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:42:22 +08:00
layue13andClaude Sonnet 5.5 095d71fea7 feat: show server status (players, latency) on the home card via Server List Ping
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:40:43 +08:00
layue13andClaude Sonnet 5.5 07ff244cd2 feat: platform-style window buttons, 21px dialog radius, thin round progress bar, title mark
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:33:44 +08:00
layue13andClaude Sonnet 5.5 0269e54b95 feat: route switcher on home card, subtler transitions, flat press feedback; add ServerEndpoint tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:27:04 +08:00
layue13andClaude Sonnet 5.5 06ef27f0e0 feat: lightweight first-run dialog for picking a game name or signing in
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:22:04 +08:00
layue13andClaude Sonnet 5.5 b3f649c026 feat: account popup above the bottom-left entry with add and manage actions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:21:16 +08:00
layue13andClaude Sonnet 5.5 0531d0cbc2 feat: sidebar with modpack, settings and bottom-left account; server name title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:13:03 +08:00
layue13andClaude Sonnet 5.5 84b6d50421 test: point JFXSwitchTest at the warm palette
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:17 +08:00
layue13andClaude Sonnet 5.5 dfdad64e93 feat: warm cream palette, new home page with line art and join card, 987x610 window
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:07 +08:00
layue13andClaude Sonnet 5.5 323d09b104 feat: bundle Noto Sans SC and Noto Serif SC (OFL) as default launcher fonts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:56:03 +08:00
layue13andClaude Sonnet 5.5 0f85782312 refactor: drop forced authlib account on first run and unofficial-build hint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:50:45 +08:00
layue13andClaude Sonnet 5.5 7b79945391 feat: multiple server routes, ws(s) addresses passed verbatim to --server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:43:00 +08:00
layue13andClaude Sonnet 5.5 441ce7220f feat: one-click join server button using manifest serverAddress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:29:26 +08:00
layue13andClaude Sonnet 5.5 e8df66dcc0 refactor: drop unused UI controls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:28:31 +08:00
layue13andClaude Sonnet 5.5 f7420e284d refactor: remove personalization settings page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:28:12 +08:00
layue13andClaude Sonnet 5.5 a2fa7ed5ab refactor: remove mod, resource pack, world, data pack and installer pages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:27:38 +08:00
浩 刘 3f67b6bda0 fix: store managed Java under the launcher workspace
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 2m48s
Java CI / build (push) Failing after 3h0m2s
Install Mojang/runtime downloads into <working-dir>/java beside
.minecraft instead of the user-global HMCL profile java store.
2026-11-07 20:35:13 +00:00
浩 刘 4cd783d6bb feat: offer Offline and Microsoft auth; keep authlib injector
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 4m59s
Java CI / build (push) Failing after 3h0m0s
Primary account creation entries are Offline and Microsoft. External
Yggdrasil / authlib-injector servers remain listed and addable for later
use. Offline creation is no longer redirected away or region-restricted.
2026-11-07 20:27:29 +00:00
浩 刘 4118030d9c feat: auto-fetch Mojang Java 25; strip April Fools, task list, unused locales
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 3m50s
Java CI / build (push) Failing after 3h0m1s
On first-capable startup, download java-runtime-epsilon via the existing
BMCLAPI-mirrored Mojang path when missing (progress dialog only; no prompt).

Remove launcher April Fools prompts/settings/UI tags, the top-right task
center page/button, dead NBT/April Fools images, and unused locale packs
(ar/de/es/ja/ru/uk) to shrink the distributable.
2026-11-07 20:19:40 +00:00
浩 刘 bcc5cda5c0 style: clear checkstyle leftovers from TDLM fork cuts
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Successful in 4m39s
Java CI / build (push) Failing after 3h0m3s
Remove unused/redundant imports, fix SettingsPage indentation, and
collapse extra blank lines so checkstyle/checkTranslations can pass on
Gitea Actions without reviewdog.
2026-11-07 19:33:20 +00:00
浩 刘 1147892830 ci: drop unmirrored reviewdog and github-script workflows
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 10m17s
Java CI / build (push) Failing after 3h1m7s
Gitea act resolves actions from git.nest.potatolab.uk; reviewdog/action-setup
and actions/github-script 404 there (even for conditional steps). Keep Gradle
checkstyle/checkTranslations only; disable PR comment and size-label flows.
2026-11-07 19:16:41 +00:00
浩 刘 a4fff896ea ci: install xvfb before Gradle build on self-hosted runners
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Failing after 3h0m4s
Self-hosted Gitea Actions runners lack xvfb by default, so xvfb-run
fails with exit 127. Install it in the Java CI workflow before build.
2026-11-07 19:02:04 +00:00
layue13 5e2da98061 Merge pull request 'Remove official groups, multiplayer, downloads, help, and self-update' (#1)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 2m20s
Java CI / build (push) Failing after 4m0s
Merge pull request #1: Remove official groups, multiplayer, downloads, help, and self-update

Server-client HMCL fork cleanup for TDLM.
2026-11-07 18:56:23 +00:00
浩 刘 2b76d44ab0 Strip NBT, schematics, multi-dir, themes, and download source UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 14s
Check Codes / build (pull_request) Failing after 15s
PR Size Label / label (pull_request_target) Failing after 25s
Java CI / build (push) Failing after 2m48s
Java CI / build (pull_request) Failing after 6m9s
Remove NBT editor and schematic management, collapse game directories to the
fixed workspace .minecraft path, drop Countly packaging and theme-pack
marketplace UI, and hide extra download-source selectors for the server client.
2026-11-07 18:53:31 +00:00
浩 刘 cf44b2e536 Prefer Java 25 and multi China download mirrors
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 57s
Check Codes / build (pull_request) Failing after 17s
Java CI / build (push) Failing after 2m27s
PR Size Label / label (pull_request_target) Failing after 23s
Java CI / build (pull_request) Failing after 2m3s
Raise the recommended Java major to 25, default download sources to BMCLAPI
mirrors, probe and rank multiple BMCLAPI entry points, and spread concurrent
file downloads across healthy mirrors with ordered fallback.
2026-11-07 18:49:52 +00:00
浩 刘 895481ce63 Remove dead free-install hub and export UI
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 5s
Check Codes / build (pull_request) Failing after 10s
PR Size Label / label (pull_request_target) Failing after 37s
Java CI / build (push) Failing after 2m30s
Java CI / build (pull_request) Failing after 2m14s
Collapse DownloadPage to addon install helpers only, drop the orphaned
modpack export wizard package, and remove unused upstream Metadata URLs.
2026-11-07 18:42:51 +00:00
浩 刘 1e45711006 Restrict launcher to bundled server modpack installs
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Check Codes / build (push) Failing after 5s
Check Codes / build (pull_request) Failing after 53s
Java CI / build (push) Failing after 2m30s
PR Size Label / label (pull_request_target) Failing after 15s
Java CI / build (pull_request) Failing after 2m34s
Replace free game download with bundled modpack install, drop Help/Feedback
and upstream About links, empty the default authlib list, and remove offline
account creation while keeping Microsoft and custom authlib logins.
2026-11-07 18:40:26 +00:00
浩 刘 f4893d9f75 Remove official groups, multiplayer, downloads, help, and self-update
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
PR Size Label / label (pull_request_target) Failing after 28s
Check Codes / build (pull_request) Failing after 28s
Check Codes / build (push) Failing after 28s
Java CI / build (pull_request) Failing after 4m13s
Java CI / build (push) Failing after 4m14s
Strip Terracotta multiplayer, QQ/Discord community promo, in-launcher
mod/modpack/world download browse flows, the title-bar help button, and
launcher self-update checks/UI so this fork no longer hooks upstream HMCL
update endpoints. Local modpack import/export and resource/shader downloads
remain. Compile and setting unit tests pass.
2026-11-07 18:14:15 +00:00
快乐凤爪王 3e172608bf fix: 避免通过按钮重复刷新模组列表 (#7099) 2026-11-07 21:17:48 +08:00
鱼前三百 f0fcd28bd0 fix(datapack, ui): 删除 DataPackListPageSkin 冗余的选择逻辑 (#7216) 2026-11-07 21:16:51 +08:00
辞庐 5736ea8a24 主页更新气泡添加悬停反馈 (#7215) 2026-11-07 21:07:39 +08:00
3gf8jv4dv b510352154 chore(l10n): refine en translations on instance page toolbar (#7225) 2026-11-07 20:52:19 +08:00
3gf8jv4dv 4efd3a1e7d chore(l10n): refine en translations (#7224) 2026-11-07 20:52:01 +08:00
3gf8jv4dv c892caeba3 chore(l10n): refine zh-hant translations (#7226) 2026-11-07 20:49:58 +08:00
Glavo 3b57add252 Hide horizontal scrollbar in addon updates list (#7235) 2026-11-07 20:38:16 +08:00
Wulian233 e757b6832f 更新游戏版本/种子数据 (#7234) 2026-11-07 20:23:00 +08:00
Glavo a45ccde0a1 Limit addon update cell width in list view (#7230) 2026-11-07 14:13:49 +08:00
鱼前三百 eb14c66e1a feat(ui): 使用 JFXSwitch 改进 DataPackInfoListCell (#7201) 2026-11-06 22:07:05 +08:00
快乐凤爪王 6fa48981d5 feat: 使用 JFXSwitch 改进 ResourcePackListCell (#7200) 2026-11-06 22:05:21 +08:00
鱼前三百 3c89a012b2 refactor(datapack): 将 DataPackListPageSkin 的搜索逻辑修改为和其他列表一致 (#7217) 2026-11-06 21:50:29 +08:00
Glavo a543216e67 Exclude Cleanroom 0.7-alpha and later from available versions (#7218) 2026-11-06 21:47:04 +08:00
辞庐 86b0b855a7 GLFW / SDL -> GLFW/SDL (#6999) 2026-11-06 21:13:47 +08:00
快乐凤爪王 13d5d6b322 fix: 避免在 更换版本 弹窗选择否后关闭版本页面 (#7107) 2026-11-06 21:01:44 +08:00
gunngz cfb0a8ce68 refactor: move legacy checksum validation into Library (#7085) 2026-11-06 21:00:14 +08:00
Wulian233 fe000fb94f 统一模组列表与详情的中文名顺序,并使用模组数据文件中的英文名 (#7070)
Close #7055
2026-11-06 20:50:34 +08:00
快乐凤爪王 29acd01155 fix: 调整 ModInfoListCell 开关的大小与间距 (#7182) 2026-11-06 20:30:50 +08:00
ZZZank 7cf696e2a0 在“回退”(切换版本)按钮不可见时释放其空间 (#7199) 2026-11-06 20:11:33 +08:00
鱼前三百 09ccf73285 feat: 为游戏文件夹添加打开文件夹按钮 (#7198)
fix #7197
2026-11-06 20:07:32 +08:00
鱼前三百 7403c3ff79 fix(modpack-export): 将选择导出位置的时机移动到导出任务开始之前 (#7208)
fix #7207
2026-11-05 21:49:11 +08:00
鱼前三百 b46a07cd45 feat(modpack-export): 为整合包导出添加进度显示 (#7206)
fix #7205
2026-11-05 21:04:36 +08:00
Glavo a262a95408 Refactor tab animation handling for consistency and clarity (#7210) 2026-11-05 20:58:08 +08:00
Glavo 88222af99a Refactor animation methods to simplify signatures (#7203) 2026-11-05 17:53:05 +08:00
Glavo f1d90fe0a1 预热 GameListPage (#7204) 2026-11-05 16:49:51 +08:00
Glavo 52c84ceb29 Optimize caching and layout management for smoother animations (#7195) 2026-11-05 05:23:17 +08:00
Glavo 087b2b678d 修复无法正常启动 Minecraft 13w16a~13w23b 的问题 (#7193) 2026-11-04 22:23:24 +08:00
Glavo 3f6e7fcffd 在深色模式下默认启用原生窗口装饰 (#7192) 2026-11-04 22:12:58 +08:00
快乐凤爪王 ed59611772 feat: 为世界管理页面添加打开存档文件夹按钮 (#7177)
Closes #7175
2026-11-04 20:55:23 +08:00
Wulian233 c100f46b9d 修复悬停在iconed-menu上时鼠标指针错误 (#7178)
Fix #7179
2026-11-04 20:34:50 +08:00
快乐凤爪王 00b1b34f98 chore: tasksEmptyIcon 使用图标 ListAlt (#7185) 2026-11-04 20:30:15 +08:00
Glavo f411840199 在 updateControl 期间禁用 JFXSwitch 动画 (#7189) 2026-11-04 20:24:57 +08:00
Glavo 8d44411302 在关于页面展示机型信息 (#7186) 2026-11-04 16:23:13 +08:00
ShulkerSakura 85540268bd 修复任务列表按钮可重复打开多个任务列表的问题 (#7180) 2026-11-04 00:27:32 +08:00
Glavo e64a7b45b5 重新实现平滑滚动 (#7162) 2026-11-03 21:57:16 +08:00
Glavo 7d89e87dca 支持将任务挂起至后台 (#7030) 2026-11-03 21:46:12 +08:00
快乐凤爪王 8f002e433f fix: 实例列表禁用横向滚动条 (#7176) 2026-11-03 20:58:39 +08:00
快乐凤爪王 a639fbb1c8 feat: 使用 JFXSwitch 改进 ModInfoListCell (#7173) 2026-11-03 20:57:54 +08:00
Glavo 70e6b6ade7 改进硬件探测功能与关于页面 (#7174) 2026-11-03 18:45:01 +08:00
Glavo d3b91d439c 根据 Tab 相对位置选择动画方向 (#7172) 2026-11-03 11:10:45 +08:00
Glavo 8912ee2661 在按压 RipplerContainer 时使用控件变色代替涟漪效果 (#7171) 2026-11-03 11:01:43 +08:00
Glavo 5dd6ab5908 使用 JFXSwitch 代替 JFXToggleButton (#7167) 2026-11-03 06:08:15 +08:00
鱼前三百 57eca838e2 refactor(addon): 为 LocalAddonFile 默认实现 Comparable (#7120)
fix #7161
2026-11-02 22:27:55 +08:00
Glavo 75d070d8c9 Enhance instance manifest handling and add failed task creation method (#7163) 2026-11-02 21:27:50 +08:00
Glavo f81ba320da 在播放 UI 动画前预渲染页面 (#7160) 2026-11-02 20:37:59 +08:00
Glavo 8047775bf8 feat: add support for virtual GPU type in AboutPage (#7158) 2026-11-01 21:54:37 +08:00
Glavo 740d3c5d80 重构 TwoLineListItem (#7153) 2026-11-01 21:48:48 +08:00
Glavo 967c326219 允许修改没有 patches 的原版实例 (#7145) 2026-11-01 21:29:48 +08:00
快乐凤爪王 fdc3500cc4 style: 为 checkstyle 添加了 RegexpSingleline 规则 (#7156) 2026-11-01 21:25:13 +08:00
Glavo 2f17995955 更新关于页面“桌面环境”条目的图标 (#7157) 2026-11-01 21:22:43 +08:00
Glavo ef311560fd Enhance SHA-1 table and update task for version detection (#7155) 2026-11-01 21:10:33 +08:00
Glavo 440b42a8b7 优化关于页面运行环境信息的排版 (#7151) 2026-11-01 15:55:40 +08:00
Glavo 8b706466e7 feat: log process PID in Launcher (#7152) 2026-11-01 15:48:28 +08:00
快乐凤爪王 8e16127845 feat: 改进 GameListCell (#7144) 2026-10-31 21:56:29 +08:00
Glavo bdbf5773a8 调整自动内存分配算法 (#7134) 2026-10-31 13:59:32 +08:00
Glavo 1ca146c615 Bump mesa-loader-windows to 26.2.6 (#7132) 2026-10-30 21:28:12 +08:00
Glavo de8ae88ee4 改进 AVF 虚拟机内的硬件检测逻辑 (#7130) 2026-10-30 17:57:27 +08:00
Glavo ba62160c76 改进 GPU 类型推断逻辑 (#7124) 2026-10-30 02:05:12 +08:00
快乐凤爪王 8e7f36c681 fix: MicrosoftAccountLoginPane 在缺失 Client ID 时禁用二维码登录 (#7119) 2026-10-29 22:22:07 +08:00
快乐凤爪王 b08f3869cf fix: 关于页面的版权条目使用 LinePane (#7122) 2026-10-29 22:01:42 +08:00
Glavo f05647fc82 支持在使用 Panthor 驱动的环境探测 Mali GPU (#7115) 2026-10-29 16:01:59 +08:00
Glavo 67e9826309 改进 CPU 拓扑探测能力 (#7113) 2026-10-29 15:18:38 +08:00
Glavo f1c9b77df5 Refactor SystemInfo and improve hardware info caching and documentation (#7112) 2026-10-29 03:09:30 +08:00
Glavo c3abfcf1d9 更新 amdgpu.ids (#7111) 2026-10-29 02:37:36 +08:00
Glavo 6632a91426 优化 GPU 信息显示逻辑 (#7109) 2026-10-28 21:55:33 +08:00
Glavo ac378ad46a 预热设置页面时预加载游戏预设 (#7105) 2026-10-28 03:38:08 +08:00
Glavo 6bbb1d157c 简化 TabItem (#7106) 2026-10-28 03:19:15 +08:00
Glavo efa1012a1c 重构 TabHeader (#7104) 2026-10-28 02:26:08 +08:00
Glavo 5058c68be8 优化窗口动画性能 (#7103) 2026-10-28 00:52:29 +08:00
鱼前三百 3612f04657 docs: 修复 withRunAsync 文档错误 (#7095) 2026-10-27 22:25:55 +08:00
快乐凤爪王 a2d3bb3b42 chore: 抑制 Decorator 中未经检查的类型转换警告 (#7098) 2026-10-27 21:50:33 +08:00
Glavo d3efc81332 为侧边栏菜单项背景色添加渐变动画 (#7101) 2026-10-27 21:22:47 +08:00
Glavo 6b1799838f 重构 RipplerContainer (#7096) 2026-10-27 20:14:05 +08:00
Glavo 5ad4880182 支持使用 CurseForge CDN 访问 NeoForge 相关文件 (#7092) 2026-10-26 23:05:08 +08:00
Glavo a3781141fe 由 TransitionPane 提供节点预热能力 (#7090) 2026-10-26 21:30:43 +08:00
Glavo ed168e14d6 清理 Controllers (#7082) 2026-10-26 21:11:06 +08:00
Glavo ee81d7cc66 使用 SPDX 文件头声明版权与开源协议 (#7091) 2026-10-26 20:15:54 +08:00
Glavo 299077d1cb 恢复使用页面预热 (#7081) 2026-10-26 02:09:11 +08:00
nekoside 7448ea90b2 fix: handle odd-length UTF-16LE GPU registry values on Windows (#7080) 2026-10-26 02:00:05 +08:00
AlexYang ac578c044f chore: update avatar for yushijinhun and update a URL (#7078) 2026-10-25 21:34:53 +08:00
快乐凤爪王 fc68029adc feat: 帮助页面添加异常提示及刷新功能 (#7047) 2026-10-25 21:32:51 +08:00
Glavo f6a92c1806 过滤模组中文名中的 Emoji (#7077) 2026-10-25 20:55:54 +08:00
Glavo 58eb452e90 在播放 UI 动画前应用 UI 布局 (#7076) 2026-10-25 19:17:39 +08:00
Wulian233 fb8a96a170 优化账户页面「上传/编辑皮肤」按钮文案 (#7050)
Close #3633
2026-10-25 19:02:55 +08:00
快乐凤爪王 ced441a830 refactor: 提取 DownloadListCell (#7075) 2026-10-25 17:30:14 +08:00
Glavo c5693c3450 清理未使用的代码与资源 (#7074) 2026-10-25 16:59:13 +08:00
鱼前三百 bc27eda220 chore(event): 移除过时且远古的进程退出事件,并将下载速度事件与全局 EventBus 解耦 (#7063) 2026-10-25 16:40:47 +08:00
Matt Van Horn 0c40dcdeed fix: honor declared modern Java requirements on legacy Linux instances (#7073) 2026-10-25 16:39:11 +08:00
Glavo 4282147042 优化设备信息的检测与显示逻辑 (#7071) 2026-10-25 16:36:49 +08:00
Glavo 0874c98d21 Fix TLS security tests on JDKs that block compromised certificates (#7065) 2026-10-25 11:42:41 +08:00
ToobLac 0c3c7dd62a chore: Bump commonmark to 0.31.0 & Update deps.json (#7056) 2026-10-25 03:04:20 +08:00
Glavo f663e5568c 在关于页面显示用户环境信息 (#7040) 2026-10-24 21:32:25 +08:00
ToobLac b8c1738f96 perf(ui): MicrosoftAccountLoginPane 在缺失 client id 时直接禁用登录按钮并显示错误提示 (#7054) 2026-10-24 19:38:51 +08:00
Glavo 41c61645e7 Detect instance manifests among multiple JSON files (#7048) 2026-10-24 18:31:52 +08:00
ToobLac 9fc697751c chore(git): 补充 schematic git attr (#7053) 2026-10-24 18:21:30 +08:00
Glavo a4db0532b9 在 default_script.csv 和 sublanguages.csv 中记录数据来源 (#7049) 2026-10-24 14:14:24 +08:00
Minecraft269andMinecraft269 8954139b5e docs: update contributor count from 130 to 140 (#7043)
Co-authored-by: Minecraft269 <Minecraft269@users.noreply.github.com>
2026-10-24 13:52:30 +08:00
Glavo b2f7e724e4 使用 IANA 时区地区映射表推断设备地区 (#7046) 2026-10-24 12:38:08 +08:00
Glavo 0d20910ea3 Block compromised eMudhra CA keys in application TLS validation (#7033) 2026-10-22 21:27:52 +08:00
Glavo 5c90299073 移除下载页面提示 (#7038) 2026-10-22 21:04:39 +08:00
Glavo fc66cbcf96 更新模组数据 (2026-10-22) (#7032) 2026-10-22 02:15:14 +08:00
Glavo e7b9a95df0 为非官方构建添加醒目标识 (#7028) 2026-10-21 21:24:41 +08:00
ToobLac acc9740fa9 perf(addon): 完善 AddonUpdatesPage (#7003) 2026-10-20 21:27:59 +08:00
鱼前三百 5789f82617 fix: 在重新检查更新的时候提示正在检查更新 (#7015)
fix: #7014
2026-10-20 21:25:24 +08:00
Glavo 8827fd06d8 优化地区检测逻辑 (#7020) 2026-10-20 18:06:54 +08:00
Glavo 583e004d71 在中国大陆地区禁用游戏下载功能 (#7017) 2026-10-20 16:37:22 +08:00
Glavo 63207affc6 更新配置文件 Json Schema (#7012) 2026-10-19 20:34:27 +08:00
Glavo 8b147bf20a Windows 平台自动使用 javaw.exe 启动游戏 (#7009) 2026-10-19 20:21:15 +08:00
鱼前三百 15754119d5 fix: 修复原生窗口透明标题栏重复绘制背景 (#7010)
fix #6959
2026-10-19 20:08:39 +08:00
快乐凤爪王 8119324a92 fix: JavaFX Alert 失败时回退到 Swing (#6918) 2026-10-18 21:15:34 +08:00
辞庐 c0ad7764b0 更新用户未购买 Minecraft 时的提示文本 (#6998) 2026-10-18 20:55:43 +08:00
辞庐 3ea054ae31 将 JFXSpinner 改为顺时针旋转 (#7000) 2026-10-18 20:52:20 +08:00
快乐凤爪王 d03ad38e24 fix: ModpackInfoPage 文件选择器关闭后不清空页面栈 (#6992) 2026-10-17 20:38:35 +08:00
快乐凤爪王 93eaee317c feat: 参考 VersionsPage 样式改进 AddonUpdatesPage (#6970) 2026-10-16 21:51:41 +08:00
ToobLac 68b3a9f076 enhance(i18n): 优化拖拽以安装本地整合包的文字提示 (#6893)
Fixes #6891
2026-10-16 21:47:09 +08:00
鱼前三百 c943e4c74f fix: 在模组/资源包文件操作失败时提示 (#6922)
fix #4314, fix #6396
2026-10-16 21:44:50 +08:00
Minecraft269 4dba96e635 chore: 在 Gitee 与 CNB Issue 模板中添加 GitHub Issue 入口 (#6967) 2026-10-16 21:19:32 +08:00
Glavo e521a1b91a Fix #6969: 修复第二次打开游戏下载页面时页面空白的问题 (#6988) 2026-10-16 21:16:18 +08:00
gunngz a7e0fe7499 Fix indeterminate checkbox rendering in modpack export wizard (#6989)
Fixes #6929
2026-10-16 21:15:44 +08:00
鱼前三百andGlavo 4e2d5cac9e chore: 转换 TaskCancellationAction 为函数式接口 (#6991)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-16 20:49:23 +08:00
ToobLac 452d030817 refactor(addon): Make LocalAddonFile implement Comparable (#6984) 2026-10-15 20:33:13 +08:00
ToobLac da9ec2c1e1 chore: sync translations (#6983) 2026-10-15 20:31:07 +08:00
Glavo 4ebbb400ab 重构 ModTranslations (#6987) 2026-10-15 20:29:12 +08:00
Glavo 0cfc2471a0 使用 BellSoft Full JDK 运行 GitHub Action (#6978) 2026-10-13 21:13:42 +08:00
Glavo 413a7291ec 修复 DateRangeDialog 调用 JavaFX 21 API 的问题 (#6979) 2026-10-13 20:55:15 +08:00
鱼前三百 e4893a6df9 feat: 缓存 MicrosoftAccount 的 CompleteGameProfile (#6975)
Fix #5055
2026-10-13 20:16:44 +08:00
ToobLac bb16a0b6c0 fix(addon): AddonUpdatePage 显示测试版本复选框无效 (#6976)
Fixes #6974
2026-10-13 20:16:03 +08:00
5768cd446e feat(instance): 截图管理 (#6096)
Resolves #5535

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-11 21:47:13 +08:00
ToobLac 6f406cede8 enhance(addon): addon更新功能优化 (#6531)
Fixes #6940 Fixes #5865 Fixes #3841 Fixes #3705
2026-10-11 21:11:06 +08:00
快乐凤爪王 0597282463 refactor: 调整 实例管理.浏览 列表布局 (#6914) 2026-10-11 20:33:08 +08:00
ToobLac f91fbe29b4 enhance(remote-addon): 合并冗余的依赖类型 & 下载整合包时推荐版本 (#6968) 2026-10-11 20:10:08 +08:00
快乐凤爪王 cccff7effd feat: 为 Java 管理 页面添加了为空提示 (#6961) 2026-10-11 20:09:06 +08:00
Glavo 101257cae8 总是启用 zip 编码探测 (#6964) 2026-10-11 20:06:03 +08:00
快乐凤爪王 a6e74e7c19 fix: 管理已禁用的 Java 页面内容为空时返回上一页 (#6960) 2026-10-10 21:33:21 +08:00
Glavo 615ebe0311 修复安装任务对话框中部分任务分类错误的问题 (#6962) 2026-10-10 21:23:22 +08:00
Wulian233 8b3468e2d9 无更新日志时提示暂未上传更新日志 (#6957) 2026-10-09 21:49:30 +08:00
辞庐andGlavo 7f1a812373 安装整合包时 整合包名称自动去除冒号 (#6911)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-09 21:44:12 +08:00
ShulkerSakuraandGlavo 22823151c3 在日志中记录当前进程是否由管理员账户运行 (#4393)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-10-09 21:10:35 +08:00
Glavo c0844834cb 修复安装 NeoForge 时会有两条进度条的问题 (#6958) 2026-10-09 20:25:16 +08:00
快乐凤爪王 7921c7abb5 refactor: 在 JavaDownloadDialog 中将 版本 与 包类型 的 ComboBox 监听 distributionBox (#6889) 2026-10-08 21:08:58 +08:00
辞庐 c610519f87 优化整合包导出页面排序 & 描述 (#6895) 2026-10-08 21:07:24 +08:00
庐庐绫 bb1949f919 feat: 为 JFXComboBoxListViewSkin 添加平滑滚动效果 (#6920) 2026-10-08 21:02:09 +08:00
Zkitefly a1e946bba6 fix(csv): 修复 CSV 转义引号和换行的格式错误 (#6931) 2026-10-08 20:51:58 +08:00
辞庐 c1bf66b972 清理冗余 Liteloader 代码 (#6952) 2026-10-08 20:32:48 +08:00
ToobLac 25952aa514 chores: 转换 LocalAddonFile$Description 为记录类 (#6956) 2026-10-08 20:26:18 +08:00
辞庐 54d8d877be 仅在打包为 EXE 时设置 AppUserModelID (#6955) 2026-10-08 20:24:14 +08:00
ToobLac 662a82f21f refactor(addon): 单例化 RemoteAddonRepository (#6900) 2026-10-05 13:08:42 +08:00
Glavo 3f0fcc1382 Separate window bounds tracking from minimum size configuration (#6905) 2026-10-05 10:57:21 +08:00
gunngz 258556b22c Fix #6600: 为自定义 LWJGL 本地库路径添加目录选择按钮 (#6887) 2026-10-05 10:20:05 +08:00
辞庐 d7e3cc4473 fix: 修改游戏目录名称后不会刷新 (#6904) 2026-10-05 10:09:28 +08:00
Glavo 93d4489b94 更新模组数据 (2026-10-04) (#6941) 2026-10-05 10:09:03 +08:00
Apostrophe 77eee17d36 更新README中contributor的人数至“130人以上”,并提高图片中的数量上限 (#6921) 2026-09-30 20:35:22 +08:00
辞庐 4118fa9729 添加实例文件夹后自动选中 (#6912) 2026-09-30 20:31:38 +08:00
Glavo 83ffc91a88 重构 DownloadProvider (#6919) 2026-09-30 20:26:28 +08:00
neveler fdf883d09d 更新 Chunkbase 种子地图 (#6892) 2026-09-30 20:25:24 +08:00
Glavo 342bcc5438 Bump Gradle to 9.8.0 (#6917) 2026-09-30 20:24:09 +08:00
Glavo 59bcc7fe62 在 JavaFX27+ 环境中使用原生窗口装饰 (#6885) 2026-09-22 20:21:30 +08:00
Glavo 77b42b6f99 使用 WebURL 代替 URI (#6883) 2026-09-20 20:38:59 +08:00
辞庐 2f5594d8c1 Cleanroom 环境移除 icu4j-core-mojang 依赖 (#6886) 2026-09-17 21:57:09 +08:00
13ad50208f [Enhancement] 原理图管理优化 (#6094)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
2026-09-17 21:15:11 +08:00
Glavo 13910c24b7 Add version entry for Minecraft 26.3-rc-3 in versions.txt (#6877) 2026-09-16 21:41:56 +08:00
Glavo cc9bdb62ca Add support for Minecraft 26.3-snapshot-10+ to unofficial supported platforms (#6870) 2026-09-16 20:50:02 +08:00
Glavo baa7c816b7 将翻译 Task 名称的逻辑移动至 I18n 中 (#6868) 2026-09-15 21:55:44 +08:00
Apostrophe 090f0b9822 修复编辑实例文件夹时不修改名称就无法保存的问题 (#6866) 2026-09-15 20:30:31 +08:00
CaveNightingale 6d9ae24912 添加安装阶段对整合包可选文件的支持 (#1771) 2026-09-14 21:51:49 +08:00
Ivan Epikhin d03851b196 i18n: update Russian translation (#6859) 2026-09-14 21:13:22 +08:00
Glavo 97cf9dcf16 Refactor native library checks for improved clarity and efficiency (#6857) 2026-09-12 20:39:34 +08:00
Glavo 7bacc2bb5a Fix #6842: Include tags in TwoLineListItem preferred width (#6844) 2026-09-12 20:20:02 +08:00
Wulian233 c7637079d7 Fix #6851: 修复右键部分选项弹出的弹窗会无限叠加 (#6854)
Close #6851
2026-09-12 20:16:18 +08:00
Glavo 8f3282e9a2 Add version entries for 26.3 snapshots and release candidates (#6849) 2026-09-12 20:13:27 +08:00
Glavo 8e491862b9 对于简体中文资源使用 zh-Hans 语言标签 (#6853) 2026-09-12 14:12:24 +08:00
Glavo e0c276075c 重命名 I18N_zh.properties 和 I18N_zh_CN.properties (#6852) 2026-09-12 13:35:16 +08:00
Glavo 19ea64e14b 同步更新所有语言文件 (#6850) 2026-09-12 10:58:09 +08:00
ToobLac 5b26b8f4eb enhance(macos): 日志、游戏崩溃、HMCL崩溃窗口按下 CMD+W 时退出 (#6813) 2026-09-11 21:37:25 +08:00
NWMA-FYWFandGlavo ed7ef92ad9 feat: 添加实例列表右键编辑功能 (#6000) (#6038)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-11 20:59:44 +08:00
ToobLac cd95891108 [Enhancement] 启动器检查更新失败时显示提示 (#6286) 2026-09-11 20:24:13 +08:00
Burning_TNT df52bc6e81 不再支持导出 MultiMC 整合包 (#4017) 2026-09-10 21:31:27 +08:00
lcxn123 9d9fc78384 fix #6202: Validate Yggdrasil access tokens on every login (#6209) 2026-09-10 21:28:21 +08:00
bluebulletandGlavo e287f672fd Feat/curse retry and cache (#6595)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-10 21:02:07 +08:00
Glavo 24702dc5a0 Fix #6833: Automatically synchronize client JARs when updating game instances (#6843) 2026-09-10 10:14:11 +08:00
ToobLac 886a25a935 [Enhancement] 延后加载并缓存资源包图标 (#6730) 2026-09-09 21:00:08 +08:00
ToobLac 450504ceda [Bugfix] 修复 #6526: macOS 下 主题模式-跟随系统设置 失效 (#6555)
Fixes #6526
2026-09-09 20:58:39 +08:00
ToobLac 8767cc0e98 [Bugfix] 修复 CurseForge 搜索部分内容正确结果很靠下的问题 & 移除没有实际意义的搜索排序方式 (#6619) 2026-09-09 20:06:12 +08:00
ToobLacandGlavo 7331f619cf [Bugfix] 修复未受支持的模组加载器标签在模组版本界面不显示 (#6289)
Fixes #6280

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 21:57:53 +08:00
ZZZank 7a8f931641 Fix display of mod with extra long display name (#6436) 2026-09-08 21:57:32 +08:00
辞庐 ce51dc4dd7 在修改游戏版本时清除已选的模组加载器 (#6475) 2026-09-08 21:57:16 +08:00
Glavo b97e7fbd55 Move modpack file controls into reusable tree cells (#6840) 2026-09-08 20:46:21 +08:00
辞庐andGlavo 7f578be08e 优化实例图标设置操作按钮 (#6816)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-08 20:33:08 +08:00
Glavo 513f53bc06 修复无法启动安装了 Forge 的 Minecraft 1.6.4 实例的问题 (#6839) 2026-09-08 20:18:25 +08:00
Glavo b2d4d36856 Implement task cleanup and abort functionality in DefaultGameBuilder (#6836)
Fix #6773
2026-09-08 16:39:06 +08:00
Glavo 9c031c5ac1 优化 ArchiveFileTree 构建性能 (#6838) 2026-09-08 16:37:31 +08:00
Glavo e2d3ac847d 打开 zip 文件时忽略 local file header (#6834) 2026-09-08 12:02:17 +08:00
Glavo 0f4152406c Bump kala-compress to 1.27.1-5 (#6837) 2026-09-08 12:02:00 +08:00
Glavo 1a258a2554 Fix #6828: 隐藏父实例缺失的实例 (#6832) 2026-09-07 21:15:16 +08:00
Glavo 3742be0aa8 Add *.jfr to .gitignore (#6835) 2026-09-07 15:54:23 +08:00
辞庐 0e34554344 转化 OptiFineVersion 为 Record (#6821) 2026-09-06 20:20:12 +08:00
Wulian233 1acfc55004 转化 CurseMetaMod 为 Record (#6822) 2026-09-06 20:18:54 +08:00
辞庐 1428183e36 简写部分 Hello Minecraft! Launcher 为 HMCL (#6826) 2026-09-06 20:16:34 +08:00
Wulian233 cbc6daf3d5 Fix #6819: 实例管理工具栏文字变小 (#6823)
Close #6819
2026-09-06 20:16:21 +08:00
辞庐 0ad180c082 fix: 无法启动新版本 Cleanroom (#6824) 2026-09-06 20:14:39 +08:00
Xirren 1da8c8d6ac 修复导出mrpack时,已禁用模组有关bug (#6830) 2026-09-06 20:14:06 +08:00
辞庐 ea232b0976 清理 DialogPane 冗余 progressBar (#6827) 2026-09-06 20:10:45 +08:00
辞庐 b4549b6f68 OAuthserver 仅在 localhost 监听端口 (#6811) 2026-09-05 20:14:46 +08:00
辞庐 4cf5dbdf66 fix: 两种方式打开的游戏选择 Popup 文字大小不一样大 (#6812)
resolves #6697
2026-09-05 20:05:16 +08:00
Glavo b179c44bec Bump kala-compress to 1.27.1-4 (#6809) 2026-09-05 00:08:32 +08:00
Wulian233 7dfc654d5d Fix #6398: Java列表无法刷新版本信息 (#6808)
Close #6398
2026-09-04 21:03:49 +08:00
ToobLac a4b930ded5 [Bugfix] 修复下载附加内容时依赖列表因文件缺失加载失败/类别标题不居中的问题 (#6532) 2026-09-04 20:51:10 +08:00
辞庐andGlavo 00cd20a6e9 简化 DefaultLauncher#getEnvVars (#6197)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-09-03 21:56:15 +08:00
Glavo 9ebf29d060 修复 1.4.7 安装 Legacy Fabric 后无法正常启动的问题 (#6807) 2026-09-03 21:16:07 +08:00
辞庐 d7d5e99185 fix: 自动安装已安装的项目会显示两份 (#6746) 2026-09-03 20:32:25 +08:00
陈梦泽 5e9ed84605 修复 GameDirectoryPage 缺失的 MessageDialogPane import (#6805) 2026-09-03 20:20:00 +08:00
ToobLac 2e01874e95 fix(ripple): 修复列表第一次点击时不显示涟漪的问题以及滚动时仍然保持涟漪的问题 (#6709)
Fixes #6703
2026-09-02 23:36:10 +08:00
辞庐 efb475879b 在选择磁盘根目录作为游戏目录时弹出警告 (#6203) 2026-09-02 21:57:19 +08:00
ToobLac cb69387151 [Chores] 移动 ModListPageSkin 到 ModListPage 内部 (#6729) 2026-09-02 20:29:55 +08:00
Glavo 70104cb9e5 重构游戏管理功能 (#6787) 2026-09-02 20:20:22 +08:00
辞庐 931fbf5181 更新 launch.advice.lwjgl_3_4_1 文案 (#6758) 2026-09-01 21:03:29 +08:00
辞庐 1d5ecb9236 使用相同逻辑提取验证服务器展示域名 (#6762) 2026-08-31 21:25:35 +08:00
辞庐 b65104e38b 优化修改实例游戏版本页标题 (#6778) 2026-08-31 21:25:25 +08:00
辞庐 8629a2bbe3 fix: 实例列表中新快照图标错误 (#6774) 2026-08-31 21:22:47 +08:00
Glavo 3142b2f894 Bump commonmark to 0.30.0 (#6793) 2026-08-31 21:20:56 +08:00
辞庐 b1759acb62 fix: 初次进入新增游戏目录页面不会自动填充 .minecraft 上级目录名称 (#6764) 2026-08-31 21:20:44 +08:00
Glavo 1363f0e70d Bump shadow plugin to 9.6.1 (#6795) 2026-08-31 21:19:26 +08:00
辞庐 45c4913b82 清理 PropertyUtils (#6763) 2026-08-31 21:15:22 +08:00
辞庐 0d47ff95e1 优化微软登录扫描二维码界面布局 (#6767) 2026-08-31 21:12:49 +08:00
Glavo 4064c5c188 Bump JUnit to 6.1.3 (#6794) 2026-08-31 21:11:18 +08:00
Glavo 56a2f75c8b Bump jsoup to 1.23.2 (#6792) 2026-08-31 21:01:46 +08:00
Glavo c4ebdeb77e Bump Gradle to 9.7.1 (#6791) 2026-08-31 21:01:22 +08:00
Glavo 13b87fff89 Bump fxsvgimage to 1.9.1 (#6790) 2026-08-31 20:52:30 +08:00
Glavo 50072799d0 Fix #6788: 修复缩放 SVG 图片时会加载 JFXPanel 类的问题 (#6789) 2026-08-31 20:48:03 +08:00
辞庐 16d45890ad 点击主页“下载”按钮时保留先前打开页面 (#6775) 2026-08-31 20:26:14 +08:00
辞庐 e0dacd0265 优化 LineSelectButton popup 副标题配色 (#6771) 2026-08-30 22:05:56 +08:00
辞庐 72071a41c9 清理 GameException (#6770) 2026-08-30 22:04:05 +08:00
辞庐 c86c19a7f9 fix: 更改模组加载器版本时下载依赖项任务未正确归类 (#6777) 2026-08-30 22:01:22 +08:00
辞庐 5475acdc9e 清理 MojangJavaRemoteVersion & MojangJavaDistribution (#6772) 2026-08-30 21:57:41 +08:00
辞庐 6daeeb0b04 清理 JsonMap (#6776) 2026-08-30 21:54:47 +08:00
辞庐 1a4ccd355d fix: 全局实例设置页面可能缺少 ”Vulkan 渲染器/驱动“选项 (#6779) 2026-08-30 21:21:01 +08:00
辞庐 204a08342d fix: LegacyFabric 实例图标异常 (#6784) 2026-08-30 21:20:02 +08:00
辞庐 ced06cb48c 清理 AuthlibInjectorDownloader (#6765) 2026-08-28 22:02:22 +08:00
辞庐 80b3883d4f 修复更换游戏组件版本对话框消息文本异常的问题 (#6757) 2026-08-28 22:01:47 +08:00
辞庐 60c7ea5a01 清理 MappedProperty (#6761) 2026-08-28 21:59:03 +08:00
辞庐 3323c6a50a 修复选择组件版本页面标题异常的问题 (#6753) 2026-08-28 21:57:48 +08:00
olivezaneandGlavo 7530f08ab1 修复 KDE Wayland 下 HMCL 不跟随缩放的问题 (#6736)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-28 21:46:26 +08:00
辞庐andGlavo 15a983de28 优化验证服务器选择样式 (#6704)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-27 21:37:15 +08:00
辞庐 5c0d4a7c7b 清理 Lang (#6707) 2026-08-27 20:49:26 +08:00
辞庐 1532247f2a feat: 支持使用系统 SDL (#6696)
closes #6452
2026-08-26 21:47:27 +08:00
Glavo 587e92543a 更新 PLATFORM.md (#6740) 2026-08-25 21:53:38 +08:00
ToobLac f0527e12b4 [Enhancement] 清理游戏实例时删除版本目录根部所有 log 文件 (#6732) 2026-08-23 20:17:34 +08:00
ToobLac b0ac4fb5d0 [Enhancement] 优先按照游戏版本排序实例 (#6731)
Fixes #4679
2026-08-23 20:17:14 +08:00
Glavo 083dbb18ad 修复安装新实例时未将 patch 合并到主 manifest 中的问题 (#6718) 2026-08-21 21:42:08 +08:00
Glavo c9585198e1 主动修剪堆内存 (#6715) 2026-08-21 21:38:41 +08:00
Glavo 0fd83e5177 修复在使用相对路径的 DefaultGameRepository 中安装实例失败的问题 (#6716) 2026-08-21 11:26:10 +08:00
辞庐 5a017ecab6 修复可通过本地安装同时安装 Cleanroom 与 Forge 的问题 (#6496) 2026-08-20 21:52:05 +08:00
辞庐 da77f52492 迁移 ModpackHelper#findMinecraftDirectoryInManuallyCreatedModpack 到 kala-compress (#6465) 2026-08-20 21:27:42 +08:00
Glavo dccfa7c9a9 将文件末尾的 CRLF 替换为 LF (#6713) 2026-08-20 21:03:12 +08:00
neveler f7bf2d468d 将 PR Checkstyle 报告同步至 Pull Request 审查评论 (#6692) 2026-08-20 20:54:32 +08:00
ToobLac a10c501d4f [Bugfix] 修复无法读取 zip 后缀名的模组的问题 (#6712) 2026-08-20 20:48:45 +08:00
Glavo 116629743e 重构 GameRepository (#6580) 2026-08-20 20:41:56 +08:00
辞庐andGlavo cfee31d339 清理未使用 i18n 键值 (#6688)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-20 20:41:33 +08:00
辞庐 3ac08d7f62 清理 FXUtils (#6706) 2026-08-19 20:29:12 +08:00
3gf8jv4dv de643becf8 fix: update modrinth category l10n for cyrillic to match upstream (#6695) 2026-08-19 20:28:41 +08:00
辞庐 edbcc1f343 优化无验证服务器提示 (#6705) 2026-08-19 19:27:06 +08:00
辞庐 91fcc4e5ef 清理未使用样式 (#6698) 2026-08-17 20:33:57 +08:00
3gf8jv4dv 34aeba1b1a fix: update modrinth category l10n for en and zh to match upstream (#6671) 2026-08-16 21:05:18 +08:00
辞庐 9a36b86cfb 简化 DownloadPage#download (#6691) 2026-08-16 21:03:26 +08:00
ApostropheandGlavo 71b4c02474 解决导出 Modrinth 整合包因大文件导致的 Out Of Memory (#6631)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:55:48 +08:00
辞庐andGlavo 45f49d079b 转化 GameVersion 为 record (#6663)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-14 21:47:11 +08:00
BANER 71d9daed16 i18n: add complete German translation (I18N_de.properties) (#6677) 2026-08-14 20:41:22 +08:00
ToobLac 5ae019ba17 fix(modloader): 修复 #6628 Cleanroom 和 LiteLoader 实例不读取子文件夹里的模组 (#6643)
Fixes #6628
2026-08-14 20:25:03 +08:00
辞庐andGlavo 770c58c2e7 优化投影管理文件夹已存在提示 (#6664)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:52:53 +08:00
ToobLac a03c11be42 fix(macos): 修复 macOS 上打开对话框后按 CMD+Q 无法退出的问题 (#6656) 2026-08-13 21:40:16 +08:00
辞庐 ca3746319f 移除 “操作被取消” toast (#6658) 2026-08-13 21:39:52 +08:00
辞庐andGlavo 56629892f8 将 ForgeInstallProfile 转化为 record (#6665)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-13 21:38:00 +08:00
BANER 5238a361cd i18n: complete Ukrainian translation (I18N_uk.properties) (#6675) 2026-08-13 20:59:33 +08:00
Glavo 0323a7f321 修复在 Linux 平台上错误的将 Intel 核显探测为独显的问题 (#6676) 2026-08-13 20:53:06 +08:00
ToobLac a3c5e8e412 [Bugfix] 修复列表中涟漪状态不更新的问题 (#6586)
Fixes #6549
2026-08-13 20:49:17 +08:00
辞庐 5c364f624a 清理 HttpRequest (#6666) 2026-08-13 20:29:58 +08:00
Glavo 22275f2c57 支持在 Linux 双显卡平台上优先使用 NVIDIA 显卡 (#6670) 2026-08-12 21:55:35 +08:00
辞庐 11e54d199c fix: .gitignore 缺少 hmcl-mod-update-list-* (#6653) 2026-08-12 20:50:20 +08:00
Glavo cf21cf4a3c 修复导出日志文件时文件为空的问题 (#6649) 2026-08-11 21:36:51 +08:00
Apostrophe a0d1fa8f38 修复异步任务 OOM 后界面假死并显示错误信息 (#6636) 2026-08-10 21:58:49 +08:00
Glavo 78b8d37891 Fix #6627: 修复安装新实例时未正确写入 patches 的问题 (#6642) 2026-08-10 21:17:58 +08:00
Glavo 0807c04572 优化 32 位平台自动分配内存策略 (#6641) 2026-08-10 19:45:23 +08:00
Glavo 8eec18450e 修复再次点击实例菜单按钮时菜单重新弹出的问题 (#6633) 2026-08-09 21:41:35 +08:00
Apostrophe 15d4874366 解决导出 MCBBS 整合包因文件过多导致的 Out Of Memory (#6630)
#6086 #6173
2026-08-09 21:22:35 +08:00
ToobLac c149401cf3 [Enhancement] enhance html spacing (#6617) 2026-08-09 20:05:28 +08:00
ToobLac ff459a5bc4 [Enhance] 模组/资源包信息对话框中 CF/MR 链接跳转到网页而非 HMCL 下载页面 (#6620) 2026-08-09 20:05:08 +08:00
Glavo 5796dad18c 改进图像类型探测功能 (#6613) 2026-08-06 19:49:33 +08:00
0443ff7cd1 [Feature] 模组等的更新日志以及 HTMLRenderer 的完善 (#6098)
Resolves #4685

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: CiiLu <109708109+ciilu@users.noreply.github.com>
2026-08-05 21:56:48 +08:00
3gf8jv4dv 4c6fe865f5 fix(l10n): correct inaccurate text for instance isolation option (#6592)
resolves #6589
2026-08-05 20:48:03 +08:00
辞庐 1e54482f3b 禁用动画时禁用 JFXCheckbox 过渡动画 (#6583) 2026-08-05 00:59:38 +08:00
bluebullet c329f0d2e8 fix(modpack): update DefaultGameBuilder whenComplete exception check (#6593) 2026-08-04 19:34:24 +08:00
Burning_TNT af4d9feea5 修复无法正确安装 MultiMC 整合包的问题 (#6584) 2026-08-04 19:33:20 +08:00
3gf8jv4dv af71c71663 fix(l10n): correct recommend part title on addon ver. list page (#6590)
resolves #6588
2026-08-04 19:12:30 +08:00
快乐凤爪王 4675442ecd Chore: 同步 Modrinth 官方 Modded 分类的 zh 和 zh_CN 翻译 (#6604) 2026-08-04 15:09:31 +08:00
辞庐andGlavo c8929c80d9 关闭动画时跳过侧边栏图标过渡动画 (#6581)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-08-02 23:50:26 +08:00
ToobLac 81634b14df [Chores] 优化 addon 类型推断逻辑 (#6536) 2026-08-02 19:58:33 +08:00
ToobLac ccca396d91 [Enhancement] 在 IO 线程上清理下载缓存 (#6560) 2026-08-02 19:27:44 +08:00
Glavo 5a14449b44 重构 Decorator (#6557) 2026-08-02 00:10:40 +08:00
辞庐 612998a98f feat: [Windows] 支持自动使用高性能显卡 (#5420)
resolves #4639
2026-08-01 22:14:34 +08:00
快乐凤爪王 a50e327f70 Fix: 防止 DisabledJavaItem 的 Label 响应鼠标事件 (#6563) 2026-08-01 21:31:08 +08:00
快乐凤爪王 7aeb1642b1 Feat: 为 GameList 添加搜索无结果提示 (#6566) 2026-08-01 21:30:26 +08:00
快乐凤爪王 5fc1536d6a Refactor: Style of JavaManagementPage (#6345) 2026-08-01 21:24:27 +08:00
ToobLac 463cf2ff0f [Chores] 遗漏的 mod -> addon 重命名 (#6535) 2026-08-01 21:11:28 +08:00
Glavo 144e822a85 使用 Xvfb 运行 Gradle Action (#6570) 2026-08-01 20:59:38 +08:00
快乐凤爪王 87789beeb7 Fix: 修复 ModDownloadListPage 末尾项 Padding (#6564) 2026-08-01 20:50:06 +08:00
Glavo bb86100c54 Bump Gson to 2.14.0 (#6562) 2026-07-31 21:23:42 +08:00
Glavo 2ceca9d8cc Bump HelloNBT to 0.4.0 (#6561) 2026-07-31 21:23:24 +08:00
快乐凤爪王 d5fc365a96 Feat: Add colors for addon channel icons in DownloadPage (#6441) 2026-07-31 20:38:28 +08:00
辞庐 d5c86a8d88 清理 CurseManifestMinecraft (#6545) 2026-07-31 20:30:36 +08:00
Glavo bd6beb1369 在 Windows 平台支持将 HMCL 固定在任务栏 (#6542) 2026-07-31 20:28:38 +08:00
Glavo b1719e77bb docs: update nullability guidelines in AGENTS.md (#6559) 2026-07-31 20:23:07 +08:00
快乐凤爪王 77f0a90f10 Fix: 在 ModpackSelectionPage 中非 IOException 的异常导致崩溃 (#6551) 2026-07-31 20:19:00 +08:00
庐庐绫 ff6110ba89 fix: 背景图片非当前选项配置重启后丢失 (#6547)
close #6546
2026-07-31 20:18:00 +08:00
快乐凤爪王 e7e964b3dd Refactor: ModpackSelectionPage 文件选择器关闭后不清空页面栈 (#6550) 2026-07-31 20:14:55 +08:00
Glavo 4e29c29a7a 更新模组数据 (2026-07-21) (#6543) 2026-07-30 21:46:55 +08:00
Glavo 25ad1dbf44 修复 GameInstancePage.java 中的拼写错误 (#6533) 2026-07-29 20:38:16 +08:00
辞庐andGlavo d2e7e0d626 实例列表左侧游戏文件夹列表添加平滑滚动 (#6495)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:19:59 +08:00
辞庐andGlavo c68bbf2904 统一管理页面工具栏 下载 按钮文案 (#6499)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-29 20:17:57 +08:00
ToobLac d0fc46f930 [Bugfix] 修复导出整合包文件选择界面UI线程阻塞 (#6363)
Fixes #6354
2026-07-29 20:02:24 +08:00
Mike Lei f1b861eb21 fix(build): fix missing icon in the dock/dash of Linux DEs (#6303) 2026-07-29 19:54:33 +08:00
Glavo 5cde4ce757 不再用 Version 表示游戏实例 (#6322) 2026-07-29 18:42:58 +08:00
庐庐绫 d297417909 fix: show/hide 前先 setIconified(false) 确保窗口状态正常 (#6525)
close #6523
2026-07-28 09:40:06 +08:00
Glavo 2854eac27d 在高刷屏上自动提高动画帧率 (#6422) 2026-07-27 21:30:27 +08:00
ToobLac c9fd7c6a1c [Enhancement] 优化主题包导出对话框的交互 (#6516)
Resolves #6512
2026-07-27 21:03:54 +08:00
Glavo 0e0517ab3c 优化模组信息读取速度 (#6515) 2026-07-27 07:12:00 +08:00
ToobLacandGlavo 7a4bea10d0 [Bugfix] 修复 #6348 资源包管理处理非UTF-8编码的options.txt文件时出错 (#6351)
Fixes #6348

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
2026-07-26 21:31:35 +08:00
辞庐 b87d40ffcc 优化 实例列表无版本时点击下载页展示 (#6494) 2026-07-26 20:56:41 +08:00
Glavo 1277bf8b5d 使用 kala-encoding-detector 作为编码推测库 (#6477) 2026-07-26 00:47:43 +08:00
辞庐 c2e807055a fix: 下载整合包时未设置任务名 (#6487) 2026-07-25 21:07:43 +08:00
辞庐 9021ec1960 清理模组管理页面冗余 “实例无模组加载器” 提示 (#6500) 2026-07-25 21:01:22 +08:00
辞庐 82a47e9e2d 优化实例列表界面 (#6493) 2026-07-25 21:00:42 +08:00
neveler 4841204fa0 update i18n (#6491) 2026-07-25 21:00:26 +08:00
辞庐 897f5fe11b fix: 形参化类 'Tab<?>' 的原始使用 (#6498) 2026-07-25 20:57:58 +08:00
Glavo 4657e3273e 复制实例时同步保存游戏设置 (#6492) 2026-07-24 20:39:59 +08:00
Glavo 4edea5f5b9 Add support for Minecraft 26.3-snapshot-5+ to unofficial supported platforms (#6481) 2026-07-23 19:51:00 +08:00
辞庐 36bd7d4491 修复 GameAdvancedListItem#loadVersion UI 操作可能不会 JavaFX 线程运行的问题 (#6445)
resolves #6443
2026-07-22 22:11:55 +08:00
neveler 3aa8603bd1 提取 pr-size-label 工作流中的 js 代码到独立文件 (#6458) 2026-07-22 22:06:57 +08:00
庐庐绫 ca7a61e20a fix: 修复模组列表加载时检查错误实例的模组更新 (#6478)
close #6448
2026-07-22 21:57:59 +08:00
辞庐 21afc81cf8 fix: NetwordUtils#doPost(URI u, Map<String, String> params) 未进行 URL 编码 (#6457) 2026-07-22 21:49:46 +08:00
neveler 94a0e26be5 update i18n: remove game.crash.reason.mod_repeat_installation (#6472) 2026-07-22 21:47:13 +08:00
辞庐 14e8d2d0b5 清理 VanillaInstallWizardProvider (#6474) 2026-07-22 21:46:49 +08:00
快乐凤爪王 49c408cb23 Refactor: InstallersPageSkin versionNamePane (#6349) 2026-07-22 21:45:49 +08:00
辞庐 b81c3398ac fix: 切换版本隔离设置后自动刷新其他子页面 功能失效 (#6468) 2026-07-22 13:21:58 +08:00
快乐凤爪王 2855df3693 Feat: Implement dynamic folder icon and use to game directory list (#6435) 2026-07-21 22:03:32 +08:00
快乐凤爪王 8184dc6cee Fix: Alignment for background opacity percentage label (#6450) 2026-07-21 21:55:12 +08:00
快乐凤爪王 df21559c0e Chore: Clean up duplicate key-value pairs for No results found (#6449) 2026-07-21 21:53:21 +08:00
快乐凤爪王 73ff3081f8 Feat: No results found tip for ThemePackManagementPage (#6451) 2026-07-21 21:49:52 +08:00
辞庐 fe05bb58a4 更新时 日志输出新进程命令行时 打码敏感信息 (#6456) 2026-07-21 21:30:32 +08:00
Glavo 4c9cd950e4 移除对 JavaFX 14 的兼容性 (#6467) 2026-07-21 20:40:56 +08:00
neveler 646d9a09a5 update i18n (#6466) 2026-07-21 20:38:56 +08:00
Glavo 255fe75640 Bump JavaFX to 21.0.11 on FreeBSD x86-64 (#6463) 2026-07-21 19:40:18 +08:00
neveler de15dd8e98 Remove extra whitespace (#6462) 2026-07-21 17:57:57 +08:00
Glavo 886319a6c7 避免在代码中直接引用 Controllers.getStage() (#6431) 2026-07-20 21:41:55 +08:00
辞庐 3320f6c341 fix: 整合包安装完成回调执行器不统一 (#6446) 2026-07-20 21:21:33 +08:00
快乐凤爪王 b85be78166 Feat: Add empty state placeholders for versions (#6426) 2026-07-20 21:21:06 +08:00
Glavo a34a7439d7 Clarify nullability and documentation in AGENTS.md (#6440) 2026-07-20 00:02:59 +08:00
ZZZank 591fd51600 实现游戏日志窗口开关自动换行 (#6424) 2026-07-19 21:53:57 +08:00
快乐凤爪王 c1407a9620 Fix: Resolve ModLoaderType.LEGACY_FABRIC for Modrinth (#6434) 2026-07-19 02:46:28 +08:00
Glavo e0a91579f2 更新内置主题相关译文 (#6429) 2026-07-17 21:29:51 +08:00
快乐凤爪王 b68f404c3c Refactor: Style of ModInfoDialog (#6423) 2026-07-17 21:28:02 +08:00
Glavo c1f50f9ba3 删除 Gemini 审查配置文件 (#6430) 2026-07-17 21:17:31 +08:00
辞庐 d8bb2131f2 允许拖入 nbt 文件 (#6427) 2026-07-17 21:08:45 +08:00
Glavo aca761385b Add support for Minecraft 26.3-snapshot-4+ to unofficial supported platforms (#6425) 2026-07-17 21:00:14 +08:00
3gf8jv4dv 15faccf376 chore(workflow): add pr-size-label workflow file (#6381) 2026-07-17 20:23:48 +08:00
Glavo 241c9c419c 修复 FXUtils 意外的被提前初始化的问题 (#6421) 2026-07-16 21:29:12 +08:00
辞庐 4b36f8ce03 fix: 任务窗口速度与取消未对齐 (#6140) 2026-07-16 21:28:31 +08:00
Glavo 5c0baad782 提早加载配置文件 (#6420) 2026-07-16 20:50:20 +08:00
Apostrophe 850a56405c 修复游戏 Java 路径显示滞后问题 (#6419) 2026-07-16 19:59:17 +08:00
快乐凤爪王 e8487c467b Refactor: Replace deprecated URL constructor (#6409) 2026-07-16 03:37:54 +08:00
Glavo 9866d88a16 完善游戏设置页面帮助文本 (#6415) 2026-07-15 21:15:00 +08:00
辞庐 af2d46ae89 Revert "Refactor: Remove redundant downloadSourceComboBox from ModDownloadListPageSkin" (#6410)
Reverts HMCL-dev/HMCL#6370
2026-07-15 19:56:20 +08:00
ToobLac 6f7c561ecb [Bugfix] 修复 1.21.1 安装信雅互联仍显示不支持 Fabric 模组的问题 (#6404)
Fixes #6391
2026-07-14 21:41:39 +08:00
ToobLac 93abe31acf [Chores] 处理升级到 9.6.1 后 Gradle 弃用提示 (#6405) 2026-07-14 21:29:58 +08:00
Glavo a6f8659349 Bump Gradle Wrapper Neo to 0.2.0 (#6397) 2026-07-14 00:06:56 +08:00
Glavo 8728c89093 Bump version root to 3.17 (#6392) 2026-07-13 20:34:38 +08:00
1173 changed files with 91261 additions and 91838 deletions

No files matched your search

+3
View File
@@ -6,3 +6,6 @@ contact_links:
- name: Discord 服务器
url: https://discord.gg/jVvC7HfM6U
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
-3
View File
@@ -1,3 +0,0 @@
code_review:
pull_request_opened:
code_review: false
+4
View File
@@ -4,6 +4,7 @@
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.js text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
@@ -58,6 +59,9 @@ Jenkinsfile text eol=lf
# Minecraft schematic
*.litematic binary
*.nbt binary
*.schem binary
*.schematic binary
# Compiled / intermediate build artefacts
*.bin binary
+3
View File
@@ -3,3 +3,6 @@ contact_links:
- name: QQ 群
url: https://docs.hmcl.net/groups.html
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
- name: GitHub Issue
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
+120
View File
@@ -0,0 +1,120 @@
const fs = require("fs");
module.exports = async ({ github, context, core }) => {
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
core.setFailed("Pull request number file not found.");
return;
}
const reportsPath = process.env.REPORTS_PATH;
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
core.setFailed("Reports file not found.");
return;
}
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
if (isNaN(PULL_REQUEST_NUMBER)) {
core.setFailed("Failed to parse the pull request number.");
return;
}
const { owner, repo } = context.repo;
let pr;
try {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
});
pr = response.data;
} catch (error) {
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
return;
}
core.info(`Current PR state: ${pr.state}`);
if (pr.state !== "open") {
core.setFailed("The pull request is not open. Skipping comment creation.");
return;
}
const run = context.payload.workflow_run;
if (!run) {
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
return;
}
if (pr.head.sha !== run.head_sha) {
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
return;
}
if (pr.head.ref !== run.head_branch) {
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
return;
}
if (pr.head.repo.full_name !== run.head_repository.full_name) {
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
return;
}
const comments = [];
const maxCommentCount = 10;
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
for (const report of reports) {
if (comments.length >= maxCommentCount) break;
try {
const data = JSON.parse(report);
if (typeof data !== "object" || Array.isArray(data)) continue;
const { location, message, severity, code } = data;
if (typeof location !== "object" || Array.isArray(location)) continue;
if (typeof message !== "string") continue;
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
if (!severities.includes(severity)) continue;
const severityText = typeof severity === "string" ? severity : severities[severity];
if (typeof code !== "object" || Array.isArray(code)) continue;
const path = location.path;
const line = location.range?.start?.line;
const codeText = code.value ? `[${code.value}] ` : "";
if (path && line) {
comments.push({
path,
line,
side: "RIGHT",
body: `**[${severityText}]** ${codeText}\n\n${message}`
});
}
} catch (error) {
core.warning(`Failed to parse report: ${error.message}`);
}
}
if (comments.length === 0) {
core.info("No diagnostics found to report as comments.");
return;
}
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
try {
await github.rest.pulls.createReview({
owner,
repo,
pull_number: PULL_REQUEST_NUMBER,
commit_id: run.head_sha,
body: "🤖 Static analysis found the following issues (max count 10):",
event: "COMMENT",
comments: comments
});
core.info("Review comments successfully posted to the pull request.");
} catch (error) {
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
return;
}
}
+105
View File
@@ -0,0 +1,105 @@
module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
const ignoredFilePatterns = [
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
/(?:^|\/)\.github\/.+\.ya?ml$/i,
/(?:^|\/)\.idea\/.+\.xml$/i,
/(?:^|\/)\.editorconfig$/i,
/(?:^|\/)\.gitattributes$/i,
/(?:^|\/)\.gitignore$/i,
/(?:^|\/).+\.kts$/i,
/(?:^|\/).+\.md$/i,
/(?:^|\/)LICENSE(?:_.*)?$/i,
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
/(?:^|\/)lang\/.+\.properties$/i,
/(?:^|\/)l10n\/.+\.properties$/i,
/(?:^|\/)checkstyle\.xml$/i,
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
];
async function processPR(issueNumber) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issueNumber, per_page: 100,
});
const countedFiles = [];
const ignoredFiles = [];
let totalChanges = 0;
for (const file of files) {
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
if (ignored) {
ignoredFiles.push(file.filename);
continue;
}
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
totalChanges += fileChanges;
countedFiles.push(`${file.filename} (${fileChanges})`);
}
let targetLabel;
if (totalChanges <= 9) targetLabel = "1+";
else if (totalChanges <= 39) targetLabel = "10+";
else if (totalChanges <= 99) targetLabel = "40+";
else if (totalChanges <= 499) targetLabel = "100+";
else if (totalChanges <= 999) targetLabel = "500+";
else if (totalChanges <= 1999) targetLabel = "1000+";
else if (totalChanges <= 4999) targetLabel = "2000+";
else targetLabel = "5000+";
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number: issueNumber, per_page: 100,
});
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
for (const name of sizeLabels) {
if (name !== targetLabel) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: issueNumber, name,
}).catch(() => { });
}
}
if (!currentLabels.some(label => label.name === targetLabel)) {
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: [targetLabel],
});
}
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
}
if (context.eventName === "workflow_dispatch") {
core.info("Manual trigger detected. Fetching all open pull requests...");
const openPRs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100,
});
const unlabelledPRs = openPRs.filter(pr => {
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
});
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
for (const pr of unlabelledPRs) {
core.info(`----------------------------------------`);
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
await processPR(pr.number);
}
} else {
const pr = context.payload.pull_request;
if (!pr) {
core.info("No pull request found in payload, skipping.");
return;
}
await processPR(pr.number);
}
}
-29
View File
@@ -1,29 +0,0 @@
name: Check Codes
on:
push:
paths:
- '**.java'
- '**.properties'
pull_request:
paths:
- '**.java'
- '**.properties'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+34 -4
View File
@@ -1,11 +1,22 @@
name: Java CI
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
# (once for the push, once for the pull request), which kept the runners queued.
on:
push:
branches:
- main
tags:
- '*'
pull_request:
paths-ignore:
- '**.md'
# A newer run for the same pull request or branch replaces the older one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
@@ -14,18 +25,37 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: 'zulu'
distribution: 'liberica'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Build with Gradle
run: ./gradlew build --no-daemon --parallel
# Only main writes the dependency cache; pull requests read it.
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# The repository's wrapper downloads Gradle itself, so cache that download too.
- name: Cache Gradle distribution
uses: actions/cache@v4
with:
path: |
~/.gradle/wrapper
.gradle/wrapper-neo
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
- name: Install Xvfb
run: |
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
# only refresh the package index when a plain install fails.
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
# One Gradle run builds, tests and runs the code and translation checks, so the
# project is configured and compiled once instead of in two workflows.
- name: Build and check with Gradle
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
env:
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
+20
View File
@@ -0,0 +1,20 @@
name: PR Size Label
# Disabled on this Gitea fork: act resolves actions/github-script from
# git.nest.potatolab.uk and that mirror 404s. Re-enable after mirroring
# actions/github-script or replacing the labeler with a plain shell step.
on:
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
if: false
steps:
- name: Disabled
run: echo "pr-size-label is disabled on this fork"
+6
View File
@@ -4,6 +4,8 @@
hs_err_pid*
*.hprof
*.jfr
.gradle
gradle.properties
@@ -17,6 +19,7 @@ NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
terracotta-log-*
hmcl-mod-update-list-*
*.hmcl-theme
/.java/
/.local/
@@ -74,3 +77,6 @@ language-subtag-registry
!/.gemini/config.yaml
.codex
/.gradle-*/
# Managed Java downloaded by the launcher when it is run from the repository root
/java/
+4 -4
View File
@@ -1,6 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Hello Minecraft! Launcher&#10;Copyright (C) &amp;#36;today.year huangyuhui &lt;huanghongxun2008@126.com&gt; and contributors&#10;&#10;This program is free software: you can redistribute it and/or modify&#10;it under the terms of the GNU General Public License as published by&#10;the Free Software Foundation, either version 3 of the License, or&#10;(at your option) any later version.&#10;&#10;This program is distributed in the hope that it will be useful,&#10;but WITHOUT ANY WARRANTY; without even the implied warranty of&#10;MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10;GNU General Public License for more details.&#10;&#10;You should have received a copy of the GNU General Public License&#10;along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;." />
<option name="myName" value="HMCL" />
</copyright>
<copyright>
<option name="notice" value="SPDX-FileCopyrightText: Copyright (C) &amp;#36;today.year The HMCL Authors&#10;SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL" />
<option name="myName" value="HMCL" />
</copyright>
</component>
+1
View File
@@ -3,6 +3,7 @@
<LanguageOptions name="JAVA">
<option name="fileTypeOverride" value="3" />
<option name="addBlankAfter" value="false" />
<option name="block" value="false" />
</LanguageOptions>
</settings>
</component>
+7 -3
View File
@@ -4,9 +4,11 @@ These rules apply to all Java code written or modified in this repository.
## Nullability
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability must never be implicit.
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
- Nullability in code being written or modified must never be implicit.
## Immutability
@@ -15,6 +17,8 @@ These rules apply to all Java code written or modified in this repository.
## Documentation
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
- Every class, field, and method must have documentation.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
+72 -74
View File
@@ -1,14 +1,16 @@
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.pack.CreateDeb
import org.jackhuang.hmcl.gradle.pack.ReleaseType
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import org.jackhuang.hmcl.gradle.utils.OfficialBuildValueSource
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -52,7 +54,7 @@ if (buildNumber != null) {
}
}
val embedResources by configurations.registering
val embedResources = configurations.register("embedResources")
dependencies {
implementation(project(":HMCLCore"))
@@ -144,6 +146,7 @@ val addOpens = listOf(
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.graphics/com.sun.javafx.tk.quantum",
"javafx.controls/javafx.scene.control",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
@@ -152,33 +155,28 @@ tasks.compileJava {
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
}
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
add("hmcl.add-opens" to addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let {
add("hmcl.version.hash" to it)
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
}
val createPropertiesFile = tasks.register<WriteProperties>("createPropertiesFile") {
destinationFile.set(layout.buildDirectory.file("hmcl.properties"))
encoding = "UTF-8"
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
val createPropertiesFile by tasks.registering {
outputs.file(hmclPropertiesFile)
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
doLast {
val targetFile = hmclPropertiesFile.get().asFile
targetFile.parentFile.mkdir()
targetFile.bufferedWriter().use {
for ((k, v) in hmclProperties) {
it.write("$k=$v\n")
}
}
}
property("hmcl.version", project.version.toString())
property("hmcl.add-opens", addOpens.joinToString(" "))
System.getenv("GITHUB_SHA")?.let { property("hmcl.version.hash", it) }
property("hmcl.version.type", versionType)
property("hmcl.microsoft.auth.id", microsoftAuthId)
property("hmcl.curseforge.apikey", curseForgeApiKey)
property("hmcl.authlib-injector.version", libs.authlib.injector.get().version!!)
property("hmcl.lwjgl-unsafe-agent.version", libs.lwjgl.unsafe.agent.get().version!!)
property("hmcl.official", providers.of(OfficialBuildValueSource::class) {
parameters.publicKeyFile.set(layout.projectDirectory.file("src/main/resources/assets/hmcl_signature_publickey.der"))
parameters.privateKeyLocation.set(providers.environmentVariable("HMCL_SIGNATURE_KEY"))
// Pin the official public key independently of the replaceable resource file.
// Under HMCL’s license agreement, you **MUST** change the software name or the version number in
// a reasonable way to distinguish it from the original version.
// You may modify this hash if and only if you have changed the software name or version number
// in accordance with the license requirements.
parameters.expectedSha256.set("36aadcfde8626b9d4c7a74facb3fdfda47e703c205a673fbe6a56b1d419f8b8e")
})
}
tasks.jar {
@@ -212,7 +210,7 @@ tasks.shadowJar {
}
manifest.attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Created-By" to "Copyright(c) 2013-2026 The HMCL Authors",
"Implementation-Version" to project.version.toString(),
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
@@ -234,13 +232,12 @@ tasks.shadowJar {
}
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(createPropertiesFile.flatMap { it.destinationFile })
from(embedResources)
}
@@ -250,15 +247,11 @@ tasks.processResources {
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
inputs.property("terracotta_version", libs.versions.terracotta)
doLast {
upgradeTerracottaConfig.get().checkValid()
}
}
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables by tasks.registering {
val makeExecutables = tasks.register("makeExecutables") {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
@@ -286,7 +279,7 @@ val makeExecutables by tasks.registering {
}
}
val makeDeb by tasks.registering(CreateDeb::class) {
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
@@ -299,6 +292,7 @@ val makeDeb by tasks.registering(CreateDeb::class) {
version.set(project.version.toString())
releaseType.set(debChannel)
launcherClassName.set("org.jackhuang.hmcl.Launcher")
appShFile.set(layout.file(provider { artifactFile("sh") }))
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
outputFile.set(debFile)
@@ -363,6 +357,11 @@ fun parseToolOptions(options: String?): MutableList<String> {
return result
}
// Tests use the same JavaFX reflective access as the packaged application.
tasks.withType<Test> {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
}
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
@@ -401,27 +400,6 @@ tasks.register<JavaExec>("run") {
}
}
// terracotta
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(
listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
)
)
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
templateFile.set(source)
outputFile.set(destination)
}
// Check Translations
@@ -429,42 +407,62 @@ tasks.register<CheckTranslations>("checkTranslations") {
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
englishFile.set(dir.file("I18N.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
tasks.register<SyncTranslations>("syncTranslations") {
group = "localization"
description = "Synchronizes translation keys and blank lines with I18N.properties."
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
sourceFile.set(dir.file("I18N.properties"))
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
}
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
val createLocaleNamesResourceBundle =
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
inputFile.set(layout.projectDirectory.file("mod.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
tasks.register<FormatJsonFile>("formatModJson") {
file.set(layout.projectDirectory.file("mod.json"))
}
tasks.register<ParseModDataTask>("parseModPackData") {
inputFile.set(layout.projectDirectory.file("modpack.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
tasks.register<FormatJsonFile>("formatModpackJson") {
file.set(layout.projectDirectory.file("modpack.json"))
}
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
inputFile.set(layout.projectDirectory.file("mod.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
}
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
inputFile.set(layout.projectDirectory.file("modpack.json"))
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
}
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package com.jfoenix.controls;
import com.jfoenix.skins.JFXCheckBoxSkin;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.layout.HBox;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
///
/// The checkbox's selected and indeterminate states are bidirectionally bound to
/// the current tree item. Reusing or clearing the cell removes the old bindings.
/// Installing a checkbox skin or switching tree items applies the selection state
/// immediately, without playing a selection animation.
/// Ordinary tree items retain the display provided by [JFXTreeCell].
/// Empty cells and `null` values display neither content nor a checkbox.
///
/// @param <T> the type of value stored in each tree item
@NotNullByDefault
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
/// The checkbox bound to the currently displayed checkable tree item.
private final JFXCheckBox checkBox = new JFXCheckBox();
/// Places the checkbox before the graphic supplied by the superclass.
private final HBox graphic = new HBox();
/// The tree item whose selection properties are currently bound, if any.
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
public JFXCheckTreeCell() {
checkBox.setAllowIndeterminate(false);
checkBox.setFocusTraversable(false);
graphic.setAlignment(Pos.CENTER_LEFT);
graphic.setPickOnBounds(false);
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
}
/// Finishes animations from the previous item and immediately displays the current state.
private void finishSelectionAnimation() {
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
skin.finishSelectionAnimation();
}
}
/// Adds the checkbox to the inherited display and updates its selection bindings.
@Override
protected void updateDisplay(@Nullable T item, boolean empty) {
// Release graphics before the superclass reuses or reparents them.
graphic.getChildren().clear();
super.updateDisplay(item, empty);
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
if (boundTreeItem != treeItem) {
if (boundTreeItem != null) {
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
}
boundTreeItem = treeItem;
if (treeItem != null) {
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
} else {
checkBox.setSelected(false);
checkBox.setIndeterminate(false);
}
finishSelectionAnimation();
}
if (treeItem != null) {
@Nullable Node itemGraphic = getGraphic();
graphic.getChildren().add(checkBox);
if (itemGraphic != null) {
graphic.getChildren().add(itemGraphic);
}
setGraphic(graphic);
}
}
}
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
private void initialize() {
getStyleClass().add(DEFAULT_STYLE_CLASS);
this.setCellFactory(listView -> new JFXListCell<T>() {
this.setCellFactory(listView -> new JFXListCell<>() {
@Override
public void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
// had to refactor the code out of the skin class to allow
// customization of the button cell
this.setButtonCell(new ListCell<T>() {
this.setButtonCell(new ListCell<>() {
{
// fixed clearing the combo box value is causing
// java prompt text to be shown because the button cell is not updated
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
*/
@Override
protected void updateItem(T item, boolean empty) {
T oldItem = getItem();
boolean oldEmpty = isEmpty();
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
super.updateItem(item, empty);
if (empty) {
setText(null);
@@ -278,6 +278,10 @@ public class JFXRippler extends StackPane {
rippler.releaseRipple();
}
public void releaseRippleImmediately() {
rippler.releaseRippleImmediately();
}
/**
* creates Ripple effect in the center of the control
*
@@ -405,6 +409,21 @@ public class JFXRippler extends StackPane {
}
}
private void releaseRippleImmediately() {
Ripple ripple = ripplesQueue.poll();
if (ripple != null) {
getChildren().remove(ripple);
if (generating.getAndSet(false)) {
if (overlayRect != null) {
overlayRect.inAnimation.stop();
if (!forceOverlay) {
overlayRect.setOpacity(0D);
}
}
}
}
}
void cacheRippleClip(boolean cached) {
cacheRipplerClip = cached;
}
@@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package com.jfoenix.controls;
import com.jfoenix.skins.JFXSwitchSkin;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.SimpleStyleableDoubleProperty;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleablePropertyFactory;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.AccessibleAction;
import javafx.scene.AccessibleAttribute;
import javafx.scene.AccessibleRole;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
/// A Material Design 3 switch for a boolean setting.
///
/// Supports clicking, dragging, and Space-key activation. Labels belong to the surrounding layout;
/// supply an accessible text description. Size and colors are configurable through CSS.
@NotNullByDefault
public class JFXSwitch extends Control {
/// Creates an unselected switch.
public JFXSwitch() {
getStyleClass().setAll("jfx-switch");
setFocusTraversable(true);
setAccessibleRole(AccessibleRole.TOGGLE_BUTTON);
}
/// Selection state.
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected") {
/// Updates visual and accessible selection without firing an action.
@Override
protected void invalidated() {
pseudoClassStateChanged(SELECTED, get());
notifyAccessibleAttributeChanged(AccessibleAttribute.SELECTED);
}
};
/// Changes selection without firing an action event.
public final void setSelected(boolean value) {
selected.set(value);
}
/// Returns whether the switch is selected.
public final boolean isSelected() {
return selected.get();
}
/// Returns the bindable selection state; the default is false.
public final BooleanProperty selectedProperty() {
return selected;
}
/// Read-only activation state.
private final ReadOnlyBooleanWrapper armed = new ReadOnlyBooleanWrapper(this, "armed") {
/// Reflects input feedback in CSS.
@Override
protected void invalidated() {
pseudoClassStateChanged(ARMED, get());
}
};
/// Returns whether the switch is armed.
public final boolean isArmed() {
return armed.get();
}
/// Returns the read-only state indicating a pending activation.
public final ReadOnlyBooleanProperty armedProperty() {
return armed.getReadOnlyProperty();
}
/// Optional action handler.
private @Nullable ObjectProperty<@Nullable EventHandler<ActionEvent>> onAction;
/// Sets or clears the action handler.
public final void setOnAction(@Nullable EventHandler<ActionEvent> handler) {
if (onAction != null || handler != null)
onActionProperty().set(handler);
}
/// Returns the action handler, or null when none is installed.
public final @Nullable EventHandler<ActionEvent> getOnAction() {
return onAction == null ? null : onAction.get();
}
/// Returns the handler property for activation events.
public final ObjectProperty<@Nullable EventHandler<ActionEvent>> onActionProperty() {
if (onAction == null) {
onAction = new SimpleObjectProperty<>(this, "onAction") {
/// Updates the action handler.
@Override
protected void invalidated() {
setEventHandler(ActionEvent.ACTION, get());
}
};
}
return onAction;
}
/// The requested track height in logical pixels.
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(SIZE, this, "size", 32.0) {
/// Validates the size and requests layout.
@Override
protected void invalidated() {
get();
requestLayout();
}
};
/// Sets the track height; see [#sizeProperty()].
public final void setSize(double value) {
size.set(value);
}
/// Returns the requested track height in logical pixels.
public final double getSize() {
return size.get();
}
/// Track height in logical pixels, styled by `-jfx-size`; the default is 32.
/// Scales the artwork and interaction target proportionally; nonpositive or nonfinite sizes render at the default size.
public final StyleableDoubleProperty sizeProperty() {
return size;
}
/// Optional selected-icon property.
private @Nullable BooleanProperty showSelectedIcon;
/// Sets the value of [#showSelectedIconProperty()].
public final void setShowSelectedIcon(boolean value) {
if (showSelectedIcon != null || value)
showSelectedIconProperty().set(value);
}
/// Returns whether the selected icon is shown.
public final boolean isShowSelectedIcon() {
return showSelectedIcon != null && showSelectedIcon.get();
}
/// Whether to show a check mark when selected; defaults to false.
public final BooleanProperty showSelectedIconProperty() {
if (showSelectedIcon == null)
showSelectedIcon = createOptionProperty("showSelectedIcon", false, false);
return showSelectedIcon;
}
/// Optional unselected-icon property.
private @Nullable BooleanProperty showUnselectedIcon;
/// Sets the value of [#showUnselectedIconProperty()].
public final void setShowUnselectedIcon(boolean value) {
if (showUnselectedIcon != null || value)
showUnselectedIconProperty().set(value);
}
/// Returns whether the unselected icon is shown.
public final boolean isShowUnselectedIcon() {
return showUnselectedIcon != null && showUnselectedIcon.get();
}
/// Whether to show a cross when unselected; defaults to false.
public final BooleanProperty showUnselectedIconProperty() {
if (showUnselectedIcon == null)
showUnselectedIcon = createOptionProperty("showUnselectedIcon", false, false);
return showUnselectedIcon;
}
/// Optional animation policy override.
private @Nullable BooleanProperty disableAnimation;
/// Sets the value of [#disableAnimationProperty()].
public final void setDisableAnimation(boolean value) {
if (disableAnimation != null || value == AnimationUtils.isAnimationEnabled())
disableAnimationProperty().set(value);
}
/// Returns whether animation is disabled.
public final boolean isDisableAnimation() {
return disableAnimation == null ? !AnimationUtils.isAnimationEnabled() : disableAnimation.get();
}
/// Disables animation, defaulting to the inverse of the global animation-enabled setting.
public final BooleanProperty disableAnimationProperty() {
if (disableAnimation == null)
disableAnimation = createOptionProperty("disableAnimation", !AnimationUtils.isAnimationEnabled(), true);
return disableAnimation;
}
/// Arms input feedback unless the switch is disabled or its selection is bound.
public final void arm() {
if (!isDisabled() && !selected.isBound())
armed.set(true);
}
/// Clears input feedback without firing an action.
public final void disarm() {
armed.set(false);
}
/// Toggles selection and fires one action; disabled or bound switches ignore activation.
public void fire() {
if (!isDisabled() && !selected.isBound()) {
setSelected(!isSelected());
fireEvent(new ActionEvent(this, this));
}
}
/// Creates the default switch skin.
@Override
protected Skin<?> createDefaultSkin() {
return new JFXSwitchSkin(this);
}
/// Creates a property that updates the skin, optionally settling active animations.
private BooleanProperty createOptionProperty(String name, boolean initialValue, boolean finishAnimation) {
return new SimpleBooleanProperty(this, name, initialValue) {
/// Validates the value even without a skin, so subsequent changes also reach this callback.
@Override
protected void invalidated() {
get();
if (getSkin() instanceof JFXSwitchSkin skin)
skin.updateOptions(finishAnimation);
}
};
}
/// Indicates that the switch is selected.
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
/// Indicates a pending activation.
private static final PseudoClass ARMED = PseudoClass.getPseudoClass("armed");
/// Factory for switch CSS metadata.
private static final StyleablePropertyFactory<JFXSwitch> STYLEABLES =
new StyleablePropertyFactory<>(Control.getClassCssMetaData());
/// CSS metadata for track height.
private static final CssMetaData<JFXSwitch, Number> SIZE =
STYLEABLES.createSizeCssMetaData("-jfx-size", control -> control.size, 32.0);
/// Switch and inherited CSS metadata.
private static final @Unmodifiable List<CssMetaData<? extends Styleable, ?>> CSS_META_DATA =
List.copyOf(STYLEABLES.getCssMetaData());
/// Returns immutable CSS metadata, including inherited control properties.
public static @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return CSS_META_DATA;
}
/// Returns the metadata used to style this switch.
@Override
public @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
/// Exposes selection to assistive technology.
@Override
public @Nullable Object queryAccessibleAttribute(AccessibleAttribute attribute, @Nullable Object... parameters) {
return attribute == AccessibleAttribute.SELECTED ? isSelected() : super.queryAccessibleAttribute(attribute, parameters);
}
/// Handles accessible activation.
@Override
public void executeAccessibleAction(AccessibleAction action, @Nullable Object... parameters) {
if (action == AccessibleAction.FIRE)
fire();
else
super.executeAccessibleAction(action, parameters);
}
}
@@ -1,373 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.controls;
import com.jfoenix.skins.JFXToggleButtonSkin;
import javafx.css.*;
import javafx.css.converter.BooleanConverter;
import javafx.css.converter.PaintConverter;
import javafx.scene.control.Skin;
import javafx.scene.control.ToggleButton;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* JFXToggleButton is the material design implementation of a toggle button.
* important CSS Selectors:
* <p>
* .jfx-toggle-button{
* -fx-toggle-color: color-value;
* -fx-untoggle-color: color-value;
* -fx-toggle-line-color: color-value;
* -fx-untoggle-line-color: color-value;
* }
* <p>
* To change the rippler color when toggled:
* <p>
* .jfx-toggle-button .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
* <p>
* .jfx-toggle-button:selected .jfx-rippler{
* -fx-rippler-fill: color-value;
* }
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButton extends ToggleButton {
/**
* {@inheritDoc}
*/
public JFXToggleButton() {
initialize();
}
/**
* {@inheritDoc}
*/
@Override
protected Skin<?> createDefaultSkin() {
return new JFXToggleButtonSkin(this);
}
private void initialize() {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
// it's up for the user to add this behavior
// toggleColor.addListener((o, oldVal, newVal) -> {
// // update line color in case not set by the user
// if(newVal instanceof Color)
// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter());
// });
}
/***************************************************************************
* *
* styleable Properties *
* *
**************************************************************************/
/**
* Initialize the style class to 'jfx-toggle-button'.
* <p>
* This is the selector class from which CSS can be used to style
* this control.
*/
private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button";
/**
* default color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR,
JFXToggleButton.this,
"toggleColor",
Color.valueOf(
"#009688"));
public Paint getToggleColor() {
return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get();
}
public StyleableObjectProperty<Paint> toggleColorProperty() {
return this.toggleColor;
}
public void setToggleColor(Paint color) {
this.toggleColor.set(color);
}
/**
* default color used when the button is not toggled
*/
private StyleableObjectProperty<Paint> untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR,
JFXToggleButton.this,
"unToggleColor",
Color.valueOf(
"#FAFAFA"));
public Paint getUnToggleColor() {
return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get();
}
public StyleableObjectProperty<Paint> unToggleColorProperty() {
return this.untoggleColor;
}
public void setUnToggleColor(Paint color) {
this.untoggleColor.set(color);
}
/**
* default line color used when the button is toggled
*/
private final StyleableObjectProperty<Paint> toggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.TOGGLE_LINE_COLOR,
JFXToggleButton.this,
"toggleLineColor",
Color.valueOf("#77C2BB"));
public Paint getToggleLineColor() {
return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get();
}
public StyleableObjectProperty<Paint> toggleLineColorProperty() {
return this.toggleLineColor;
}
public void setToggleLineColor(Paint color) {
this.toggleLineColor.set(color);
}
/**
* default line color used when the button is not toggled
*/
private final StyleableObjectProperty<Paint> untoggleLineColor = new SimpleStyleableObjectProperty<>(
StyleableProperties.UNTOGGLE_LINE_COLOR,
JFXToggleButton.this,
"unToggleLineColor",
Color.valueOf("#999999"));
public Paint getUnToggleLineColor() {
return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get();
}
public StyleableObjectProperty<Paint> unToggleLineColorProperty() {
return this.untoggleLineColor;
}
public void setUnToggleLineColor(Paint color) {
this.untoggleLineColor.set(color);
}
/**
* Default size of the toggle button.
*/
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(
StyleableProperties.SIZE,
JFXToggleButton.this,
"size",
10.0);
public double getSize() {
return size.get();
}
public StyleableDoubleProperty sizeProperty() {
return this.size;
}
public void setSize(double size) {
this.size.set(size);
}
/**
* Disable the visual indicator for focus
*/
private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS,
JFXToggleButton.this,
"disableVisualFocus",
false);
public final StyleableBooleanProperty disableVisualFocusProperty() {
return this.disableVisualFocus;
}
public final Boolean isDisableVisualFocus() {
return disableVisualFocus != null && this.disableVisualFocusProperty().get();
}
public final void setDisableVisualFocus(final Boolean disabled) {
this.disableVisualFocusProperty().set(disabled);
}
/**
* disable animation on button action
*/
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
JFXToggleButton.this,
"disableAnimation",
!AnimationUtils.isAnimationEnabled());
public final StyleableBooleanProperty disableAnimationProperty() {
return this.disableAnimation;
}
public final Boolean isDisableAnimation() {
return disableAnimation != null && this.disableAnimationProperty().get();
}
public final void setDisableAnimation(final Boolean disabled) {
this.disableAnimationProperty().set(disabled);
}
private static final class StyleableProperties {
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_COLOR =
new CssMetaData<>("-jfx-toggle-color",
PaintConverter.getInstance(), Color.valueOf("#009688")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleColor == null || !control.toggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_COLOR =
new CssMetaData<>("-jfx-untoggle-color",
PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleColor == null || !control.untoggleColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-toggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#77C2BB")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.toggleLineColor == null || !control.toggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.toggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_LINE_COLOR =
new CssMetaData<>("-jfx-untoggle-line-color",
PaintConverter.getInstance(), Color.valueOf("#999999")) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.untoggleLineColor == null || !control.untoggleLineColor.isBound();
}
@Override
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
return control.unToggleLineColorProperty();
}
};
private static final CssMetaData<JFXToggleButton, Number> SIZE =
new CssMetaData<>("-jfx-size",
StyleConverter.getSizeConverter(), 10.0) {
@Override
public boolean isSettable(JFXToggleButton control) {
return !control.size.isBound();
}
@Override
public StyleableProperty<Number> getStyleableProperty(JFXToggleButton control) {
return control.sizeProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_VISUAL_FOCUS =
new CssMetaData<>("-jfx-disable-visual-focus",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableVisualFocus == null || !control.disableVisualFocus.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableVisualFocusProperty();
}
};
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_ANIMATION =
new CssMetaData<>("-jfx-disable-animation",
BooleanConverter.getInstance(), false) {
@Override
public boolean isSettable(JFXToggleButton control) {
return control.disableAnimation == null || !control.disableAnimation.isBound();
}
@Override
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
return control.disableAnimationProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables =
new ArrayList<>(ToggleButton.getClassCssMetaData());
Collections.addAll(styleables,
SIZE,
TOGGLE_COLOR,
UNTOGGLE_COLOR,
TOGGLE_LINE_COLOR,
UNTOGGLE_LINE_COLOR,
DISABLE_VISUAL_FOCUS,
DISABLE_ANIMATION
);
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
}
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
return getClassCssMetaData();
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return StyleableProperties.CHILD_STYLEABLES;
}
}
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
treeItemRef = new WeakReference<>(newTreeItem);
}
};
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
treeItemProperty().addListener(weakTreeItemListener);
treeItemProperty().addListener(treeItemInvalidationListener);
if (getTreeItem() != null) {
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
}
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
selectedPane.setVisible(isSelected());
}
private void updateDisplay(T item, boolean empty) {
/// Updates the text and graphic for the current value and tree item.
///
/// @implSpec Called when the cell value or the current tree item's graphic changes.
/// Subclasses may override this method to decorate the default display.
///
/// @param item the cell value, or `null` to clear the display
/// @param empty whether the cell has no item
protected void updateDisplay(@Nullable T item, boolean empty) {
if (item == null || empty) {
hbox = null;
setText(null);
setGraphic(null);
} else {
TreeItem<T> treeItem = getTreeItem();
@Nullable TreeItem<T> treeItem = getTreeItem();
if (treeItem != null && treeItem.getGraphic() != null) {
if (item instanceof Node) {
setText(null);
@@ -12,35 +12,25 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.transitions.CachedTransition;
import com.jfoenix.transitions.JFXFillTransition;
import com.jfoenix.utils.JFXNodeUtils;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.animation.*;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.VPos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.skin.CheckBoxSkin;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
public class JFXCheckBoxSkin extends CheckBoxSkin {
private final StackPane box = new StackPane();
private final StackPane mark = new StackPane();
private final SVGPath markShape = new SVGPath();
private final double lineThick = 2.0;
private final double padding = 10.0;
private final JFXRippler rippler;
@@ -48,7 +38,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
private final double labelOffset = -8.0;
private final Transition transition;
private boolean invalid = true;
private JFXFillTransition select;
private boolean selected;
private boolean indeterminate;
/// The checkbox fill animation, initialized with the configured checked color.
private JFXFillTransition fillTransition;
public JFXCheckBoxSkin(JFXCheckBox control) {
super(control);
@@ -61,22 +55,17 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
boxContainer.getChildren().add(this.box);
boxContainer.setPadding(new Insets(this.padding));
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
this.updateRippleColor();
SVGPath shape = new SVGPath();
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setShape(shape);
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
this.mark.setShape(this.markShape);
this.updateMarkShape(true, false);
this.mark.setVisible(false);
this.mark.setScaleX(0.0);
this.mark.setScaleY(0.0);
boxContainer.getChildren().add(this.mark);
this.container.getChildren().add(this.rippler);
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
control.selectedProperty().addListener((o, oldVal, newVal) -> {
this.updateRippleColor();
this.playSelectAnimation(newVal);
});
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
if (focusVisibleProperty == null)
@@ -93,37 +82,63 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
this.updateChildren();
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
if (select != null) {
select.stop();
if (fillTransition != null) {
fillTransition.stop();
}
this.createFillTransition();
updateColors();
boolean selected = control.isSelected();
boolean indeterminate = control.isIndeterminate();
JFXFillTransition fillTransition = this.createFillTransition();
this.fillTransition = fillTransition;
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
updateColors(selected, indeterminate);
});
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
this.transition = new CheckBoxTransition();
this.createFillTransition();
this.fillTransition = this.createFillTransition();
}
private void updateRippleColor() {
var control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(control.isSelected()
private void requestSelectionUpdate() {
this.invalid = true;
this.getSkinnable().requestLayout();
}
private void updateRippleColor(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.rippler.setRipplerFill(selected || indeterminate
? control.getCheckedColor()
: control.getUnCheckedColor());
}
private void updateColors() {
private void updateColors(boolean selected, boolean indeterminate) {
var control = (JFXCheckBox) getSkinnable();
boolean isSelected = control.isSelected();
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
boolean marked = selected || indeterminate;
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
this.updateBorder(selected, indeterminate);
}
private void updateBorder(boolean selected, boolean indeterminate) {
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
box.setBorder(new Border(new BorderStroke(
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
borderStroke.getTopStyle(),
borderStroke.getRadii(),
borderStroke.getWidths())));
}
private void updateMarkShape(boolean selected, boolean indeterminate) {
if (indeterminate) {
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
this.mark.setMaxSize(10.0, 2.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
} else if (selected) {
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
this.mark.setMaxSize(15.0, 12.0);
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
}
}
protected void updateChildren() {
super.updateChildren();
if (this.container != null) {
@@ -151,9 +166,10 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
if (this.invalid) {
if (this.getSkinnable().isSelected()) {
this.playSelectAnimation(true);
}
boolean selected = checkBox.isSelected();
boolean indeterminate = checkBox.isIndeterminate();
this.updateRippleColor(selected, indeterminate);
this.playSelectAnimation(selected, indeterminate);
this.invalid = false;
}
@@ -180,27 +196,85 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
};
}
private void playSelectAnimation(Boolean selection) {
if (selection == null) {
selection = false;
private void playSelectAnimation(boolean selected, boolean indeterminate) {
boolean previousSelected = this.selected;
boolean previousIndeterminate = this.indeterminate;
this.selected = selected;
this.indeterminate = indeterminate;
boolean previousMarked = previousSelected || previousIndeterminate;
boolean marked = selected || indeterminate;
boolean sameVisualState = previousIndeterminate == indeterminate
&& (indeterminate || previousSelected == selected);
this.updateBorder(selected, indeterminate);
if (sameVisualState) {
return;
}
this.invalid = false;
if (!AnimationUtils.isAnimationEnabled()) {
finishSelectionAnimation(selected, indeterminate);
return;
}
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
this.transition.setRate(selection ? 1.0 : -1.0);
this.select.setRate(selection ? 1.0 : -1.0);
if (previousMarked && marked) {
this.transition.stop();
this.transition.setOnFinished(null);
this.transition.jumpTo(this.transition.getCycleDuration());
this.transition.setRate(-1.0);
this.transition.setOnFinished(event -> {
this.transition.setOnFinished(null);
this.updateMarkShape(selected, indeterminate);
this.transition.jumpTo(Duration.ZERO);
this.transition.setRate(1.0);
this.transition.play();
});
this.transition.play();
return;
}
this.transition.setOnFinished(null);
if (marked) {
this.updateMarkShape(selected, indeterminate);
}
this.transition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.setRate(marked ? 1.0 : -1.0);
this.fillTransition.play();
this.transition.play();
this.select.play();
this.box.setBorder(new Border(new BorderStroke(
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
BorderStrokeStyle.SOLID,
new CornerRadii(2.0),
new BorderWidths(this.lineThick))));
}
private void createFillTransition() {
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
/// Stops selection animations and immediately displays the checkbox's current state.
///
/// The state indicator, fill, and border are updated even if no animation is running.
/// This also suppresses pending selection updates and the initial selection animation.
/// The skin must not have been disposed.
public void finishSelectionAnimation() {
CheckBox checkBox = this.getSkinnable();
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
}
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
this.selected = selected;
this.indeterminate = indeterminate;
boolean marked = selected || indeterminate;
transition.stop();
transition.setOnFinished(null);
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
fillTransition.stop();
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
invalid = false;
updateMarkShape(selected, indeterminate);
mark.setVisible(marked);
mark.setScaleX(marked ? 1.0 : 0.0);
mark.setScaleY(marked ? 1.0 : 0.0);
updateColors(selected, indeterminate);
}
private JFXFillTransition createFillTransition() {
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
this.select.setInterpolator(Interpolator.EASE_OUT);
fillTransition.setInterpolator(Interpolator.EASE_OUT);
return fillTransition;
}
private final class CheckBoxTransition extends CachedTransition {
@@ -21,6 +21,7 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ComboBoxBase;
import javafx.scene.control.ListView;
import javafx.scene.control.skin.VirtualFlow;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -28,6 +29,7 @@ import javafx.scene.paint.Paint;
import javafx.scene.text.Text;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.List;
@@ -178,6 +180,11 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
comboBox.hide();
});
Platform.runLater(() -> {
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
FXUtils.smoothScrolling(virtualFlow);
}
});
}
}
}
@@ -0,0 +1,731 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.svg.SVGGlyph;
import com.jfoenix.transitions.CachedTransition;
import javafx.animation.*;
import javafx.animation.Animation.Status;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.chrono.ChronoLocalDate;
import java.time.chrono.Chronology;
import java.time.format.DateTimeFormatter;
import java.time.format.DecimalStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.IntStream;
public class JFXDatePickerContent extends VBox {
private static final String SPINNER_LABEL = "spinner-label";
private static final String ROBOTO = "Roboto";
protected JFXDatePicker datePicker;
private JFXButton backMonthButton;
private JFXButton forwardMonthButton;
private Label selectedDateLabel;
private Label selectedYearLabel;
private Label monthYearLabel;
protected GridPane contentGrid;
private final StackPane calendarPlaceHolder = new StackPane();
private final CachedTransition showTransition;
private final CachedTransition hideTransition;
private ParallelTransition tempImageTransition;
private final int daysPerWeek = 7;
private final List<DateCell> weekDaysCells = new ArrayList<>();
private final List<DateCell> weekNumberCells = new ArrayList<>();
protected List<DateCell> dayCells = new ArrayList<>();
private LocalDate[] dayCellDates;
private DateCell currentFocusedDayCell = null;
private final ListView<String> yearsListView = new JFXListView<>() {
{
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
this.setCellFactory((listView) -> new ListCell<>() {
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
final Label label = new Label();
final StackPane root;
final RipplerContainer ripplerContainer;
final WeakListenerHolder holder = new WeakListenerHolder();
{
label.getStyleClass().add(SPINNER_LABEL);
label.setMaxWidth(Double.MAX_VALUE);
label.textProperty().bind(itemProperty());
StackPane pane = new StackPane(label);
pane.setPadding(new Insets(7));
ripplerContainer = new RipplerContainer(pane);
root = new StackPane(ripplerContainer);
root.getStyleClass().add("data-picker-list-cell");
this.setGraphic(root);
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
if (newVal != null && getItem() != null && getItem().equals(newVal))
updateSelected(true);
});
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
itemProperty().addListener(selectedListenerWeak);
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
if (getListView() != null) {
if (selected) {
getListView().getSelectionModel().select(getIndex());
} else {
getListView().getSelectionModel().clearSelection(getIndex());
}
}
root.pseudoClassStateChanged(SELECTED, selected);
if (selected) {
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
}
});
}
public void updateItem(String item, boolean empty) {
String oldItem = getItem();
boolean oldEmpty = isEmpty();
super.updateItem(item, empty);
setText(null);
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
ripplerContainer.resetFeedback();
if (!empty && item != null) {
setGraphic(root);
} else {
setGraphic(null);
}
}
});
}
};
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
JFXDatePickerContent(final DatePicker datePicker) {
this.datePicker = (JFXDatePicker) datePicker;
this.getStyleClass().add("date-picker-popup");
LocalDate date = datePicker.getValue();
this.valueProperty.set(date);
this.valueProperty.addListener((o, old, n) -> {
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
this.updateValues();
});
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
this.getChildren().add(this.createHeaderPane());
this.getChildren().add(new Separator());
this.contentGrid = new GridPane() {
protected double computePrefWidth(double height) {
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
}
protected void layoutChildren() {
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
super.layoutChildren();
}
}
};
this.contentGrid.setFocusTraversable(true);
this.contentGrid.getStyleClass().add("calendar-grid");
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
this.contentGrid.setVgap(4);
this.contentGrid.setHgap(4);
this.createWeekDaysCells();
this.createDayCells();
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
StackPane main = new StackPane();
this.yearsListView.setVisible(false);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
main.getChildren().setAll(this.contentGrid, this.yearsListView);
VBox contentHolder = new VBox();
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
this.calendarPlaceHolder.getStyleClass().add("content");
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
this.getChildren().add(this.calendarPlaceHolder);
this.getChildren().add(this.createActions());
this.refresh();
this.addEventHandler(KeyEvent.ANY, (event) -> {
Node node = this.getScene().getFocusOwner();
if (node instanceof DateCell) {
this.currentFocusedDayCell = (DateCell) node;
}
switch (event.getCode()) {
case HOME -> {
this.init();
this.goToDate(LocalDate.now(), true);
event.consume();
}
case PAGE_UP -> {
if (!this.backMonthButton.isDisabled()) {
this.forward(-1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case PAGE_DOWN -> {
if (!this.forwardMonthButton.isDisabled()) {
this.forward(1, ChronoUnit.MONTHS, true, true);
}
event.consume();
}
case ESCAPE -> {
fireEvent(new DialogCloseEvent());
event.consume();
}
default -> event.consume();
}
});
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void starting() {
super.starting();
JFXDatePickerContent.this.yearsListView.setVisible(true);
}
};
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
{
this.setCycleDuration(Duration.millis(320.0F));
this.setDelay(Duration.seconds(0.0F));
}
protected void stopping() {
super.stopping();
JFXDatePickerContent.this.yearsListView.setVisible(false);
}
};
}
ObjectProperty<YearMonth> displayedYearMonthProperty() {
return this.selectedYearMonth;
}
private void createWeekDaysCells() {
for (int i = 0; i < this.daysPerWeek; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("day-name-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
cell.setAlignment(Pos.BASELINE_CENTER);
this.weekDaysCells.add(cell);
}
for (int i = 0; i < 6; ++i) {
DateCell cell = new DateCell();
cell.getStyleClass().add("week-number-cell");
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.weekNumberCells.add(cell);
}
}
protected VBox createHeaderPane() {
Label title = new Label(I18n.i18n("button.select_date"));
title.getStyleClass().add("title");
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
HBox titleContainer = new HBox(title);
titleContainer.setAlignment(Pos.CENTER_LEFT);
titleContainer.setPadding(new Insets(0, 0, 5, 0));
this.selectedYearLabel = new Label();
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
HBox yearLabelContainer = new HBox();
yearLabelContainer.getStyleClass().add("spinner");
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
yearLabelContainer.setFillHeight(false);
yearLabelContainer.setOnMouseClicked((click) -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
}
});
this.selectedDateLabel = new Label();
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
HBox selectedDateContainer = new HBox();
selectedDateContainer.getStyleClass().add("spinner");
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
selectedDateContainer.setOnMouseClicked((click) -> {
if (this.yearsListView.isVisible()) {
this.showTransition.stop();
this.hideTransition.play();
}
});
VBox headerPanel = new VBox();
headerPanel.getStyleClass().add("month-year-pane");
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
return headerPanel;
}
protected BorderPane createCalendarArrowsPane() {
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
leftChevron.setSize(6.0F, 11.0F);
rightChevron.setSize(6.0F, 11.0F);
this.backMonthButton = new JFXButton();
this.backMonthButton.setMinSize(40.0F, 40.0F);
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.backMonthButton.getStyleClass().add("left-button");
this.backMonthButton.setGraphic(leftChevron);
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
this.forwardMonthButton = new JFXButton();
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
this.forwardMonthButton.getStyleClass().add("right-button");
this.forwardMonthButton.setGraphic(rightChevron);
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
BorderPane arrowsContainer = new BorderPane();
arrowsContainer.setLeft(this.backMonthButton);
arrowsContainer.setRight(this.forwardMonthButton);
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
arrowsContainer.setPickOnBounds(false);
return arrowsContainer;
}
protected BorderPane createCalendarMonthLabelPane() {
this.monthYearLabel = new Label();
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
SVGContainer svgContainer = new SVGContainer(13);
HBox box = new HBox(monthYearLabel, svgContainer);
box.setSpacing(3);
box.setAlignment(Pos.CENTER);
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
FXUtils.onClicked(box, () -> {
if (!this.yearsListView.isVisible()) {
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
this.hideTransition.stop();
this.showTransition.play();
} else {
this.showTransition.stop();
this.hideTransition.play();
}
});
BorderPane monthContainer = new BorderPane();
monthContainer.setMinHeight(50.0F);
monthContainer.setCenter(box);
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
return monthContainer;
}
protected HBox createActions() {
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
cancelButton.getStyleClass().add("dialog-cancel");
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
acceptButton.getStyleClass().add("dialog-accept");
FXUtils.onClicked(acceptButton, () -> {
datePicker.setValue(valueProperty.get());
fireEvent(new DialogCloseEvent());
});
HBox hBox = new HBox(cancelButton, acceptButton);
hBox.setPadding(new Insets(0, 5, 3, 0));
hBox.setSpacing(5);
hBox.setAlignment(Pos.CENTER_RIGHT);
return hBox;
}
void updateContentGrid() {
this.contentGrid.getColumnConstraints().clear();
this.contentGrid.getChildren().clear();
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
ColumnConstraints columnConstraints = new ColumnConstraints();
columnConstraints.setPercentWidth(100.0F);
for (int i = 0; i < colsNumber; ++i) {
this.contentGrid.getColumnConstraints().add(columnConstraints);
}
for (int i = 0; i < this.daysPerWeek; ++i) {
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
}
if (this.datePicker.isShowWeekNumbers()) {
for (int i = 0; i < 6; ++i) {
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
}
}
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
}
}
}
private void refresh() {
this.updateDayNameCells();
this.updateValues();
}
private void updateDayNameCells() {
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
for (int i = 0; i < this.daysPerWeek; ++i) {
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
}
this.weekDaysCells.get(i).setText(name);
}
}
void updateValues() {
this.updateWeekNumberDateCells();
this.updateDayCells();
this.updateMonthYearPane();
}
void updateWeekNumberDateCells() {
if (this.datePicker.isShowWeekNumbers()) {
Locale locale = this.getLocale();
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
for (int i = 0; i < 6; ++i) {
LocalDate date = firstDayOfMonth.plusWeeks(i);
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
this.weekNumberCells.get(i).setText(weekNumber);
}
}
}
private void updateDayCells() {
Locale locale = this.getLocale();
Chronology chrono = this.getPrimaryChronology();
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
YearMonth currentYearMonth = this.selectedYearMonth.get();
int daysInCurMonth = -1;
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
DateCell dayCell = this.dayCells.get(i);
try {
if (daysInCurMonth == -1) {
daysInCurMonth = currentYearMonth.lengthOfMonth();
}
int dayIndex = i - firstOfMonthIndex + 1;
LocalDate date;
if (dayIndex <= 0) {
YearMonth previous = currentYearMonth.minusMonths(1);
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
} else if (dayIndex > daysInCurMonth) {
YearMonth next = currentYearMonth.plusMonths(1);
date = next.atDay(dayIndex - daysInCurMonth);
} else {
date = currentYearMonth.atDay(dayIndex);
}
this.dayCellDates[i] = date;
if (date.equals(LocalDate.now())) {
dayCell.getStyleClass().add("today");
} else {
dayCell.getStyleClass().removeAll("today");
}
if (date.equals(valueProperty.get())) {
dayCell.getStyleClass().add("selected");
this.currentFocusedDayCell = dayCell;
} else {
dayCell.getStyleClass().removeAll("selected");
}
ChronoLocalDate cDate = chrono.date(date);
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
dayCell.setText(cellText);
if (i < firstOfMonthIndex) {
dayCell.getStyleClass().add("previous-month");
dayCell.getStyleClass().removeAll("next-month");
dayCell.setDisable(true);
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
dayCell.getStyleClass().add("next-month");
dayCell.getStyleClass().removeAll("previous-month");
dayCell.setDisable(true);
} else {
dayCell.getStyleClass().removeAll("previous-month", "next-month");
dayCell.setDisable(false);
}
dayCell.updateItem(date, false);
} catch (DateTimeException var13) {
dayCell.setText("");
dayCell.setDisable(true);
}
}
}
protected void updateMonthYearPane() {
YearMonth yearMonth = this.selectedYearMonth.get();
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
this.selectedYearLabel.setText(this.formatYear(yearMonth));
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
Chronology chrono = this.datePicker.getChronology();
LocalDate firstDayOfMonth = yearMonth.atDay(1);
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
}
private String formatMonth(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
private String formatYear(YearMonth yearMonth) {
try {
Chronology chrono = this.getPrimaryChronology();
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
} catch (DateTimeException var4) {
return "";
}
}
protected LocalDate dayCellDate(DateCell dateCell) {
assert this.dayCellDates != null;
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
}
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
this.getParent().setManaged(false);
SnapshotParameters snapShotparams = new SnapshotParameters();
snapShotparams.setFill(Color.TRANSPARENT);
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
ImageView tempImage = new ImageView(temp);
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
contentTransition.setToX(0.0F);
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
this.tempImageTransition.setOnFinished((finish) -> {
this.calendarPlaceHolder.getChildren().remove(tempImage);
this.getParent().setManaged(true);
});
this.tempImageTransition.play();
}
YearMonth yearMonth = this.selectedYearMonth.get();
DateCell dateCell = this.currentFocusedDayCell;
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
}
this.goToDayCell(dateCell, offset, unit, focusDayCell);
}
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
}
private void goToDate(LocalDate date, boolean focusDayCell) {
if (this.isValidDate(this.datePicker.getChronology(), date)) {
this.valueProperty.set(date);
this.selectedYearMonth.set(YearMonth.from(date));
if (focusDayCell) {
this.findDayCellOfDate(date).requestFocus();
}
}
}
private void selectDayCell(DateCell dateCell) {
this.valueProperty.set(this.dayCellDate(dateCell));
}
private DateCell findDayCellOfDate(LocalDate date) {
for (int i = 0; i < this.dayCellDates.length; ++i) {
if (date.equals(this.dayCellDates[i])) {
return this.dayCells.get(i);
}
}
return this.dayCells.get(this.dayCells.size() / 2 + 1);
}
void init() {
this.contentGrid.setOpacity(1.0F);
this.yearsListView.setOpacity(0.0F);
this.yearsListView.setVisible(false);
}
void clearFocus() {
LocalDate focusDate = this.valueProperty.get();
if (focusDate == null) {
focusDate = LocalDate.now();
}
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
this.goToDate(focusDate, true);
}
}
protected void createDayCells() {
for (int row = 0; row < 6; ++row) {
for (int col = 0; col < this.daysPerWeek; ++col) {
DateCell dayCell = this.createDayCell();
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
if (click.getButton() == MouseButton.PRIMARY) {
DateCell selectedDayCell = (DateCell) click.getSource();
this.selectDayCell(selectedDayCell);
}
});
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
dayCell.setAlignment(Pos.BASELINE_CENTER);
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
this.dayCells.add(dayCell);
}
}
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
this.updateContentGrid();
}
private DateCell createDayCell() {
DateCell dayCell = null;
if (this.datePicker.getDayCellFactory() != null) {
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
}
if (dayCell == null) {
dayCell = new DateCell();
}
dayCell.getStyleClass().add("day-cell");
dayCell.setPrefSize(36.0F, 36.0F);
dayCell.setDisable(false);
dayCell.setStyle(null);
dayCell.setGraphic(null);
dayCell.setTooltip(null);
return dayCell;
}
protected Chronology getPrimaryChronology() {
return this.datePicker.getChronology();
}
protected Locale getLocale() {
return SettingsManager.settings().languageProperty().get().getLocale();
}
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
}
protected boolean isValidDate(Chronology chrono, LocalDate date) {
try {
if (date != null) {
chrono.date(date);
}
return true;
} catch (DateTimeException var4) {
return false;
}
}
}
@@ -0,0 +1,181 @@
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.jfoenix.skins;
import com.jfoenix.adapters.ReflectionHelper;
import com.jfoenix.adapters.skins.DatePickerSkin;
import com.jfoenix.controls.JFXDatePicker;
import com.jfoenix.controls.JFXDialog;
import com.jfoenix.controls.JFXDialog.DialogTransition;
import javafx.beans.value.ChangeListener;
import javafx.event.ActionEvent;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Stack;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class JFXDatePickerSkin extends DatePickerSkin {
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
private final JFXDatePicker jfxDatePicker;
private TextField displayNode;
private JFXDatePickerContent content;
private JFXDialog dialog;
public JFXDatePickerSkin(JFXDatePicker datePicker) {
super(datePicker);
this.jfxDatePicker = datePicker;
try {
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
int i = changeListeners.length - 1;
if (i >= 0) {
while (i >= 0 && changeListeners[i] == null) {
--i;
}
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
}
} catch (NullPointerException e) {
LOG.warning("Cannot remove focusedProperty listener", e);
}
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
if (this.getEditor() != null && !newVal) {
this.setTextFromTextFieldIntoComboBoxValue2();
}
});
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
arrowBox.setAlignment(Pos.CENTER);
this.setArrow(arrowBox);
this.getArrowButton().getChildren().setAll(arrowBox);
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
this.updateDisplayNode2();
this.content = null;
this.setPopup2(null);
});
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
if (this.jfxDatePicker.isShowing()) {
if (this.content != null) {
LocalDate date = this.jfxDatePicker.getValue();
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
this.content.updateValues();
}
this.show();
} else {
this.hide();
}
});
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
if (this.content != null) {
this.content.updateContentGrid();
this.content.updateWeekNumberDateCells();
}
});
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
this.updateDisplayNode2();
this.jfxDatePicker.fireEvent(new ActionEvent());
});
}
@Override
public JFXDatePickerContent getPopupContent() {
if (this.content == null) {
this.content = new JFXDatePickerContent(this.jfxDatePicker);
}
return this.content;
}
@Override
public void show() {
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
super.show();
}
if (this.content != null) {
this.content.init();
this.content.clearFocus();
}
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
if (dialogParent == null) {
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
}
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
@Override
public void close() {
super.close();
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
if (o instanceof Stack<?> stack) {
stack.pop();
}
jfxDatePicker.hide();
}
};
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
getArrowButton().setOnMouseClicked((click) -> {
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
StackPane parent = this.jfxDatePicker.getDialogParent();
if (parent == null) {
parent = (StackPane) this.getSkinnable().getScene().getRoot();
}
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
contentStack.push(this.getPopupContent());
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
this.dialog.show(parent);
}
});
}
}
JFXDialog getDialog() {
return this.dialog;
}
@Override
public Node getDisplayNode() {
if (this.displayNode == null) {
this.displayNode = this.getEditableInputNode2();
this.displayNode.getStyleClass().add("date-picker-display-node");
this.updateDisplayNode2();
}
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
return this.displayNode;
}
public void syncWithAutoUpdate() {
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
this.jfxDatePicker.hide();
}
}
}
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
arc = new Arc();
arc.setManaged(false);
arc.setLength(180);
arc.setLength(-180);
arc.getStyleClass().setAll("arc");
arc.setFill(Color.TRANSPARENT);
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
frames.add(new KeyFrame(Duration.seconds(duration),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 45 + startingAngle,
-(angle + 45 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 90 + startingAngle,
-(angle + 90 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 135 + startingAngle,
-(angle + 135 + startingAngle),
Interpolator.LINEAR)));
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
angle + 435 + startingAngle,
-(angle + 435 + startingAngle),
Interpolator.LINEAR)));
}
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
addKeyFrames(keyFrames, 900, 2.8);
addKeyFrames(keyFrames, 1350, 4.2);
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(),
1845 + startingAngle,
-(1845 + startingAngle),
Interpolator.LINEAR)));
timeline = new Timeline();
timeline.getKeyFrames().setAll(keyFrames);
} else {
final double arcLength = 250;
final double arcLength = -250;
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
new KeyFrame(Duration.seconds(1.2),
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
);
}
@@ -0,0 +1,587 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package com.jfoenix.skins;
import com.jfoenix.controls.JFXSwitch;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.css.StyleOrigin;
import javafx.css.StyleableProperty;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.SVGPath;
import javafx.scene.transform.Scale;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.Motion;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/// Renders a Material Design 3 switch and handles input.
/// Geometry uses unscaled MD3 units and is mirrored in RTL layouts.
@NotNullByDefault
public final class JFXSwitchSkin extends SkinBase<JFXSwitch> {
/// Track width in unscaled MD3 artwork coordinates.
private static final double TRACK_WIDTH = 52;
/// Track height corresponding to the control's default `-jfx-size`.
private static final double TRACK_HEIGHT = 32;
/// Unscaled interaction target height.
private static final double TARGET_HEIGHT = 48;
/// Vertical center shared by the track, handle, state layer, and icon.
private static final double CENTER_Y = TARGET_HEIGHT / 2;
/// Handle center at the leading end of the track.
private static final double OFF_CENTER = TRACK_HEIGHT / 2;
/// Handle center at the trailing end of the track.
private static final double ON_CENTER = TRACK_WIDTH - OFF_CENTER;
/// Diameter of the unselected handle without an icon.
private static final double OFF_DIAMETER = 16;
/// Diameter of a selected handle or a handle containing an icon.
private static final double ON_DIAMETER = 24;
/// Handle diameter while pressed or dragged.
private static final double PRESSED_DIAMETER = 28;
/// Radius of the hover, focus, and pressed overlay.
private static final double STATE_LAYER_RADIUS = 20;
/// Minimum pointer movement in control pixels before a press becomes a drag.
private static final double DRAG_THRESHOLD = 3;
/// Indicates visible keyboard focus.
private static final PseudoClass VISUAL_FOCUSED = PseudoClass.getPseudoClass("visual-focused");
/// The rendered switch.
private final JFXSwitch control;
/// Unmanaged switch artwork.
private final Pane artwork = new Pane();
/// Scales artwork from its leading top corner.
private final Scale artworkScale = new Scale();
/// Rounded track with its optional outline.
private final Rectangle track = new Rectangle(0, (TARGET_HEIGHT - TRACK_HEIGHT) / 2, TRACK_WIDTH, TRACK_HEIGHT);
/// Interaction feedback behind the handle.
private final Circle stateLayer = new Circle(OFF_CENTER, CENTER_Y, STATE_LAYER_RADIUS);
/// Switch handle.
private final Circle handle = new Circle(OFF_CENTER, CENTER_Y, OFF_DIAMETER / 2);
/// Optional handle icon.
private @Nullable SVGPath icon;
/// Current logical handle center measured from the track's leading edge.
private final DoubleProperty center = new SimpleDoubleProperty(OFF_CENTER) {
/// Updates handle position.
@Override
protected void invalidated() {
layoutHandle();
}
};
/// Unscaled handle diameter.
private final DoubleProperty diameter = new SimpleDoubleProperty(OFF_DIAMETER) {
/// Updates handle size.
@Override
protected void invalidated() {
layoutHandle();
}
};
/// Optional movement and resize animation.
private @Nullable Timeline geometryTransition;
/// Animated CSS paints.
private final List<CssColor> colors = new ArrayList<>();
/// Optional shared color animation.
private @Nullable Transition colorTransition;
/// Whether a selection color update is pending.
private boolean animateColors;
/// Prevents animation writes from being interpreted as new CSS targets.
private boolean applyingColors;
/// Pointer event handler.
private final EventHandler<MouseEvent> mouseHandler = this::handleMouse;
/// Keyboard event handler.
private final EventHandler<KeyEvent> keyHandler = this::handleKey;
/// Whether a primary press is awaiting click or drag completion.
private boolean mouseDown;
/// Whether the handle is following the pointer instead of an animation.
private boolean dragging;
/// Whether a Space press is awaiting release.
private boolean keyDown;
/// Pointer position in unscaled artwork coordinates at press time.
private double pressX;
/// Handle center at press time, preserving the pointer's grab offset.
private double pressCenter;
/// Creates a skin for the switch.
public JFXSwitchSkin(JFXSwitch control) {
super(control);
this.control = control;
artwork.getStyleClass().setAll("switch-artwork");
track.getStyleClass().setAll("switch-track");
stateLayer.getStyleClass().setAll("switch-state-layer");
handle.getStyleClass().setAll("switch-handle");
track.setArcWidth(TRACK_HEIGHT);
track.setArcHeight(TRACK_HEIGHT);
artwork.setManaged(false);
artwork.setMouseTransparent(true);
artwork.getTransforms().add(artworkScale);
artwork.getChildren().setAll(track, stateLayer, handle);
for (var node : artwork.getChildren())
node.setManaged(false);
getChildren().add(artwork);
registerChangeListener(control.selectedProperty(), value -> updateSelection());
registerChangeListener(control.armedProperty(), value -> update(false));
@Nullable ReadOnlyBooleanProperty focusVisible = FXUtils.focusVisibleProperty(control);
ReadOnlyBooleanProperty focusIndicator = focusVisible != null ? focusVisible : control.focusedProperty();
registerChangeListener(focusIndicator, value ->
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed()));
// Match the previous toggle skin: pointer press and release both dismiss focus feedback.
registerChangeListener(control.pressedProperty(), value ->
control.pseudoClassStateChanged(VISUAL_FOCUSED, false));
Consumer<ObservableValue<?>> cancelGesture = value -> cancelGesture();
registerChangeListener(control.disabledProperty(), cancelGesture);
registerChangeListener(control.sceneProperty(), cancelGesture);
registerChangeListener(control.focusedProperty(), value -> {
if (!control.isFocused())
cancelGesture();
});
control.addEventFilter(MouseEvent.ANY, mouseHandler);
control.addEventFilter(KeyEvent.ANY, keyHandler);
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed());
update(true);
}
/// Updates icon and animation options, optionally settling active animations.
public void updateOptions(boolean finishAnimation) {
if (finishAnimation)
finishColors();
update(finishAnimation);
}
/// Stops animations and removes artwork, event handlers, and listeners.
@Override
public void dispose() {
control.removeEventFilter(MouseEvent.ANY, mouseHandler);
control.removeEventFilter(KeyEvent.ANY, keyHandler);
cancelGesture();
for (CssColor color : colors)
color.dispose();
control.pseudoClassStateChanged(VISUAL_FOCUSED, false);
getChildren().remove(artwork);
super.dispose();
}
/// Centers the scaled artwork within the control and starts pending color transitions.
@Override
protected void layoutChildren(double x, double y, double w, double h) {
double scale = sizeScale();
artworkScale.setX(scale);
artworkScale.setY(scale);
// Keep animation and CSS geometry in MD3 units; scale the entire artwork consistently.
artwork.resizeRelocate(x + (w - TRACK_WIDTH * scale) / 2,
y + (h - TARGET_HEIGHT * scale) / 2, TRACK_WIDTH, TARGET_HEIGHT);
layoutHandle();
// JavaFX applies CSS before layout, so all changed paints now share the same start time.
if (animateColors) {
animateColors = false;
for (CssColor color : colors) {
if (color.target != null) {
colorTransition().playFromStart();
break;
}
}
}
}
/// Reserves the scaled track width and control insets.
@Override
protected double computeMinWidth(double h, double top, double right, double bottom, double left) {
return left + TRACK_WIDTH * sizeScale() + right;
}
/// Reserves the scaled interaction target height and control insets.
@Override
protected double computeMinHeight(double w, double top, double right, double bottom, double left) {
return top + TARGET_HEIGHT * sizeScale() + bottom;
}
/// Uses the same fixed width as the minimum size.
@Override
protected double computePrefWidth(double h, double top, double right, double bottom, double left) {
return computeMinWidth(h, top, right, bottom, left);
}
/// Uses the same fixed height as the minimum size.
@Override
protected double computePrefHeight(double w, double top, double right, double bottom, double left) {
return computeMinHeight(w, top, right, bottom, left);
}
/// Limits width to the preferred size.
@Override
protected double computeMaxWidth(double h, double top, double right, double bottom, double left) {
return computePrefWidth(h, top, right, bottom, left);
}
/// Limits height to the preferred size.
@Override
protected double computeMaxHeight(double w, double top, double right, double bottom, double left) {
return computePrefHeight(w, top, right, bottom, left);
}
/// Handles clicking and dragging.
private void handleMouse(MouseEvent event) {
if (event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) {
// Activation happens on release; do not also activate the surrounding settings row.
event.consume();
return;
}
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
if (event.getButton() != MouseButton.PRIMARY || keyDown
|| event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown()
|| event.isMiddleButtonDown() || event.isSecondaryButtonDown())
return;
if (control.isDisabled() || control.selectedProperty().isBound())
return;
if (control.isFocusTraversable())
control.requestFocus();
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
if (point != null) {
mouseDown = true;
pressX = point.getX();
pressCenter = center.get();
control.arm();
}
event.consume();
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED && mouseDown && event.isPrimaryButtonDown()) {
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
if (point == null)
return;
if (!dragging && Math.abs(point.getX() - pressX) * sizeScale() < DRAG_THRESHOLD)
return;
dragging = true;
if (geometryTransition != null)
geometryTransition.stop();
control.arm();
diameter.set(PRESSED_DIAMETER);
center.set(dragCenter(point.getX()));
event.consume();
} else if (event.getEventType() == MouseEvent.MOUSE_RELEASED && event.getButton() == MouseButton.PRIMARY) {
if (!mouseDown) {
event.consume();
return;
}
@Nullable Point2D point = dragging
? artwork.sceneToLocal(event.getSceneX(), event.getSceneY())
: control.sceneToLocal(event.getSceneX(), event.getSceneY());
if (dragging && point != null)
center.set(dragCenter(point.getX()));
boolean activate = point != null && (dragging
? (center.get() >= (OFF_CENTER + ON_CENTER) / 2) != control.isSelected()
: control.isArmed() && control.contains(point));
mouseDown = false;
dragging = false;
control.disarm();
event.consume();
if (activate)
control.fire();
// Action handlers may replace or remove the skin synchronously.
if (control.getSkin() == this)
update(false);
} else if (mouseDown && !dragging) {
if (event.getEventType() == MouseEvent.MOUSE_ENTERED)
control.arm();
else if (event.getEventType() == MouseEvent.MOUSE_EXITED)
control.disarm();
}
}
/// Clamps pointer displacement to the track while preserving the initial grab offset.
private double dragCenter(double pointerX) {
return Math.max(OFF_CENTER, Math.min(ON_CENTER, pressCenter + pointerX - pressX));
}
/// Handles Space activation and Escape cancellation.
private void handleKey(KeyEvent event) {
if (event.getCode() == KeyCode.ESCAPE && event.getEventType() == KeyEvent.KEY_PRESSED) {
if (mouseDown || keyDown) {
boolean awaitingRelease = keyDown;
cancelGesture();
// Ignore auto-repeat until the canceled Space press is released.
keyDown = awaitingRelease;
event.consume();
}
} else if (event.getCode() == KeyCode.SPACE) {
if (event.getEventType() == KeyEvent.KEY_PRESSED) {
if (event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown())
return;
if (!keyDown) {
if (mouseDown || control.isDisabled() || control.selectedProperty().isBound())
return;
keyDown = true;
control.arm();
}
event.consume();
} else if (event.getEventType() == KeyEvent.KEY_RELEASED && keyDown) {
keyDown = false;
boolean activate = control.isArmed();
control.disarm();
event.consume();
if (activate)
control.fire();
}
}
}
/// Cancels input without changing selection.
private void cancelGesture() {
finishColors();
mouseDown = false;
keyDown = false;
dragging = false;
control.disarm();
update(true);
}
/// Updates geometry and schedules selection colors for the next CSS pass.
private void updateSelection() {
update(false);
if (control.isDisableAnimation() || control.isDisabled() || control.getScene() == null)
return;
if (colors.isEmpty()) {
colors.add(new CssColor(track.fillProperty()));
colors.add(new CssColor(handle.fillProperty()));
colors.add(new CssColor(stateLayer.fillProperty()));
colors.add(new CssColor(track.strokeProperty()));
if (icon != null)
colors.add(new CssColor(icon.fillProperty()));
}
if (colorTransition != null)
colorTransition.stop();
// A reversal before the first frame may reapply the visible paint without emitting a change event.
for (CssColor color : colors)
color.target = null;
animateColors = true;
control.requestLayout();
}
/// Updates handle geometry, with optional animation.
private void update(boolean immediate) {
if (dragging)
return;
double currentCenter = center.get();
double currentDiameter = diameter.get();
if (geometryTransition != null)
geometryTransition.stop();
boolean selected = control.isSelected();
boolean pressed = control.isArmed() && !control.isDisabled();
boolean hasIcon = selected ? control.isShowSelectedIcon() : control.isShowUnselectedIcon();
double targetDiameter = pressed ? PRESSED_DIAMETER : selected || hasIcon ? ON_DIAMETER : OFF_DIAMETER;
double targetCenter = selected ? ON_CENTER : OFF_CENTER;
updateIcon(selected, hasIcon);
if (immediate || control.isDisableAnimation() || control.getScene() == null
|| currentCenter == targetCenter && currentDiameter == targetDiameter) {
center.set(targetCenter);
diameter.set(targetDiameter);
} else {
if (geometryTransition == null)
geometryTransition = new Timeline();
// Preserve the visible geometry when a press or reversal interrupts a transition.
center.set(currentCenter);
diameter.set(currentDiameter);
geometryTransition.getKeyFrames().setAll(
new KeyFrame(Duration.ZERO,
new KeyValue(center, currentCenter),
new KeyValue(diameter, currentDiameter)),
new KeyFrame(pressed ? Motion.SHORT2 : Motion.MEDIUM1,
new KeyValue(diameter, targetDiameter, pressed ? Motion.LINEAR : Motion.STANDARD)),
new KeyFrame(Motion.MEDIUM2,
new KeyValue(center, targetCenter, Motion.EASE_OUT_BACK)));
geometryTransition.play();
}
layoutHandle();
}
/// Updates the handle icon, creating it if needed.
private void updateIcon(boolean selected, boolean visible) {
if (icon == null && visible) {
icon = new SVGPath();
icon.getStyleClass().setAll("switch-icon");
icon.setManaged(false);
// The first CSS fill should appear immediately, without a transition from default black.
icon.setFill(null);
if (!colors.isEmpty())
colors.add(new CssColor(icon.fillProperty()));
artwork.getChildren().add(icon);
}
if (icon != null) {
icon.setVisible(visible);
if (visible)
icon.setContent(selected
? "M2 8l4 4 8-8-1.4-1.4L6 9.2 3.4 6.6Z"
: "M4 2.6l4 4 4-4L13.4 4l-4 4 4 4-1.4 1.4-4-4-4 4L2.6 12l4-4-4-4Z");
}
}
/// Positions and sizes the handle and its overlays.
private void layoutHandle() {
double cx = center.get();
stateLayer.setCenterX(cx);
handle.setCenterX(cx);
handle.setRadius(diameter.get() / 2);
if (icon != null && icon.isVisible())
icon.relocate(cx - icon.getLayoutBounds().getWidth() / 2,
CENTER_Y - icon.getLayoutBounds().getHeight() / 2);
}
/// Returns the size scale, or 1 for an invalid size.
private double sizeScale() {
double size = control.getSize();
return Double.isFinite(size) && size > 0 ? size / TRACK_HEIGHT : 1;
}
/// Returns the shared color animation, creating it if needed.
private Transition colorTransition() {
if (colorTransition == null) {
colorTransition = new Transition() {
{
setCycleDuration(Motion.SHORT4);
setInterpolator(Motion.STANDARD);
}
/// Updates only paints with a pending CSS target.
@Override
protected void interpolate(double fraction) {
for (CssColor color : colors)
color.interpolate(fraction);
}
};
colorTransition.setOnFinished(event -> finishColors());
}
return colorTransition;
}
/// Stops color animation and applies pending targets.
private void finishColors() {
animateColors = false;
if (colorTransition != null)
colorTransition.stop();
for (CssColor color : colors)
color.finish();
}
/// Animates a solid paint while preserving CSS precedence.
private final class CssColor implements ChangeListener<@Nullable Paint> {
/// The animated paint property.
private final ObjectProperty<@Nullable Paint> property;
/// Most recent solid CSS target; null means no transition is pending.
private @Nullable Color target;
/// CSS origin of the target paint.
private @Nullable StyleOrigin origin;
/// Starting color.
private Color from = Color.TRANSPARENT;
/// Observes a styleable paint property.
private CssColor(ObjectProperty<@Nullable Paint> property) {
this.property = property;
property.addListener(this);
}
/// Animates selection colors and applies other paint changes immediately.
@Override
public void changed(ObservableValue<? extends @Nullable Paint> observable,
@Nullable Paint previous, @Nullable Paint next) {
if (applyingColors)
return;
// CSS may reapply the same target while the displayed value is still interpolating.
if (target != null && target.equals(next) && previous != null) {
apply(previous);
return;
}
target = null;
if (!animateColors || !(previous instanceof Color start) || !(next instanceof Color end) || start.equals(end))
return;
from = start;
target = end;
origin = ((StyleableProperty<?>) property).getStyleOrigin();
interpolate(0);
}
/// Applies an animation frame.
private void interpolate(double fraction) {
if (target != null)
apply(fraction == 1 ? target : from.interpolate(target, fraction));
}
/// Writes a CSS value while suppressing this transition's own listener.
@SuppressWarnings("unchecked")
private void apply(Paint value) {
applyingColors = true;
try {
((StyleableProperty<Paint>) property).applyStyle(origin, value);
} finally {
applyingColors = false;
}
}
/// Applies and clears the pending target.
private void finish() {
if (target != null) {
apply(target);
target = null;
}
}
/// Removes the paint listener.
private void dispose() {
property.removeListener(this);
}
}
}
@@ -1,186 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package com.jfoenix.skins;
import com.jfoenix.controls.JFXRippler;
import com.jfoenix.controls.JFXRippler.RipplerMask;
import com.jfoenix.controls.JFXRippler.RipplerPos;
import com.jfoenix.controls.JFXToggleButton;
import com.jfoenix.effects.JFXDepthManager;
import com.jfoenix.transitions.JFXAnimationTimer;
import com.jfoenix.transitions.JFXKeyFrame;
import com.jfoenix.transitions.JFXKeyValue;
import javafx.animation.Interpolator;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.control.skin.ToggleButtonSkin;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
import javafx.scene.shape.StrokeLineCap;
import javafx.util.Duration;
/**
* <h1>Material Design ToggleButton Skin</h1>
*
* @author Shadi Shaheen
* @version 1.0
* @since 2016-03-09
*/
public class JFXToggleButtonSkin extends ToggleButtonSkin {
private Runnable releaseManualRippler = null;
private JFXAnimationTimer timer;
private final Circle circle;
private final Line line;
public JFXToggleButtonSkin(JFXToggleButton toggleButton) {
super(toggleButton);
double circleRadius = toggleButton.getSize();
line = new Line();
line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
line.setStartX(0);
line.setStartY(0);
line.setEndX(circleRadius * 2 + 2);
line.setEndY(0);
line.setStrokeWidth(circleRadius * 1.5);
line.setStrokeLineCap(StrokeLineCap.ROUND);
line.setSmooth(true);
circle = new Circle();
circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor());
circle.setCenterX(-circleRadius);
circle.setCenterY(0);
circle.setRadius(circleRadius);
circle.setSmooth(true);
JFXDepthManager.setDepth(circle, 1);
StackPane circlePane = new StackPane();
circlePane.getChildren().add(circle);
circlePane.setPadding(new Insets(circleRadius * 1.5));
JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK);
rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
rippler.setTranslateX(computeTranslation(circleRadius, line));
final StackPane main = new StackPane();
main.getChildren().setAll(line, rippler);
main.setCursor(Cursor.HAND);
// show focus traversal effect
getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
if (newVal) {
releaseManualRippler = rippler.createManualRipple();
} else if (releaseManualRippler != null) {
releaseManualRippler.run();
}
});
toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> {
if (!toggleButton.isDisableVisualFocus()) {
if (newVal) {
if (!getSkinnable().isPressed()) {
rippler.setOverlayVisible(true);
}
} else {
rippler.setOverlayVisible(false);
}
}
});
toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false));
// add change listener to selected property
getSkinnable().selectedProperty().addListener(observable -> {
rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
if (!toggleButton.isDisableAnimation()) {
timer.reverseAndContinue();
} else {
rippler.setTranslateX(computeTranslation(circleRadius, line));
}
});
getSkinnable().setGraphic(main);
timer = new JFXAnimationTimer(
new JFXKeyFrame(Duration.millis(100),
JFXKeyValue.builder()
.setTarget(rippler.translateXProperty())
.setEndValueSupplier(() -> computeTranslation(circleRadius, line))
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(line.strokeProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleLineColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleLineColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build(),
JFXKeyValue.builder()
.setTarget(circle.fillProperty())
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
((JFXToggleButton) getSkinnable()).getToggleColor()
: ((JFXToggleButton) getSkinnable()).getUnToggleColor())
.setInterpolator(Interpolator.EASE_BOTH)
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
.build()
)
);
timer.setCacheNodes(circle, line);
registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor());
}
});
registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> {
if (getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor());
}
});
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
if (!getSkinnable().isSelected()) {
line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor());
}
});
}
private double computeTranslation(double circleRadius, Line line) {
return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2);
}
@Override
public void dispose() {
super.dispose();
timer.dispose();
timer = null;
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.FileSaver;
@@ -23,6 +8,7 @@ import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.TLSSecurity;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.JOptionPane;
@@ -42,6 +28,7 @@ public final class EntryPoint {
private EntryPoint() {
}
/// Initializes TLS protection before downloading dependencies and starting the launcher.
public static void main(String[] args) {
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
@@ -49,11 +36,10 @@ public final class EntryPoint {
createHMCLDirectories();
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
TLSSecurity.install();
checkWine();
setupJavaFXVMOptions();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
if (!isInsideMacAppBundle())
@@ -74,71 +60,6 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
try {
@@ -225,15 +146,8 @@ public final class EntryPoint {
private static void checkWine() {
if (OperatingSystem.isRunningUnderWine()) {
SwingUtils.initLookAndFeel();
LOG.warning("HMCL is running under Wine or its distributions!");
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
showWarning(i18n("fatal.wine_warning"));
}
}
@@ -273,10 +187,21 @@ public final class EntryPoint {
}
}
static void showWarning(String message) {
SwingUtils.initLookAndFeel();
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE, null, null, null);
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
exit(1);
}
}
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
private static void showErrorAndExit(String message) {
static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
@@ -1,22 +1,9 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import com.sun.jna.Pointer;
import com.sun.jna.WString;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
@@ -29,8 +16,6 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.util.Duration;
@@ -38,19 +23,27 @@ import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
import org.jackhuang.hmcl.util.platform.windows.Shell32;
import org.jackhuang.hmcl.util.platform.windows.User32;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.CookieHandler;
@@ -60,10 +53,11 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
@@ -71,6 +65,7 @@ import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The main JavaFX application entry point.
public final class Launcher extends Application {
public static final CookieManager COOKIE_MANAGER = new CookieManager();
@@ -98,18 +93,7 @@ public final class Launcher extends Application {
}
try {
try {
SettingsManager.init();
initializeSettingsRuntime();
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
initializeSettingsRuntime();
// https://lapcatsoftware.com/articles/app-translocation.html
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
@@ -141,10 +125,6 @@ public final class Launcher extends Application {
Platform.setImplicitExit(false);
Controllers.initialize(primaryStage);
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
Themes.applyNativeDarkMode(primaryStage);
UpdateChecker.init();
primaryStage.show();
});
@@ -274,61 +254,14 @@ public final class Launcher extends Application {
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
if (showAlert(AlertType.ERROR,
i18n("fatal.config_loading_failure.unix", owner, command),
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
EntryPoint.exit(1);
}
@Override
public void stop() throws Exception {
Controllers.onApplicationStop();
FileSaver.shutdown();
SettingsManager.shutdown();
LOG.shutdown();
}
public static void main(String[] args) {
if (UpdateHandler.processArguments(args)) {
LOG.shutdown();
return;
}
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
@@ -341,6 +274,10 @@ public final class Launcher extends Application {
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
}
LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName());
try {
LOG.info("Process PID: " + ProcessHandle.current().pid());
} catch (UnsupportedOperationException ignored) {
}
LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET);
LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding"));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
@@ -353,8 +290,8 @@ public final class Launcher extends Application {
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
try {
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
@@ -372,10 +309,27 @@ public final class Launcher extends Application {
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
@Nullable Boolean isRoot = isRootUser();
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
try {
SettingsManager.init();
} catch (SambaException e) {
EntryPoint.showWarning(i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigOwner();
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
EntryPoint.exit(1);
}
setupJavaFXVMOptions();
setupWindowsAppUserModelID();
launch(Launcher.class, args);
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
@@ -408,5 +362,283 @@ public final class Launcher extends Application {
});
}
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
private static void setupWindowsAppUserModelID() {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
return;
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
return;
@Nullable Path thisJar = JarUtils.thisJarPath();
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
return;
try {
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
if (hr < 0) {
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
} else {
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
}
} catch (Throwable e) {
LOG.warning("Failed to set AppUserModelID", e);
}
}
/// Sets JavaFX VM options before the toolkit starts.
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
setUpAnimationFrameRate:
{
if (System.getProperty("javafx.animation.pulse") != null) {
break setUpAnimationFrameRate;
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
int value = Integer.parseInt(animationFrameRate);
if (value <= 0)
throw new NumberFormatException(animationFrameRate);
if (value != 60)
System.setProperty("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
break setUpAnimationFrameRate;
}
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
break setUpAnimationFrameRate;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
if (pointer != null) {
try {
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
if (refreshRate > 0) {
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
if (refreshRate >= 90) {
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
}
}
} finally {
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
}
}
}
}
}
float uiScaleLowerBound;
float uiScaleUpperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
uiScaleLowerBound = 0.25f;
uiScaleUpperBound = 4f;
} else {
uiScaleLowerBound = 0.01f;
uiScaleUpperBound = 10f;
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
float scaleValue = Float.NaN;
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
if ("wayland".equals(xdgSessionType)
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
try {
@Nullable Path xrdb = SystemUtils.which("xrdb");
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
@Nullable String dpiLine;
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
BufferedReader bufferedReader = new BufferedReader(reader);
Stream<String> lines = bufferedReader.lines()) {
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
.findFirst().orElse(null);
}
return dpiLine != null
? dpiLine.substring("Xft.dpi:".length()).trim()
: null;
}, java.time.Duration.ofSeconds(1)) : null;
if (xftDpi != null) {
float dpiValue = Float.parseFloat(xftDpi);
float scale = dpiValue / 96f;
if (scale > 1 && scale <= 10) {
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
scaleValue = scale;
uiScale = Float.toString(scale);
}
}
} catch (Exception e) {
LOG.warning("Failed to read Xft.dpi from xrdb", e);
}
}
}
if (Float.isFinite(scaleValue)) {
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
}
}
private static void checkConfigOwner() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
return;
String userName = System.getProperty("user.name");
Path configDirectory = SettingsManager.localConfigDirectory();
if (!Files.exists(configDirectory)) {
return;
}
String owner;
try {
owner = Files.getOwner(configDirectory).getName();
} catch (IOException ioe) {
LOG.warning("Failed to get file owner", ioe);
return;
}
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
return;
ArrayList<String> files = new ArrayList<>();
files.add(configDirectory.toString());
if (Files.exists(Metadata.HMCL_USER_HOME))
files.add(Metadata.HMCL_USER_HOME.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
SwingUtils.initLookAndFeel();
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
int result = JOptionPane.showOptionDialog(null,
i18n("fatal.config_loading_failure.unix", owner, command),
i18n("message.error"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.ERROR_MESSAGE,
null,
options,
options[0]);
if (result == 0) {
try {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
} catch (Throwable e) {
LOG.warning("Failed to copy command to clipboard", e);
}
}
EntryPoint.exit(1);
}
private static @Nullable Boolean isRootUser() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS -> {
WinReg reg = WinReg.INSTANCE;
if (reg == null)
return null;
try {
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
} catch (Exception e) {
LOG.warning("Failed to check Windows administrator privileges", e);
return null;
}
}
case LINUX -> {
Path selfStatus = Paths.get("/proc/self/status");
if (Files.exists(selfStatus)) {
try (var reader = Files.newBufferedReader(selfStatus)) {
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("Uid:")) {
String[] parts = line.split("\t");
if (parts.length >= 3) {
String uid = parts[2].trim();
return "0".equals(uid);
}
}
}
} catch (Exception e) {
LOG.warning("Failed to read " + selfStatus, e);
}
}
}
case MACOS -> {
if (NativeUtils.USE_JNA) {
LibSystem libSystem = LibSystem.INSTANCE;
if (libSystem != null) {
try {
return libSystem.geteuid() == 0;
} catch (Throwable e) {
LOG.warning("Failed to check macOS root privileges", e);
return null;
}
}
}
}
}
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
@Nullable Path id = SystemUtils.which("id");
if (id != null) {
try {
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
} catch (Exception e) {
LOG.warning("Failed to run id -u", e);
return null;
}
}
}
return null;
}
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.StringUtils;
@@ -23,6 +8,7 @@ import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
@@ -37,23 +23,20 @@ public final class Metadata {
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
/// Whether the build metadata identifies this launcher as an official build.
public static final boolean OFFICIAL_BUILD = Boolean.parseBoolean(JarUtils.getAttribute("hmcl.official", "false"));
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
public static final int RECOMMENDED_JAVA_VERSION = 25;
public static final String DOCS_URL = "https://docs.hmcl.net";
public static final String CONTACT_URL = DOCS_URL + "/help.html";
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
@@ -106,7 +89,7 @@ public final class Metadata {
public static @Nullable String getSuggestedJavaDownloadLink() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
return "https://www.loongnix.cn/zh/api/java/downloads-jdk25/index.html";
else {
EnumSet<Architecture> supportedArchitectures;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
@@ -130,4 +113,46 @@ public final class Metadata {
return null;
}
}
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
public static Path getBundledModpackDirectory() {
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
}
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
///
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
/// signal to offer automatic install; the file is removed after a successful install.
///
/// @return the modpack path, or `null` when no package is present
public static @Nullable Path findBundledModpackFile() {
Path directory = getBundledModpackDirectory();
Path zipModpack = directory.resolve("modpack.zip");
if (Files.isRegularFile(zipModpack)) {
return zipModpack;
}
Path mrpackModpack = directory.resolve("modpack.mrpack");
if (Files.isRegularFile(mrpackModpack)) {
return mrpackModpack;
}
return null;
}
/// Server address used when the installed modpack manifest does not provide one.
///
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
public static String getDefaultServerAddress() {
return System.getProperty("hmcl.server.address", "").trim();
}
/// The server name shown in the title bar.
///
/// Can be set with the `hmcl.server.name` system property; empty by default.
public static String getServerName() {
return System.getProperty("hmcl.server.name", "").trim();
}
}
@@ -0,0 +1,473 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon;
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.SoftReference;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
@NotNullByDefault
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
"/assets/addon/mod.csv",
32768
) {
@Override
protected int getFieldsCount() {
return MOD_IDS_INDEX + 1;
}
@Override
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
return new ModProjectInfo(
Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX],
fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX],
fields[SUB_NAME_INDEX],
fields[ABBR_NAME_INDEX],
fields[MOD_IDS_INDEX]
);
}
};
/// Provides metadata for modpacks.
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
"/assets/addon/modpack.csv", 2048
) {
@Override
protected int getFieldsCount() {
return ABBR_NAME_INDEX + 1;
}
@Override
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
}
};
/// Returns the registry for the addon type, or `null` for null or unsupported types.
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
if (type == null) return null;
return switch (type) {
case MOD -> MOD;
case MODPACK -> MODPACK;
default -> null;
};
}
protected static final int MCMOD_ID_INDEX = 0;
protected static final int CURSEFORGE_ID_INDEX = 1;
protected static final int MODRINTH_ID_INDEX = 2;
protected static final int MAIN_NAME_INDEX = 3;
protected static final int SUB_NAME_INDEX = 4;
protected static final int ABBR_NAME_INDEX = 5;
protected static final int MOD_IDS_INDEX = 6;
private final ReentrantLock lock = new ReentrantLock();
private final String assetsPath;
private final int initialCapacity;
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
/// Softly caches projects indexed by mod ID.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
/// Softly caches projects indexed by cleaned secondary name.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
/// Softly caches projects indexed by CurseForge identifier.
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
/// Softly caches searchable names in resource and field order.
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
/// Creates a registry reading records from the given classpath resource.
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
this.assetsPath = assetsPath;
this.initialCapacity = initialCapacity;
}
protected abstract int getFieldsCount();
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
private @Unmodifiable List<I> loadInfos() throws IOException {
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
if (inputStream == null) {
return List.of();
}
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
ArrayList<I> infos = new ArrayList<>(initialCapacity);
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
int fieldsCount = getFieldsCount();
String[] fields = new String[fieldsCount];
@Nullable StringBuilder fieldBuilder = null;
@Nullable String line;
loop:
while ((line = reader.readLine()) != null) {
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int offset = 0;
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
if (offset > line.length()) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
int end = line.indexOf(',', offset);
if (end < 0)
end = line.length();
String field;
if (offset == end) {
field = "";
} else {
int cursor = offset;
while (cursor < end) {
if (line.charAt(cursor) == '\\') {
break;
} else {
cursor++;
}
}
if (cursor == end) {
field = line.substring(offset, end);
} else {
if (fieldBuilder == null) {
fieldBuilder = new StringBuilder(32);
} else {
fieldBuilder.setLength(0);
}
fieldBuilder.append(line, offset, cursor);
while (cursor < end) {
char c = line.charAt(cursor);
if (c == '\\') {
if (cursor < end - 1) {
char next = line.charAt(cursor + 1);
switch (next) {
case '0' -> {
fieldBuilder.append('\0');
cursor += 2;
}
case 'n' -> {
fieldBuilder.append('\n');
cursor += 2;
}
case 'r' -> {
fieldBuilder.append('\r');
cursor += 2;
}
case 't' -> {
fieldBuilder.append('\t');
cursor += 2;
}
case '\\' -> {
fieldBuilder.append('\\');
cursor += 2;
}
case 'c' -> {
fieldBuilder.append(',');
cursor += 2;
}
case 'u' -> {
if (cursor < end - 5) {
try {
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
fieldBuilder.append((char) codePoint);
cursor += 6;
} catch (NumberFormatException e) {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
default -> {
LOG.warning("Invalid line in " + assetsPath + ": " + line);
continue loop;
}
}
} else {
fieldBuilder.append(c);
cursor++;
}
} else {
fieldBuilder.append(c);
cursor++;
}
}
field = fieldBuilder.toString();
}
}
fields[fieldIndex] = field;
offset = end + 1;
}
try {
I info = parseAddonProjectInfo(fields);
info.setKeepAlive(result);
infos.add(info);
} catch (Exception e) {
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
}
}
infos.trimToSize();
return result;
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getModIdMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.modIdMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
if (info instanceof ModProjectInfo mod) {
for (String id : mod.getModIds()) {
if (!id.isEmpty())
map.putIfAbsent(id, info);
}
}
}
cached = Collections.unmodifiableMap(map);
this.modIdMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getSubnameMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.subnameMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String name = cleanSubname(info.subName());
if (!name.isEmpty())
map.putIfAbsent(name, info);
}
cached = Collections.unmodifiableMap(map);
this.subnameMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
/// Duplicate keys retain the first project in resource order.
private @Unmodifiable Map<String, I> getCurseForgeMap() {
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = this.curseForgeMapRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
Map<String, I> map = new HashMap<>();
for (I info : getInfos()) {
String id = info.curseforgeId();
if (StringUtils.isNotBlank(id))
map.putIfAbsent(id, info);
}
cached = Collections.unmodifiableMap(map);
this.curseForgeMapRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns the first project with the exact CurseForge identifier, or `null` if the
/// identifier is null, blank or unknown.
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
}
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
/// A name match is accepted only when the ID is blank or belongs to that mod project.
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
/// Duplicate keys resolve to the first project in resource order.
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
String name = cleanSubname(subname);
if (StringUtils.isNotBlank(name)) {
@Nullable I info = getSubnameMap().get(name);
if (info != null && (StringUtils.isBlank(id)
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
return info;
}
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
}
private static boolean isSupportedSubnameChar(int ch) {
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|| ".+\\".indexOf(ch) >= 0;
}
/// Removes supported separators from a secondary name.
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
private static String cleanSubname(@Nullable String subname) {
if (StringUtils.isBlank(subname))
return "";
int offset = 0;
while (offset < subname.length()) {
int ch = subname.charAt(offset);
if (!isSupportedSubnameChar(ch))
break;
offset++;
}
if (offset == subname.length())
return subname;
StringBuilder builder = new StringBuilder(subname.length());
builder.append(subname, 0, offset);
while (offset < subname.length()) {
int ch = subname.codePointAt(offset);
if (isSupportedSubnameChar(ch)) {
builder.appendCodePoint(ch);
} else if (!(Character.isWhitespace(ch)
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0)) {
return "";
}
offset += Character.charCount(ch);
}
return builder.toString();
}
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
private @Unmodifiable List<Pair<String, I>> getKeywords() {
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
lock.lock();
try {
reference = keywordsRef;
cached = reference != null ? reference.get() : null;
if (cached != null)
return cached;
List<Pair<String, I>> keywords = new ArrayList<>();
for (I info : getInfos()) {
if (!info.mainName().isEmpty())
keywords.add(Pair.pair(info.mainName(), info));
if (!info.subName().isEmpty())
keywords.add(Pair.pair(info.subName(), info));
if (!info.abbrName().isEmpty())
keywords.add(Pair.pair(info.abbrName(), info));
}
cached = List.copyOf(keywords);
keywordsRef = new SoftReference<>(cached);
return cached;
} finally {
lock.unlock();
}
}
/// Returns projects ranked by descending longest common subsequence length after removing
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
/// Matches require at least one unit and at least query length minus three units.
/// Each matching name contributes a result, so projects may occur more than once.
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
public @Unmodifiable List<I> search(String query) {
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
List<Pair<Integer, I>> matches = new ArrayList<>();
for (Pair<String, I> keyword : keywords) {
int score = lcs.calc(query, keyword.getKey());
if (score >= Math.max(1, query.length() - 3))
matches.add(Pair.pair(score, keyword.getValue()));
}
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
.map(Pair::getValue).toList();
}
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
public @Unmodifiable List<I> getInfos() {
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
lock.lock();
try {
infosRef = this.infosRef;
infos = infosRef != null ? infosRef.get() : null;
if (infos != null)
return infos;
try {
infos = loadInfos();
} catch (IOException e) {
LOG.warning("Failed to load addon project info from " + assetsPath, e);
infos = List.of();
}
this.infosRef = new SoftReference<>(infos);
return infos;
} finally {
lock.unlock();
}
}
}
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Pair;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
dependents = addons.stream().map(addon ->
Task.supplyAsync(Schedulers.io(), () -> {
AddonUpdate candidate = null;
AddonUpdate candidateRelease = null;
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
AddonUpdate update = null;
AddonUpdate updateRelease = null;
try {
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
if (u != null) {
update = u.key();
updateRelease = u.value();
}
} catch (IOException e) {
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
}
if (update == null) continue;
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
candidate = update;
if (updateRelease == null) continue;
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
candidateRelease = updateRelease;
}
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
return Pair.pair(candidate, candidateRelease);
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
).toList();
setStage("update.checking");
getProperties().put("total", dependents.size());
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public Collection<? extends Task<?>> getDependents() {
return dependents;
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
.forEachOrdered(pair -> {
commonUpdates.add(pair.key());
if (pair.value() != null) releaseUpdates.add(pair.value());
});
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
}
}
@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.addon.update;
import org.jackhuang.hmcl.addon.LocalAddonFile;
import org.jackhuang.hmcl.addon.LocalAddonManager;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents = new ArrayList<>();
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
setStage("addon.check_update.confirm");
getProperties().put("total", addons.size());
for (AddonUpdate addon : addons) {
LocalAddonFile<?> local = addon.localAddonFile();
RemoteAddon.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String fileName = remote.file().filename();
if (isDisabled)
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
String newFileName = fileName;
Path targetFile = addonDirectory.resolve(newFileName);
dependents.add(Task
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
.thenComposeAsync(tempFile ->
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
.setName(remote.name())
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
.whenComplete(Schedulers.javafx(), exception -> {
try {
Files.deleteIfExists(tempFile);
} catch (Exception e) {
LOG.warning("Failed to delete temp file " + tempFile, e);
}
if (exception != null) {
// restore state if failed
local.setOld(false);
if (isDisabled)
local.markDisabled();
} else {
local.onUpdated(newFileName);
if (!local.keepOldFiles()) {
try {
local.delete();
} catch (IOException e) {
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
}
}
}
})
).whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Failed to update addon", exception);
failedAddons.add(local);
}
}).withCounter("addon.check_update.confirm"));
}
}
public List<LocalAddonFile<?>> getFailedAddons() {
return failedAddons;
}
@Override
public Collection<Task<?>> getDependents() {
return dependents;
}
@Override
public boolean doPreExecute() {
return true;
}
@Override
public void preExecute() {
notifyPropertiesChanged();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (!isDependentsSucceeded())
throw getException();
}
}
@@ -0,0 +1,413 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.download;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.game.AssetObject;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLCacheRepository;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.RegionUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/// @author Glavo
@NotNullByDefault
public final class HMCLDownloadProvider extends DownloadProvider {
private volatile DownloadSource versionListSource = DownloadSource.MIRROR;
private volatile DownloadSource fileSource = DownloadSource.MIRROR;
private static boolean preferMirror(DownloadSource source) {
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
return source != DownloadSource.OFFICIAL;
}
return source == DownloadSource.MIRROR;
}
private static DownloadCandidates getCandidates(
DownloadSource source,
WebURL officialUrl,
List<WebURL> mirrorUrls
) {
if (preferMirror(source)) {
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.addAll(spreadPrimary(mirrorUrls.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
candidates.add(DownloadCandidate.of(officialUrl));
return DownloadCandidates.of(List.copyOf(candidates));
} else {
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
candidates.add(DownloadCandidate.of(officialUrl));
for (WebURL mirrorUrl : mirrorUrls) {
candidates.add(DownloadCandidate.of(mirrorUrl));
}
return DownloadCandidates.of(List.copyOf(candidates));
}
}
/// Rotates the first mirror among the top entries using a stable hash of the URL, so concurrent
/// file downloads spread across healthy mirrors instead of stampeding one entry point.
private static List<DownloadCandidate> spreadPrimary(List<DownloadCandidate> mirrors, String key) {
if (mirrors.size() <= 1)
return mirrors;
int top = Math.min(2, mirrors.size());
int primary = Math.floorMod(key.hashCode(), top);
if (primary == 0)
return mirrors;
var rotated = new ArrayList<DownloadCandidate>(mirrors.size());
rotated.add(mirrors.get(primary));
for (int i = 0; i < mirrors.size(); i++) {
if (i != primary)
rotated.add(mirrors.get(i));
}
return rotated;
}
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
if (urls.isEmpty()) {
throw new IllegalArgumentException("urls cannot be empty");
}
// true means prefer mirror, false means prefer official, null means do not use mirror
@Nullable Boolean mirrorFirst;
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
mirrorFirst = switch (source) {
case DEFAULT, MIRROR -> true;
case OFFICIAL -> false;
};
} else {
mirrorFirst = switch (source) {
case DEFAULT, OFFICIAL -> null;
case MIRROR -> true;
};
}
if (mirrorFirst == null) {
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
}
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
for (String url : urls) {
DownloadCandidate candidate = DownloadCandidate.of(url);
if (candidate.url() == null) {
// Invalid URL, just add it to the list and let the download task handle it.
candidates.add(candidate);
continue;
}
boolean added = false;
for (MirrorRule rule : rules) {
@Nullable List<DownloadCandidate> list = rule.getCandidates(candidate, mirrorFirst);
if (list != null) {
candidates.addAll(list);
added = true;
break;
}
}
if (!added)
candidates.add(candidate);
}
return DownloadCandidates.of(List.copyOf(candidates));
}
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
private final List<MirrorRule> rules;
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
this.rules = List.of(
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-meta.mojang.com", Function.identity()),
new MultiRootRule("https://piston-data.mojang.com", Function.identity()),
new MultiRootRule("https://launcher.mojang.com", Function.identity()),
new MultiRootRule("https://libraries.minecraft.net", root -> root + "/libraries"),
new MultiRootRule("http://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://files.minecraftforge.net/maven", root -> root + "/maven"),
new MultiRootRule("https://maven.minecraftforge.net", root -> root + "/maven"),
(source, mirrorFirst) -> {
String neoforgedMaven = "https://maven.neoforged.net/";
String neoforgedMavenRelease = "https://maven.neoforged.net/releases/";
if (source.url() == null || !source.url().href().startsWith(neoforgedMaven))
return null;
var candidates = new ArrayList<DownloadCandidate>();
List<String> roots = mirrorRoots.get();
if (source.url().href().startsWith(neoforgedMavenRelease)) {
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
List<DownloadCandidate> mirrors = roots.stream()
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
.toList();
if (mirrorFirst) {
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.addAll(mirrors);
}
} else {
candidates.add(source);
}
// https://neoforged.forgecdn.net
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
return candidates;
},
new MultiRootRule("http://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://dl.liteloader.com/versions", root -> root + "/maven"),
new MultiRootRule("https://meta.fabricmc.net", root -> root + "/fabric-meta"),
new MultiRootRule("https://maven.fabricmc.net", root -> root + "/maven"),
new MultiRootRule("https://authlib-injector.yushi.moe", root -> root + "/mirrors/authlib-injector"),
new SimpleRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
new SimpleRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
// https://github.com/mcmod-info-mirror/mcim-rust-api
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
new SimpleRule("https://api.curseforge.com", null, "https://mod.mcimirror.top/curseforge"),
new SimpleRule("https://edge.forgecdn.net", null, "https://mod.mcimirror.top")
);
}
private String primaryMirrorRoot() {
List<String> roots = mirrorRoots.get();
return roots.isEmpty() ? "https://bmclapi2.bangbang93.com" : roots.get(0);
}
private List<WebURL> mirrorUrls(String suffix) {
return mirrorRoots.get().stream()
.map(root -> WebURL.parse(root + suffix))
.toList();
}
@Override
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
assert (gameVersion == null) == (type == GameComponentType.GAME);
String bmclRoot = primaryMirrorRoot();
switch (type) {
case FORGE -> {
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case NEO_FORGE -> {
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
DownloadSource source = versionListSource;
if (source == DownloadSource.OFFICIAL) {
return fetchOfficial;
}
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
if (preferMirror(source)) {
return new FallbackTask<>(fetchBMCL, fetchOfficial);
} else {
return new FallbackTask<>(fetchOfficial, fetchBMCL);
}
}
case OPTIFINE -> {
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
}
}
return super.fetchVersionsAsync(type, gameVersion);
}
public void setFileSource(DownloadSource fileSource) {
this.fileSource = Objects.requireNonNull(fileSource);
}
public void setVersionListSource(DownloadSource versionListSource) {
this.versionListSource = Objects.requireNonNull(versionListSource);
}
@Override
public HMCLCacheRepository getCacheRepository() {
return HMCLCacheRepository.REPOSITORY;
}
@Override
public DownloadCandidates getGameVersionListCandidates() {
return getCandidates(
versionListSource,
GameRemoteVersion.VERSION_MANIFEST_URL,
mirrorUrls("/mc/game/version_manifest.json")
);
}
@Override
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
DownloadSource source = versionListSource;
return new FabricLikeVersionListCandidates(
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
);
}
@Override
public DownloadCandidates getCleanroomVersionListCandidates() {
return "CN".equals(RegionUtils.MACHINE_REGION)
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
}
@Override
public DownloadCandidates getDownloadCandidates(List<String> urls) {
return getCandidates(fileSource, rules, urls);
}
@Override
public DownloadCandidates getVersionListCandidates(List<String> urls) {
return getCandidates(versionListSource, rules, urls);
}
@Override
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
String location = assetObject.getLocation();
return getCandidates(
fileSource,
WebURL.parse("https://resources.download.minecraft.net/" + location),
mirrorUrls("/assets/" + location)
);
}
@NotNullByDefault
private static final class FallbackTask<T> extends Task<T> {
private final Task<? extends T> task;
private final Task<? extends T> fallback;
private List<Task<?>> dependencies = List.of();
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
this.task = task;
this.fallback = fallback;
}
@Override
public Collection<? extends Task<?>> getDependents() {
return List.of(task);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
setResult(task.getResult());
} else {
dependencies = List.of(fallback);
fallback.storeTo(this::setResult);
}
}
@Override
public List<Task<?>> getDependencies() {
return dependencies;
}
}
@NotNullByDefault
@FunctionalInterface
private interface MirrorRule {
@Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst);
}
@NotNullByDefault
private final class MultiRootRule implements MirrorRule {
private final String source;
private final Function<String, String> rootMapper;
private MultiRootRule(String source, Function<String, String> rootMapper) {
this.source = source;
this.rootMapper = rootMapper;
}
@Override
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
if (source.url() == null || !source.url().href().startsWith(this.source))
return null;
String urlWithoutPrefix = source.url().href().substring(this.source.length());
List<DownloadCandidate> mirrors = mirrorRoots.get().stream()
.map(root -> DownloadCandidate.of(rootMapper.apply(root) + urlWithoutPrefix))
.toList();
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 1);
if (mirrorFirst) {
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
candidates.add(source);
} else {
candidates.add(source);
candidates.addAll(mirrors);
}
return candidates;
}
}
@NotNullByDefault
private record SimpleRule(String source, @Nullable String first, @Nullable String fallback) implements MirrorRule {
public SimpleRule(String source, String target) {
this(source, target, null);
}
@Override
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
if (source.url() == null || !source.url().href().startsWith(this.source))
return null;
boolean addSourceFirst = !mirrorFirst || first == null;
String urlWithoutPrefix = source.url().href().substring(this.source.length());
var candidates = new ArrayList<DownloadCandidate>();
if (addSourceFirst)
candidates.add(source);
if (first != null)
candidates.add(DownloadCandidate.of(first + urlWithoutPrefix));
if (!addSourceFirst)
candidates.add(source);
if (fallback != null)
candidates.add(DownloadCandidate.of(fallback + urlWithoutPrefix));
return candidates;
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import javafx.beans.property.SimpleStringProperty;
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DefaultCacheRepository;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jetbrains.annotations.NotNullByDefault;
/// Provides HMCL-specific game builders for an HMCL game repository.
@NotNullByDefault
public class HMCLDependencyManager extends DefaultDependencyManager {
/// Creates a dependency manager for a repository and download context.
///
/// @param repository the associated game repository
/// @param downloadProvider the remote download provider
/// @param cacheRepository the artifact cache
public HMCLDependencyManager(
HMCLGameRepository repository,
DownloadProvider downloadProvider,
DefaultCacheRepository cacheRepository) {
super(repository, downloadProvider, cacheRepository);
}
/// {@inheritDoc}
@Override
public HMCLGameRepository getGameRepository() {
return (HMCLGameRepository) super.getGameRepository();
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
}
/// {@inheritDoc}
@Override
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
validateGameInstance(instance);
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
return new HMCLGameBuilder(
this, updateTarget.getId(), updateTarget, draft, initialManifest);
}
}
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DefaultGameBuilder;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
/// Builds game instances and applies HMCL-specific post-installation settings.
@NotNullByDefault
public class HMCLGameBuilder extends DefaultGameBuilder {
/// Creates a builder around an HMCL target already reserved by its dependency manager.
///
/// @param dependencyManager the dependency manager for the target repository
/// @param instanceId the id of the reserved instance
/// @param updateTarget the exact update target retained by the draft, or `null` for install
/// @param draft the open draft containing `initialManifest`
/// @param initialManifest the empty target manifest retained by `draft`
HMCLGameBuilder(
HMCLDependencyManager dependencyManager,
GameInstanceID instanceId,
@Nullable HMCLGameInstance updateTarget,
DefaultGameRepositoryDraft draft,
GameInstanceManifest initialManifest) {
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
}
/// {@inheritDoc}
///
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
/// launches use the instance root. A read-only instance retains its existing setting.
@Override
protected void onInstanceCommitted(DefaultGameInstance instance) {
if (isolationEnabled) {
((HMCLGameInstance) instance).enableIsolation();
}
}
}
@@ -0,0 +1,848 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectPropertyBase;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
@NotNullByDefault
public class HMCLGameInstance extends DefaultGameInstance {
/// Whether the instance-local game settings file has already been inspected.
private boolean gameSettingsLoaded;
/// Whether the instance-local game settings file cannot be overwritten safely.
private boolean gameSettingsReadOnly;
/// Cached instance-local game settings, or `null` when none exist after loading.
private GameSettings.@Nullable Instance gameSettings;
/// Creates a registered instance bound to the given repository snapshot.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
this(snapshot, id, manifest, (Path) null);
}
/// Creates a registered instance with an optional non-conventional manifest path.
///
/// @param snapshot the repository snapshot that owns this instance
/// @param id the instance id
/// @param manifest the stored instance manifest
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
protected HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
@Nullable Path manifestFile) {
super(snapshot, id, manifest, manifestFile);
}
/// Creates an instance that shares mutable instance-local state with another instance.
///
/// Used when the repository clones a snapshot so that settings and the icon property remain
/// available on the new wrapper.
private HMCLGameInstance(
DefaultGameRepositorySnapshot snapshot,
GameInstanceID id,
GameInstanceManifest manifest,
HMCLGameInstance shareState) {
super(snapshot, id, manifest, shareState);
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
this.gameSettings = shareState.gameSettings;
}
@Override
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
return new HMCLGameInstance(newSnapshot, id, manifest, this);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
/// Returns the HMCL modpack configuration file for this instance.
///
/// @return the `modpack.cfg` path in the instance root
@Override
public Path getModpackConfigurationFile() {
return getLayout().getModpackConfigurationFile(getId());
}
/// Returns whether this instance has an HMCL modpack configuration file.
///
/// @return whether [#getModpackConfigurationFile()] exists
public boolean isModpack() {
return Files.exists(getModpackConfigurationFile());
}
/// Reads this instance's HMCL modpack configuration.
///
/// @return the parsed configuration, or `null` when the file does not exist
/// @throws IOException if the configuration cannot be read
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
Path file = getModpackConfigurationFile();
if (Files.notExists(file)) {
return null;
}
try {
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration: " + file, e);
}
}
@Override
public Path getRunDirectory() {
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
}
/// Returns the loaded instance-local game settings, loading them while this instance is current.
///
/// @return the settings, or `null` when no local settings exist after loading
public @Nullable GameSettings.Instance getSettings() {
ensureGameSettingsLoaded();
return gameSettings;
}
/// Returns the instance-local game settings, creating an empty settings object when absent.
///
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
public @Nullable GameSettings.Instance getSettingsOrCreate() {
GameSettings.Instance setting = getSettings();
if (setting == null) {
setting = createSettings();
}
return setting;
}
/// Resolves this instance's effective settings against its selected parent preset.
///
/// @return the effective settings
public GameSettings.Effective getEffectiveSettings() {
@Nullable GameSettings.Instance setting = getSettings();
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
}
/// Creates empty instance-local game settings when none are loaded.
///
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
public @Nullable GameSettings.Instance createSettings() {
ensureGameSettingsLoaded();
if (gameSettingsReadOnly) {
return null;
}
if (gameSettings != null) {
return gameSettings;
}
return initSettings(new GameSettings.Instance(), true);
}
/// Returns whether the instance-local game settings file cannot be overwritten safely.
///
/// @return whether the settings are loaded in read-only mode
public boolean isSettingsReadOnly() {
ensureGameSettingsLoaded();
return gameSettingsReadOnly;
}
/// Enables instance-local running-directory selection for this instance.
///
/// A blank local running directory resolves to the instance root. This operation is idempotent
/// and schedules a settings save only when it adds the override. It leaves the instance
/// unchanged when its local settings cannot be written safely.
public void enableIsolation() {
if (isSettingsReadOnly()) {
return;
}
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
if (setting != null
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveSettings();
}
}
/// Backs up and overwrites the settings file when this instance still owns its settings.
public void forceOverwriteSettings() {
ensureGameSettingsLoaded();
if (!ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
if (setting == null) {
setting = new GameSettings.Instance();
gameSettings = setting;
gameSettingsLoaded = true;
}
boolean installAutoSave = !setting.isSavable();
Path file = getGameSettingsFile().toAbsolutePath().normalize();
SettingFileUtils.backupInvalidConfig(file);
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
setting.setSavable(true);
setting.setBackupOnNextSave(false);
gameSettingsReadOnly = false;
saveSettings();
if (installAutoSave) {
setting.addListener(a -> saveSettings());
}
}
/// Saves the settings asynchronously when writable and still owned by the current instance.
public void saveSettings() {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
try {
Files.createDirectories(file.getParent());
} catch (IOException e) {
LOG.warning("Failed to create directory: " + file.getParent(), e);
}
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Saves the settings synchronously when writable and still owned by the current instance.
///
/// @throws IOException if saving the file fails
public void saveSettingsSync() throws IOException {
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
return;
}
GameSettings.Instance setting = gameSettings;
Path file = getGameSettingsFile().toAbsolutePath().normalize();
Files.createDirectories(file.getParent());
if (setting.isBackupOnNextSave()) {
setting.setBackupOnNextSave(false);
SettingFileUtils.backupInvalidConfig(file);
}
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
}
/// Initializes this instance with the given settings object.
///
/// @param setting the settings to install
/// @param allowSave whether the settings may be written back to disk
/// @return the installed settings
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
normalizeRunningDirectoryOverride(setting);
setting.setSavable(allowSave);
gameSettingsLoaded = true;
gameSettings = setting;
setting.iconProperty().addListener(observable -> invalidateIconImage());
if (allowSave) {
gameSettingsReadOnly = false;
setting.addListener(a -> saveSettings());
} else {
gameSettingsReadOnly = true;
}
return setting;
}
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
/// the effective parent preset when no local settings exist.
///
/// @return a detached copy suitable for installing into another instance
public GameSettings.Instance copySettings() {
@Nullable GameSettings.Instance setting = getSettings();
if (setting != null) {
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
}
GameSettings.Instance copied = new GameSettings.Instance();
copied.parentProperty().setValue(
getEffectiveSettings().getPreset().idProperty().getValue());
return copied;
}
/// Returns the first custom icon file found in this instance's root directory.
///
/// @return the icon file, or empty when no supported icon file exists
public @Nullable Path getIconFile() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
if (Files.exists(file)) {
return file;
}
}
return null;
}
/// Replaces this instance's custom icon file.
///
/// Existing supported icon files are removed before `iconFile` is copied.
///
/// @param iconFile the source icon file
/// @throws IOException if the icon cannot be copied
/// @throws IllegalArgumentException if the file extension is unsupported
public void setIconFile(Path iconFile) throws IOException {
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
throw new IllegalArgumentException("Unsupported icon file: " + extension);
}
clearIconFiles();
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
invalidateIconImage();
}
/// Deletes all supported custom icon files for this instance.
///
/// Individual deletion failures are logged and do not stop later files from being attempted.
public void deleteIconFile() {
clearIconFiles();
invalidateIconImage();
}
private void clearIconFiles() {
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
Path file = getInstanceRoot().resolve("icon." + extension);
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOG.warning("Failed to delete instance icon file: " + file, e);
}
}
}
/// Soft-cached icon image for this instance id.
///
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
private @Nullable WeakCachedIconImageProperty iconImage;
/// Returns the observable icon image for this instance.
///
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
/// pressure. The next [#getIconImage] reloads it.
///
/// @return the icon image property
public ReadOnlyObjectProperty<Image> iconImageProperty() {
if (iconImage == null) {
iconImage = new WeakCachedIconImageProperty();
}
return iconImage;
}
/// Returns the icon image selected for this instance.
///
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
///
/// @return the selected or derived icon image
public Image getIconImage() {
return iconImageProperty().get();
}
/// Drops the soft-cached icon image and notifies observers.
public void invalidateIconImage() {
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
}
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
private @Nullable WeakReference<Image> cache;
@Override
public Object getBean() {
return HMCLGameInstance.this;
}
@Override
public String getName() {
return "iconImage";
}
@Override
public Image get() {
WeakReference<Image> current = cache;
Image image = current != null ? current.get() : null;
if (image != null) {
return image;
}
image = computeIconImage();
cache = new WeakReference<>(image);
return image;
}
/// Computes the icon image from settings, custom files, and the launch manifest.
///
/// @return the selected or derived icon image
private Image computeIconImage() {
@Nullable GameSettings.Instance setting = getSettings();
GameInstanceIconType iconType = setting != null
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
: GameInstanceIconType.DEFAULT;
if (iconType != GameInstanceIconType.DEFAULT) {
return iconType.getIcon();
}
@Nullable Path iconFile = getIconFile();
if (iconFile != null) {
try {
return FXUtils.loadImage(iconFile, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load instance icon for " + getId(), e);
}
}
for (ModLoaderType modLoader : getModLoaders()) {
return GameInstanceIconType.getIconType(modLoader).getIcon();
}
if (hasComponent(GameComponentType.OPTIFINE))
return GameInstanceIconType.OPTIFINE.getIcon();
GameVersionNumber version = getVersion();
if (version instanceof GameVersionNumber.LegacySnapshot
|| version.isAprilFools()
|| version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
return GameInstanceIconType.COMMAND.getIcon();
else if (version instanceof GameVersionNumber.Old)
return GameInstanceIconType.CRAFT_TABLE.getIcon();
else
return GameInstanceIconType.GRASS.getIcon();
}
/// Clears the weak cache and notifies listeners.
void invalidate() {
cache = null;
fireValueChangedEvent();
}
}
/// Creates the marker indicating that the most recent launch ended abnormally.
public void markLaunchedAbnormally() {
try {
Files.createFile(getInstanceRoot().resolve(".abnormal"));
} catch (IOException ignored) {
}
}
/// Deletes the abnormal-launch marker when present.
///
/// @return whether a regular marker file was present
public boolean unmarkLaunchedAbnormally() {
Path file = getInstanceRoot().resolve(".abnormal");
if (!Files.isRegularFile(file)) {
return false;
}
try {
Files.delete(file);
} catch (IOException e) {
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
}
return true;
}
/// Loads settings once while this object is the repository's current instance.
private void ensureGameSettingsLoaded() {
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
loadGameSettings();
}
}
/// Returns whether this instance still owns the settings object used by the current snapshot.
private boolean ownsGameSettings() {
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
boolean owns = current == this
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
if (!owns && gameSettings != null) {
gameSettings.setSavable(false);
}
return owns;
}
private void loadGameSettings() {
gameSettingsLoaded = true;
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
if (result.setting() != null) {
initSettings(result.setting(), result.allowSave());
return;
}
if (!result.allowSave()) {
gameSettingsReadOnly = true;
return;
}
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
if (SettingsManager.getGameSettings(legacyParent) == null) {
legacyParent = null;
}
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
getRepository(), id, legacyParent);
if (migrationResult != null) {
initSettings(migrationResult.setting(), true);
try {
saveSettingsSync();
migrationResult.saveReceipt();
} catch (IOException e) {
LOG.warning("Failed to save migrated instance game settings for " + id, e);
}
}
}
private Path getGameSettingsFile() {
return getLayout().getInstanceGameSettingsFile(id);
}
public LaunchOptions.Builder getLaunchOptions(
JavaRuntime javaRuntime,
Path javaExecutable,
Path gameDir,
List<String> javaAgents,
List<String> javaArguments,
boolean makeLaunchScript) {
GameSettings.Effective vs = getEffectiveSettings();
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
GameVersionNumber gameVersionNumber = getVersion();
@Nullable Integer maxMemory;
if (autoMemory) {
maxMemory = noJVMOptions
? null
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
SystemInfo.getPhysicalMemoryStatus(),
javaRuntime.getPlatform()) / 1024L / 1024L);
} else {
maxMemory = vs.getMaxMemory();
}
LaunchOptions.Builder builder = new LaunchOptions.Builder()
.setInstanceId(getId())
.setGameDir(gameDir)
.setJava(javaRuntime)
.setJavaExecutable(javaExecutable)
.setVersionType(Metadata.TITLE)
.setVersionName(getId().id())
.setProfileName(Metadata.TITLE)
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
.setMaxMemory(maxMemory)
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
.setEnvironmentVariables(
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
.stream()
.map(it -> {
int idx = it.indexOf('=');
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
})
.collect(Collectors.toList())
)
)
.setWidth(vs.getWidth())
.setHeight(vs.getHeight())
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
.setProxyOption(getProxyOption())
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
.setNoGeneratedJVMArgs(noJVMOptions)
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
.setRenderer(vs.getRenderer(gameVersionNumber))
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
.setJavaAgents(javaAgents)
.setJavaArguments(javaArguments);
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
if (quickPlayOption != null) {
builder.setQuickPlayOption(quickPlayOption);
}
Path json = getModpackConfigurationFile();
if (Files.exists(json)) {
try {
String jsonText = Files.readString(json);
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
} catch (IOException | JsonParseException e) {
LOG.warning("Failed to parse modpack configuration file " + json, e);
}
}
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
builder.setMaxMemory(null);
return builder;
}
private static ProxyOption getProxyOption() {
return switch (settings().proxyTypeProperty().get()) {
case SYSTEM -> ProxyOption.Default.INSTANCE;
case DIRECT -> ProxyOption.Direct.INSTANCE;
case HTTP, SOCKS -> {
String proxyHost = settings().proxyHostProperty().get();
int proxyPort = settings().proxyPortProperty().get();
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
yield ProxyOption.Default.INSTANCE;
}
String proxyUser = settings().proxyUserProperty().get();
String proxyPass = settings().proxyPasswordProperty().get();
if (StringUtils.isBlank(proxyUser)) {
proxyUser = null;
proxyPass = null;
} else if (proxyPass == null) {
proxyPass = "";
}
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
} else {
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
}
}
};
}
/// Loads a new-format instance game settings file.
private static LoadResult loadGameSettingsFile(Path file) {
if (!Files.exists(file)) {
return new LoadResult(null, true);
}
try {
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
if (jsonObject == null) {
LOG.warning("Instance game settings are empty: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
return new LoadResult(fallback, true);
}
JsonSchema.CompatibilityResult schemaResult =
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
switch (schemaResult.status()) {
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
}
}
if (!schemaResult.readable()) {
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setSavable(false);
return new LoadResult(fallback, false);
}
GameSettings.@Nullable Instance setting =
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
if (setting == null) {
LOG.warning("Instance game settings deserialized to null: " + file);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
}
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
}
return new LoadResult(setting, schemaResult.allowSave());
} catch (JsonParseException ex) {
LOG.warning("Failed to parse game setting " + file, ex);
GameSettings.Instance fallback = new GameSettings.Instance();
fallback.setBackupOnNextSave(true);
return new LoadResult(fallback, true);
} catch (Exception ex) {
LOG.warning("Failed to load game setting " + file, ex);
return new LoadResult(null, false);
}
}
/// Keeps old local custom running directories effective under the new source-selection model.
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
}
/// Result of loading an instance-specific game settings file.
///
/// @param setting the loaded instance settings, or `null` when unavailable
/// @param allowSave whether the file may be overwritten
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
}
/// Optional reference to an HMCL game instance bound to a repository.
///
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
/// from the current snapshot while preserving repository context.
@NotNullByDefault
public static final class Optional {
private final HMCLGameRepository repository;
private final @Nullable HMCLGameInstance instance;
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
public Optional(HMCLGameRepository repository) {
this.repository = Objects.requireNonNull(repository);
this.instance = null;
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
public Optional(HMCLGameInstance instance) {
this.repository = instance.getRepository();
this.instance = instance;
}
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
///
/// @param repository the repository
/// @param instanceId the instance id, or `null` for an empty optional
/// @return an optional that is empty when `instanceId` is null or not registered
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
if (instanceId == null) {
return new Optional(repository);
}
HMCLGameInstance instance = repository.findInstance(instanceId);
return instance != null ? new Optional(instance) : new Optional(repository);
}
/// Creates an optional that holds the given instance.
///
/// @param instance the instance
/// @return the optional
public static Optional of(HMCLGameInstance instance) {
return new Optional(instance);
}
/// Creates an empty optional bound only to a repository.
///
/// @param repository the repository
/// @return the empty optional
public static Optional empty(HMCLGameRepository repository) {
return new Optional(repository);
}
/// Returns the repository associated with this optional.
///
/// @return the repository
public HMCLGameRepository repository() {
return repository;
}
/// Returns the held instance, if any.
///
/// @return the instance, or `null` when empty
@Contract(pure = true)
public @Nullable HMCLGameInstance instance() {
return instance;
}
/// Returns the held instance id, if any.
///
/// @return the instance id, or `null` when empty
@Contract(pure = true)
public @Nullable GameInstanceID instanceId() {
return instance != null ? instance.getId() : null;
}
/// Returns whether an instance is present.
///
/// @return whether [#instance()] is non-null
public boolean isPresent() {
return instance != null;
}
/// Returns whether no instance is present.
///
/// @return whether [#instance()] is null
public boolean isEmpty() {
return instance == null;
}
/// Re-resolves the held instance id from the repository's current snapshot.
///
/// @return this optional when empty; otherwise a fresh optional for the same id
public Optional refreshed() {
if (instance == null) {
return this;
}
return of(repository, instance.getId());
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.Metadata;
@@ -42,16 +27,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class HMCLGameLauncher extends DefaultLauncher {
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
this(repository, version, authInfo, options, null);
/// Creates a launcher with daemon process monitors.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(instance, manifest, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
this(repository, version, authInfo, options, listener, true);
}
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(repository, version, authInfo, options, listener, daemon);
/// Creates a launcher for the given instance and launch plan.
///
/// @param instance the instance being launched
/// @param manifest the effective launch-time manifest
/// @param authInfo authentication information for the game process
/// @param options launch options
/// @param listener process listener, or `null` to inherit IO
/// @param daemon whether monitors should be daemon threads
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
super(instance, manifest, authInfo, options, listener, daemon);
}
@Override
@@ -66,7 +62,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isDisableAutoGameOptions())
return;
Path runDir = repository.getRunDirectory(version.getId());
Path runDir = instance.getRunDirectory();
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
@@ -91,8 +87,14 @@ public final class HMCLGameLauncher extends DefaultLauncher {
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
*/
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
if (gameVersion.compareTo("1.1") < 0)
GameVersionNumber gameVersion = instance.getVersion();
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
return;
// These snapshots lack the external language resources needed for non-English locales.
// Selecting an unavailable language causes a NullPointerException during startup.
if (gameVersion instanceof GameVersionNumber.LegacySnapshot
&& gameVersion.compareTo("13w16a") >= 0 && gameVersion.compareTo("13w23b") <= 0)
return;
String lang = normalizedLanguageTag(locale, gameVersion);
@@ -161,7 +163,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
if (options.isAllowAutoAgent()
&& !options.isNoGeneratedJVMArgs()
&& !options.isNoGeneratedOptimizingJVMArgs()
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
try {
result.add("-javaagent:" + extractLwjglUnsafeAgent());
@@ -178,9 +180,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
String fileName = library.getArtifact().getFileName();
String fileName = library.artifact().getFileName();
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
if (agentPath.toString().contains("=")) {
throw new IOException("Invalid library path: " + agentPath);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.nio.file.Path;
@NotNullByDefault
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
/// Directory under the instance root that stores HMCL-managed instance metadata.
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
/// Directory under the instance metadata directory that stores instance configuration files.
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
/// Directory under the instance metadata directory that stores instance state files.
private static final String INSTANCE_STATE_DIRECTORY = "state";
/// Current file name for instance-specific game settings.
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
public HMCLGameRepositoryLayout(Path baseDirectory) {
super(baseDirectory);
}
/// Returns the HMCL-managed metadata directory under the instance root.
///
/// This directory stores instance-scoped files owned by HMCL.
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
}
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
}
/// Returns the HMCL-managed state directory under the instance metadata directory.
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
}
/// Returns the current local game settings path under the instance configuration directory.
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
}
}
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Collection;
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
@NotNullByDefault
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
/// Creates an empty unsealed HMCL snapshot.
///
/// @param repository the owning repository
/// @param layout the HMCL layout for this snapshot
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
super(repository, layout);
}
@Override
public HMCLGameRepository getRepository() {
return (HMCLGameRepository) super.getRepository();
}
@Override
public HMCLGameRepositoryLayout getLayout() {
return (HMCLGameRepositoryLayout) super.getLayout();
}
@Override
protected HMCLGameRepositorySnapshot newEmpty() {
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
}
@SuppressWarnings("unchecked")
@Override
public Collection<HMCLGameInstance> getInstances() {
return (Collection<HMCLGameInstance>) super.getInstances();
}
}
@@ -1,25 +1,10 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.GameBuilder;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.Modpack;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
@@ -27,53 +12,110 @@ import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
@NotNullByDefault
public final class HMCLModpackInstallTask extends Task<Void> {
private final Path zipFile;
private final String name;
private final GameInstanceID instanceId;
/// Existing instance selecting update mode, or `null` for a new installation.
private final @Nullable DefaultGameInstance updateTarget;
private final HMCLGameRepository repository;
private final DefaultDependencyManager dependency;
private final Modpack modpack;
private final List<Task<?>> dependencies = new ArrayList<>(1);
private final List<Task<?>> dependents = new ArrayList<>(4);
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
/// Creates a task that installs a new HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the id of the new instance
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId) {
this(repository, zipFile, modpack, instanceId, null);
}
/// Creates a task that updates an existing HMCL modpack instance.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instance the existing instance to update
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
/// modpack configuration, or records another provider type
public HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
DefaultGameInstance instance) {
this(repository, zipFile, modpack, instance.getId(), instance);
}
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
///
/// @param repository the target HMCL game repository
/// @param zipFile the HMCL modpack archive
/// @param modpack the parsed modpack metadata
/// @param instanceId the target instance id
/// @param updateTarget the existing instance selecting update mode, or `null` for install
/// @throws IllegalArgumentException if an update target belongs to another repository or has
/// no compatible configuration
private HMCLModpackInstallTask(
HMCLGameRepository repository,
Path zipFile,
Modpack modpack,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget) {
this.repository = repository;
this.dependency = repository.getDependency();
this.zipFile = zipFile;
this.name = name;
this.instanceId = instanceId;
this.updateTarget = updateTarget;
this.modpack = modpack;
if (this.updateTarget != null) {
dependency.validateGameInstance(this.updateTarget);
}
Path run = repository.getRunDirectory(name);
Path json = repository.getModpackConfiguration(name);
if (repository.hasVersion(name) && Files.notExists(json))
throw new IllegalArgumentException("Version " + name + " already exists");
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
if (this.updateTarget != null && Files.notExists(json))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
onDone().register(event -> {
if (event.isFailed()) repository.removeVersionFromDisk(name);
});
ModpackConfiguration<Modpack> config = null;
@Nullable ModpackConfiguration<Modpack> config = null;
try {
if (Files.exists(json)) {
if (this.updateTarget != null && Files.exists(json)) {
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
}
} catch (JsonParseException | IOException ignore) {
}
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
onDone().register(event -> {
if (this.updateTarget == null && event.isFailed()) {
repository.removeInstanceFromDisk(this.instanceId);
}
});
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
}
@Override
@@ -86,20 +128,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
return dependents;
}
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
///
/// The pack manifest is used only for component detection. The builder creates a new
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
@Override
public void execute() throws Exception {
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
// reinstall libraries
// libraries of Forge and OptiFine should be obtained by installation.
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
continue;
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
}
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
.withId(instanceId)
.withJar(null);
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
try (GameBuilder builder = updateTarget == null
? dependency.newGameBuilder(instanceId)
: dependency.newGameBuilder(updateTarget)) {
builder.enableIsolation();
String gameVersion = modpack.getGameVersion();
builder.component(GameComponentType.GAME, gameVersion);
for (GameComponentAnalyzer.Mark mark : analyzer) {
if (mark.componentType() == GameComponentType.GAME) {
continue;
}
@Nullable String componentVersion = mark.version();
if (componentVersion != null) {
if (mark.componentType() == GameComponentType.OPTIFINE) {
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
if (matcher.matches()) {
componentVersion = matcher.group("optifine");
}
}
builder.component(mark.componentType(), componentVersion);
}
}
dependencies.add(builder.buildAsync());
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.modpack.ModpackManifest;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
@@ -28,10 +13,11 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Set;
public final class HMCLModpackProvider implements ModpackProvider {
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
@@ -42,12 +28,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@Override
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
return null;
}
@Override
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
public Task<?> createUpdateTask(
DefaultDependencyManager dependencyManager,
DefaultGameInstance instance,
Path zipFile,
Modpack modpack,
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -55,29 +46,34 @@ public final class HMCLModpackProvider implements ModpackProvider {
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
}
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
}
@Override
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
if (game.getJar() == null)
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
if (game.jar() == null)
if (StringUtils.isBlank(manifest.getVersion()))
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
else
manifest.setManifest(HMCLModpackManifest.INSTANCE);
else
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
return manifest;
}
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
public Task<?> getInstallTask(
DefaultDependencyManager dependencyManager,
Path zipFile,
GameInstanceID instanceId,
String iconUrl,
@Nullable Set<String> excludedFiles) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.jfoenix.controls.JFXButton;
@@ -25,8 +10,6 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.MaintainTask;
import org.jackhuang.hmcl.download.game.*;
import org.jackhuang.hmcl.java.JavaManager;
import org.jackhuang.hmcl.java.JavaRuntime;
@@ -34,15 +17,14 @@ import org.jackhuang.hmcl.launch.*;
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.ModpackProvider;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.LauncherVisibility;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
import org.jackhuang.hmcl.util.*;
@@ -50,20 +32,21 @@ import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@@ -82,9 +65,8 @@ public final class LauncherHelper {
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
private final HMCLGameRepository repository;
private final HMCLGameInstance gameInstance;
private Account account;
private final String selectedVersion;
private Path scriptFile;
private final GameSettings.Effective setting;
private LauncherVisibility launcherVisibility;
@@ -92,14 +74,21 @@ public final class LauncherHelper {
private QuickPlayOption quickPlayOption;
private boolean disableOfflineSkin = false;
public LauncherHelper(HMCLGameRepository repository, Account account, String selectedVersion) {
this.repository = Objects.requireNonNull(repository);
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
this.gameInstance = Objects.requireNonNull(gameInstance);
this.account = Objects.requireNonNull(account);
this.selectedVersion = Objects.requireNonNull(selectedVersion);
this.setting = repository.getEffectiveGameSettings(selectedVersion);
this.setting = gameInstance.getEffectiveSettings();
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
this.launchingStepsPane.setTitle(i18n("version.launch"));
this.launchingStepsPane.setTitle(i18n("instance.launch"));
}
public HMCLGameInstance getGameInstance() {
return gameInstance;
}
private HMCLGameRepository repository() {
return gameInstance.getRepository();
}
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
@@ -132,7 +121,7 @@ public final class LauncherHelper {
public void launch() {
FXUtils.checkFxUserThread();
LOG.info("Launching game version: " + selectedVersion);
LOG.info("Launching game instance: " + gameInstance.getId());
Controllers.dialog(launchingStepsPane);
launch0();
@@ -143,48 +132,72 @@ public final class LauncherHelper {
launch();
}
/// Builds and executes the launch pipeline for the captured game instance.
@SuppressWarnings("NonAtomicOperationOnVolatileField")
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
final var context = new Object() {
volatile GameInstanceManifest launchManifest;
volatile JavaRuntime javaRuntime;
volatile Path javaExecutable;
};
HMCLGameRepository repository = repository();
DefaultDependencyManager dependencyManager = repository.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
Optional<String> gameVersion = repository.getGameVersion(version.get());
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
CountDownLatch launchingLatch = new CountDownLatch(1);
List<String> javaAgents = new ArrayList<>(0);
List<String> javaArguments = new ArrayList<>(0);
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
TaskExecutor executor = checkGameState(repository, setting, version.get())
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
.thenComposeAsync(java -> {
javaVersionRef.set(Objects.requireNonNull(java));
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
context.javaRuntime = Objects.requireNonNull(java);
context.javaExecutable = java.getBinary();
if (java.getPlatform().os() == OperatingSystem.WINDOWS
&& scriptFile == null
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
if (Files.isRegularFile(javawBinary)) {
context.javaExecutable = javawBinary;
}
}
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
if (setting.getInheritable(GameSettings::notCheckGameProperty))
return null;
return Task.allOf(
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
Task.composeAsync(() -> {
try {
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
@Nullable ModpackConfiguration<?> configuration =
gameInstance.readModpackConfiguration();
if (configuration == null) return null;
@Nullable ModpackProvider provider =
ModpackHelper.getProviderByType(configuration.getType());
if (provider == null) return null;
else return provider.createCompletionTask(dependencyManager, selectedVersion);
else return provider.createCompletionTask(
dependencyManager,
gameInstance);
} catch (IOException e) {
return null;
}
}),
Task.composeAsync(() -> {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|| renderer.mesaDriverName() == null)
return null;
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
if (lib == null)
return null;
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
return null;
@@ -192,7 +205,7 @@ public final class LauncherHelper {
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
return new LibraryDownloadTask(dependencyManager, file, lib)
.thenRunAsync(() -> javaAgents.add(agent));
} else {
@@ -202,13 +215,13 @@ public final class LauncherHelper {
})
);
}).withStage("launch.state.dependencies")
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
.thenComposeAsync(() -> {
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
return Task.completed(null);
} else {
CompletableFuture<Void> future = new CompletableFuture<>();
@@ -227,8 +240,13 @@ public final class LauncherHelper {
})
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
context.javaRuntime,
context.javaExecutable,
repository.getBaseDirectory(),
javaAgents,
javaArguments,
scriptFile != null);
if (disableOfflineSkin) {
launchOptionsBuilder.setDaemon(false);
}
@@ -236,18 +254,48 @@ public final class LauncherHelper {
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
try {
WinReg reg = WinReg.INSTANCE;
if (reg != null) {
Object current = reg.queryValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg
);
if (current == null) {
boolean result = reg.setValue(
WinReg.HKEY.HKEY_CURRENT_USER,
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
javaPathGpuReg,
"GpuPreference=2;"
);
if (result)
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
else
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
} else {
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
}
}
} catch (Exception e) {
LOG.warning("Failed to apply high performance GPU preference", e);
}
}
LaunchOptions launchOptions = launchOptionsBuilder.create();
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
return new HMCLGameLauncher(
repository,
version.get(),
gameInstance,
context.launchManifest,
authInfo,
launchOptions,
launcherVisibility == LauncherVisibility.CLOSE
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
);
}).thenComposeAsync(launcher -> { // launcher is prev task's result
if (scriptFile == null) {
@@ -264,14 +312,14 @@ public final class LauncherHelper {
if (launcherVisibility == LauncherVisibility.CLOSE)
Launcher.stopApplication();
else
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
launchingStepsPane.setCancel(it -> {
process.stop();
it.fireEvent(new DialogCloseEvent());
}));
});
} else {
runLater(() -> {
launchingStepsPane.fireEvent(new DialogCloseEvent());
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
});
}
}).withFakeProgress(
@@ -310,31 +358,29 @@ public final class LauncherHelper {
} else if (ex instanceof NotDecompressingNativesException) {
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
} else if (ex instanceof LibraryDownloadException) {
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
if (ex.getCause() instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message += i18n("download.code.404", uri);
message += i18n("download.code.404", url);
else
message += i18n("download.failed", uri, responseCode);
message += i18n("download.failed", url, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URI uri = ((DownloadException) ex).getUri();
} else if (ex instanceof DownloadException de) {
String url = de.getUrl();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
message = i18n("install.failed.downloading.timeout", url);
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -342,14 +388,13 @@ public final class LauncherHelper {
message = i18n("account.failed.injector_download_failure");
} else if (ex instanceof CharacterDeletedException) {
message = i18n("account.failed.character_deleted");
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
} else if (ex instanceof ResponseCodeException rce) {
int responseCode = rce.getResponseCode();
String uri = rce.getUri();
String url = rce.getUrl();
if (responseCode == 404)
message = i18n("download.code.404", uri);
message = i18n("download.code.404", url);
else
message = i18n("download.failed", uri, responseCode);
message = i18n("download.failed", url, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -373,7 +418,7 @@ public final class LauncherHelper {
message = StringUtils.getStackTrace(ex);
}
Controllers.dialog(message,
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
MessageType.ERROR);
}
}
@@ -383,16 +428,17 @@ public final class LauncherHelper {
}
});
executor.start();
launchingStepsPane.startExecutor();
}
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
/// Checks launch requirements, allowing required Java downloads to share this launch's execution slot.
private Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
GameVersionNumber gameVersion = gameInstance.getVersion();
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
try {
return setting.getJava(gameVersion, version);
return setting.getJava(gameVersion, manifest);
} catch (InterruptedException e) {
throw new CancellationException();
}
@@ -419,9 +465,9 @@ public final class LauncherHelper {
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
GameJavaVersion minimumJavaVersion = null;
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
@@ -443,18 +489,18 @@ public final class LauncherHelper {
}
} else {
if (gameVersion.compareTo("1.12.2") == 0) {
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
if (cleanroomVersion.isPresent()) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null) {
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
}
}
if (targetJavaVersion == null)
targetJavaVersion = version.getJavaVersion();
targetJavaVersion = manifest.javaVersion();
}
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
downloadJava(targetJavaVersion, repository)
downloadJava(targetJavaVersion, gameInstance.getRepository())
.whenCompleteAsync((downloadedJava, exception) -> {
if (exception == null) {
future.complete(downloadedJava);
@@ -480,8 +526,8 @@ public final class LauncherHelper {
if (java != null) {
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violatedMandatoryConstraints.add(constraint);
} else {
@@ -497,7 +543,7 @@ public final class LauncherHelper {
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
if (suggestedJava != null) {
FXUtils.runInFX(() -> {
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
@@ -517,21 +563,20 @@ public final class LauncherHelper {
} else {
GameJavaVersion gameJavaVersion;
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
.orElse("");
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
gameJavaVersion = !cleanroomVersion.isEmpty()
gameJavaVersion = cleanroomVersion != null
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
: GameJavaVersion.JAVA_21;
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
gameJavaVersion = version.getJavaVersion();
gameJavaVersion = manifest.javaVersion();
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
else
gameJavaVersion = null;
if (gameJavaVersion != null) {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
if (throwable == null) {
setting.setJavaAutoSelected();
future.complete(downloadedJava);
@@ -601,7 +646,7 @@ public final class LauncherHelper {
break;
case MODDED_JAVA_16:
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
suggestions.add(i18n("launch.advice.forge37_0_60"));
else
@@ -614,8 +659,8 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
break;
case CLEANROOM: {
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
if (!cleanroomVersion.isEmpty())
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
if (cleanroomVersion != null)
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
else
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
@@ -644,20 +689,20 @@ public final class LauncherHelper {
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
}
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
.map(VersionNumber::asVersion)
.orElse(null);
// Forge 2760~2773 will crash game with LiteLoader.
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
}
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
}
@@ -689,13 +734,18 @@ public final class LauncherHelper {
return task.withStage("launch.state.java");
}
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
/// Prompts for and downloads Java as a required child of this launch operation.
private CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Controllers.dialog(new MessageDialogPane.Builder(
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
i18n("message.warning"),
MessageType.QUESTION)
.yesOrNo(() -> {
if (!launchingStepsPane.canStartChild()) {
future.completeExceptionally(new CancellationException());
return;
}
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
@@ -705,11 +755,11 @@ public final class LauncherHelper {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
future.completeExceptionally(new CancellationException());
}
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()), launchingStepsPane);
}, () -> future.completeExceptionally(new CancellationException())).build());
return future;
@@ -760,7 +810,10 @@ public final class LauncherHelper {
case HIDE_AND_REOPEN:
runLater(() -> {
Optional.ofNullable(Controllers.getStage())
.ifPresent(Stage::show);
.ifPresent(stage -> {
stage.setIconified(false);
stage.show();
});
});
break;
case KEEP:
@@ -789,16 +842,12 @@ public final class LauncherHelper {
}
}
/**
* The managed process listener.
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
* Because every time we launched a game, we generates a new [HMCLProcessListener]
*/
/// The managed process listener.
/// Guarantee that one Java [Process], one [HMCLProcessListener].
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
private ManagedProcess process;
private volatile boolean lwjgl;
@@ -810,9 +859,7 @@ public final class LauncherHelper {
private Thread submitLogThread;
private LinkedBlockingQueue<Log> logBuffer;
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.repository = repository;
this.version = version;
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
this.launchOptions = launchOptions;
this.launchingLatch = launchingLatch;
this.detectWindow = detectWindow;
@@ -895,8 +942,10 @@ public final class LauncherHelper {
runLater(() -> {
// If application was stopped and execution services did not finish termination,
// these codes will be executed.
if (Controllers.getStage() != null) {
Controllers.getStage().hide();
@Nullable Stage stage = Controllers.getStage();
if (stage != null) {
stage.setIconified(false);
stage.hide();
launchingLatch.countDown();
}
});
@@ -937,7 +986,7 @@ public final class LauncherHelper {
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
if (showLogs) {
if (level == null)
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
lock.lock();
@@ -995,8 +1044,8 @@ public final class LauncherHelper {
}
if (exitType != ExitType.NORMAL) {
repository.markVersionLaunchedAbnormally(version.getId());
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
gameInstance.markLaunchedAbnormally();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}
checkExit();
@@ -1,28 +1,16 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
import org.jackhuang.hmcl.addon.AddonProjectInfo;
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
@@ -38,19 +26,28 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public String getApiBaseUrl() {
return getBackedRemoteModRepository().getApiBaseUrl();
}
@Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
if (!StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
public String getBaseUrl() {
return getBackedRemoteModRepository().getBaseUrl();
}
@Override
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
for (AddonProjectInfo mod : registry.search(searchFilter)) {
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
@@ -60,7 +57,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
@@ -69,8 +66,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
SearchResult searchResult = null;
List<RemoteAddon> remoteAddons = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteAddons = searchResult.getUnsortedResults().toList();
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
remoteAddons = searchResult.results().toList();
if (!remoteAddons.isEmpty()) {
break;
}
@@ -82,23 +79,23 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
continue;
}
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
searchResultArray[chineseIndex++] = remoteAddon;
} else {
searchResultArray[englishIndex--] = remoteAddon;
}
}
totalPages = searchResult.getTotalPages();
totalPages = searchResult.totalPages();
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
return Pair.pair(remoteMod, Integer.MAX_VALUE);
}
String chineseRemoteModName = chineseRemoteMod.getName();
String chineseRemoteModName = chineseRemoteMod.mainName();
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
}
@@ -113,8 +110,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public Stream<Category> getCategories() throws IOException {
return getBackedRemoteModRepository().getCategories();
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
return getBackedRemoteModRepository().getCategories(type);
}
@Override
@@ -123,17 +120,32 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
}
@Override
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getModById(downloadProvider, id);
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
}
@Override
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
return getBackedRemoteModRepository().getModFile(modId, fileId);
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
}
@Override
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
}
@Override
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
}
@Override
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
return getBackedRemoteModRepository().getVersionPageUrl(version);
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.Log4jLevel;
@@ -1,31 +1,19 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -42,22 +30,24 @@ public final class LogExporter {
}
public static CompletableFuture<Void> exportLogs(
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
PathMatcher logMatcher) {
Path runDirectory = gameRepository.getRunDirectory(versionId);
Path baseDirectory = gameRepository.getBaseDirectory();
List<String> versions = new ArrayList<>();
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
Path runDirectory = options.getGameDir();
List<GameInstanceID> instances = new ArrayList<>();
String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
GameInstanceID currentInstanceId = instance.id;
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);
if (resolvedSoFar.contains(currentInstanceId)) break;
resolvedSoFar.add(currentInstanceId);
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
if (currentInstance == null)
break;
instances.add(currentInstanceId);
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
if (currentInstance.getManifest().inheritsFrom() != null) {
currentInstanceId = currentInstance.getManifest().inheritsFrom();
} else {
break;
}
@@ -74,10 +64,12 @@ public final class LogExporter {
zipper.putTextFile(logs, "minecraft.log");
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
zipper.putFile(versionJson, id + ".json");
for (GameInstanceID id : instances) {
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
if (currentInstance != null) {
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
}
}
}
} catch (IOException e) {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import java.nio.file.Path;
@@ -1,28 +1,10 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.Unzipper;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -31,12 +13,10 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
private final Path zipFile;
private final Charset charset;
private final String name;
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
this.zipFile = zipFile;
this.charset = charset;
this.name = name;
setName(i18n("modpack.installing"));
@@ -44,19 +24,15 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
@Override
public void execute() throws Exception {
Path subdirectory;
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
}
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
Path dest = Paths.get("externalgames").resolve(name);
setResult(dest);
new Unzipper(zipFile, dest)
.setSubDirectory(subdirectory.toString())
.setSubDirectory(subdirectory)
.setTerminateIfSubDirectoryNotExists()
.setEncoding(charset)
.unzip();
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
@@ -31,11 +16,7 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.GameWindowType;
import org.jackhuang.hmcl.setting.JavaVersionType;
import org.jackhuang.hmcl.setting.GameDirectory;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.setting.*;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
@@ -46,13 +27,12 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -60,10 +40,9 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.Set;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.toIterable;
import static org.jackhuang.hmcl.util.Pair.pair;
/// Utilities for reading, installing, and applying modpack-specific game settings.
@@ -82,7 +61,7 @@ public final class ModpackHelper {
);
static {
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
MultiMCComponents.setImplementation(Metadata.FULL_NAME + " v" + Metadata.VERSION);
}
@Nullable
@@ -95,8 +74,8 @@ public final class ModpackHelper {
return "zip".equals(ext) || "mrpack".equals(ext);
}
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
// Order for trying detecting manifest is necessary here.
// Do not change to iterating providers.
for (ModpackProvider provider : new ModpackProvider[]{
@@ -107,15 +86,15 @@ public final class ModpackHelper {
MultiMCModpackProvider.INSTANCE,
ServerModpackProvider.INSTANCE}) {
try {
return provider.readManifest(zipFile, file, charset);
return provider.readManifest(zipFile, file);
} catch (Exception ignored) {
}
}
} catch (IOException ignored) {
}
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
try {
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
throw new ManuallyCreatedModpackException(file);
} catch (IOException e) {
// ignore it
@@ -124,28 +103,35 @@ public final class ModpackHelper {
throw new UnsupportedModpackException(file.toString());
}
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
Path root = fs.getPath("/");
if (isMinecraftDirectory(root)) return root;
try (Stream<Path> firstLayer = Files.list(root)) {
for (Path dir : toIterable(firstLayer)) {
if (isMinecraftDirectory(dir)) return dir;
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
throws IOException, UnsupportedModpackException {
try (Stream<Path> secondLayer = Files.list(dir)) {
for (Path subdir : toIterable(secondLayer)) {
if (isMinecraftDirectory(subdir)) return subdir;
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
if (isMinecraftDirectory(rootDir)) {
return "";
}
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
if (isMinecraftDirectory(firstLayer)) {
return firstLayer.getName();
}
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
if (isMinecraftDirectory(secondLayer)) {
return firstLayer.getName() + "/" + secondLayer.getName();
}
} catch (IOException ignored) {
}
}
} catch (IOException ignored) {
}
throw new UnsupportedModpackException(modpackName);
}
private static boolean isMinecraftDirectory(Path path) {
return Files.isDirectory(path.resolve("versions")) &&
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
}
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
@@ -156,16 +142,10 @@ public final class ModpackHelper {
}
}
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, String name, Modpack modpack) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -175,8 +155,9 @@ public final class ModpackHelper {
}
};
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
@@ -184,12 +165,13 @@ public final class ModpackHelper {
return Files.exists(Paths.get("externalgames").resolve(name));
}
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
/// Extracts a manually created modpack and selects its new external game directory.
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
if (isExternalGameNameConflicts(name)) {
throw new IllegalArgumentException("name existing");
}
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
return new ManuallyCreatedModpackInstallTask(zipFile, name)
.thenAcceptAsync(Schedulers.javafx(), location -> {
GameDirectory newGameDirectory = new GameDirectory(
GameDirectoryManager.newGameDirectoryId(),
@@ -197,19 +179,35 @@ public final class ModpackHelper {
PortablePath.fromPath(location));
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
});
})
.withStage("hmcl.modpack")
.withStagesHints("hmcl.modpack");
}
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, String name, Modpack modpack, String iconUrl) {
repository.markVersionAsModpack(name);
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
}
/// Creates an install task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the new instance id
/// @param modpack the parsed modpack
/// @param iconUrl the optional icon URL, or `null`
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the install task
public static Task<?> getInstallTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
Modpack modpack,
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
ExceptionalRunnable<?> success = () -> {
repository.refreshVersions();
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
repository.undoMark(name);
if (setting != null) {
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
}
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
};
ExceptionalConsumer<Exception, ?> failure = ex -> {
@@ -220,45 +218,77 @@ public final class ModpackHelper {
};
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
.whenComplete(Schedulers.javafx(), success, failure)
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
switch (configuration.getType()) {
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
.thenComposeAsync(repository.refreshVersionsAsync())
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
HMCLGameInstance instance = repository.getInstance(instanceId);
return new ModpackUpdateTask(
instance,
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
default:
throw new UnsupportedModpackException();
}
}
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
ModpackProvider provider = getProviderByType(configuration.getType());
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
}
/// Creates an update task that respects optional-file selection.
///
/// @param repository the target repository
/// @param zipFile the modpack archive
/// @param instanceId the instance to update
/// @param configuration the existing modpack configuration
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
/// When non-null, must not contain `null` elements.
/// @return the update task
public static Task<?> getUpdateTask(
HMCLGameRepository repository,
Path zipFile,
GameInstanceID instanceId,
ModpackConfiguration<?> configuration,
@Nullable Set<String> excludedFiles)
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
if (provider == null) {
throw new UnsupportedModpackException();
}
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
else
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
.thenComposeAsync(repository.refreshVersionsAsync());
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
.thenComposeAsync(repository.refreshAsync())
.withStage("hmcl.modpack")
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
}
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
@@ -333,25 +363,25 @@ public final class ModpackHelper {
}
}
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
});
}
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
ModpackHelper.toGameSettings(manifest, setting);
});
}
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, String version) {
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
return Task.runAsync(Schedulers.javafx(), () -> {
GameSettings.Effective effective = repository.getEffectiveGameSettings(version);
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
setting.getOverrideProperties().addAll(List.of(
GameSettings.PROPERTY_AUTO_MEMORY,
GameSettings.PROPERTY_MIN_MEMORY,
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import fi.iki.elonen.NanoHTTPD;
@@ -53,7 +38,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private String idToken;
private OAuthServer(int port) {
super(port);
super("localhost", port);
this.port = port;
@@ -146,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Resolves the routes that the one-click join button can connect through.
@NotNullByDefault
public final class ServerEndpoint {
private ServerEndpoint() {
}
/// One selectable connection route.
///
/// @param name the display name
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
public record Route(String name, String address) {
}
/// Lists the routes configured for an instance.
///
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
/// used, each as a single route.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the routes in display order; empty when nothing is configured
public static @Unmodifiable List<Route> routes(Path configurationFile) {
List<Route> result = new ArrayList<>();
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
if (manifest != null) {
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
if (StringUtils.isNotBlank(route.getAddress())) {
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
}
}
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
result.add(new Route("default", manifest.getServerAddress().trim()));
}
}
if (result.isEmpty()) {
String fallback = Metadata.getDefaultServerAddress();
if (!fallback.isEmpty()) {
result.add(new Route("default", fallback));
}
}
return List.copyOf(result);
}
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the route to connect through, or `null` when nothing is configured
public static @Nullable Route selected(Path configurationFile) {
List<Route> routes = routes(configurationFile);
if (routes.isEmpty()) {
return null;
}
@Nullable String saved = readSelectedName(configurationFile);
for (Route route : routes) {
if (route.name().equals(saved)) {
return route;
}
}
return routes.get(0);
}
/// Remembers the player's route choice next to the instance's modpack configuration.
///
/// @param configurationFile the instance's `modpack.json` path
/// @param route the chosen route
public static void select(Path configurationFile, Route route) {
try {
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
} catch (IOException e) {
LOG.warning("Unable to save the selected route", e);
}
}
private static Path selectionFile(Path configurationFile) {
return configurationFile.resolveSibling("server-route.txt");
}
private static @Nullable String readSelectedName(Path configurationFile) {
try {
Path file = selectionFile(configurationFile);
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
} catch (IOException e) {
return null;
}
}
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
if (!Files.isRegularFile(configurationFile)) {
return null;
}
try {
ModpackConfiguration<ServerModpackManifest> configuration =
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
return configuration != null ? configuration.getManifest() : null;
} catch (Exception e) {
LOG.warning("Unable to read server routes from " + configurationFile, e);
return null;
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import javafx.beans.InvalidationListener;
@@ -33,6 +18,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -99,7 +85,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(texture.url(), file).run();
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
LOG.info("Texture downloaded: " + texture.url());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import org.jackhuang.hmcl.download.DownloadProvider;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import com.google.gson.annotations.SerializedName;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import kala.compress.archivers.ArchiveEntry;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import com.google.gson.*;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import com.google.gson.JsonArray;
@@ -26,18 +11,13 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.game.GameJavaVersion;
import org.jackhuang.hmcl.game.JavaVersionConstraint;
import org.jackhuang.hmcl.game.Version;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -81,8 +61,12 @@ public final class JavaManager {
"Semeru"
};
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
/// Managed Mojang/Disco Java installs for this workspace (beside `.minecraft`), not the user-global HMCL profile.
public static final HMCLJavaRepository REPOSITORY =
new HMCLJavaRepository(Metadata.CURRENT_DIRECTORY.resolve("java"));
/// Optional portable installs under the launcher-local `.hmcl` directory.
public static final HMCLJavaRepository LOCAL_REPOSITORY =
new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -315,13 +299,13 @@ public final class JavaManager {
}
@Nullable
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, version);
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
return findSuitableJava(getAllJava(), gameVersion, manifest);
}
@Nullable
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -342,8 +326,8 @@ public final class JavaManager {
boolean violationSuggested = false;
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
if (constraint.isMandatory()) {
violationMandatory = true;
} else {
@@ -438,14 +422,14 @@ public final class JavaManager {
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
}
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
// Search in PATH.
if (System.getenv("PATH") != null) {
@@ -499,7 +483,7 @@ public final class JavaManager {
it.isJDK() ? "JDK" : "JRE",
it.getVersion(),
it.getPlatform().getArchitecture().getDisplayName(),
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
it.getBinary()))
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
return searcher.javaRuntimes;
@@ -842,7 +826,7 @@ public final class JavaManager {
}
void searchJavaInProgramFiles(String env, String defaultValue) {
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
Path path;
try {
path = Path.of(programFiles);
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.java;
import com.google.gson.*;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonObject;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonArray;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonObject;
@@ -47,13 +32,8 @@ import java.util.*;
import static java.util.stream.Collectors.toList;
import static javafx.collections.FXCollections.observableArrayList;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -73,7 +53,14 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
/// Account factories offered when creating a new account from the UI.
///
/// Primary: Offline and Microsoft. Authlib-injector remains available for external Yggdrasil servers.
public static final List<AccountFactory<?>> FACTORIES = List.of(
FACTORY_OFFLINE,
FACTORY_MICROSOFT,
FACTORY_AUTHLIB_INJECTOR
);
// ==== login type / account factory mapping ====
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
@@ -41,9 +26,8 @@ import java.util.Objects;
@NotNullByDefault
@JsonSerializable
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
/// The LittleSkin Yggdrasil API endpoint used only when recognizing an existing entry.
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
/// The JSON schema supported by this authlib-injector server list.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
@@ -55,17 +39,10 @@ public final class AuthlibInjectorServerList extends ObservableSetting implement
}
/// Creates the default authlib-injector server list for a newly created file.
///
/// The default list is empty; operators add their own authentication server when needed.
public static AuthlibInjectorServerList createDefault() {
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
result.addLittleSkinIfAbsent();
return result;
}
/// Adds the bundled LittleSkin server when it is not already present.
public void addLittleSkinIfAbsent() {
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
}
return new AuthlibInjectorServerList();
}
/// The schema used by this authlib-injector server list file.
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonParseException;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonParseException;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
/// @author Glavo
@@ -1,62 +1,29 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.download.*;
import org.jackhuang.hmcl.task.DownloadException;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.HMCLDownloadProvider;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Unmodifiable;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.List;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {
}
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
private static final MirrorRoots MIRROR_ROOTS =
new MirrorRoots(System.getProperty("hmcl.bmclapi.override"));
private static final DownloadProvider MOJANG_PROVIDER;
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
private static final DownloadProvider DEFAULT_PROVIDER;
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(MIRROR_ROOTS::getRankedRoots);
static {
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
MOJANG_PROVIDER = new MojangDownloadProvider();
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
}
/// Initializes download provider settings and synchronizes download thread settings.
/// Initializes download provider settings, mirror probing, and download thread settings.
public static void init() {
InvalidationListener onChangeDownloadThreads = observable -> {
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
@@ -67,73 +34,20 @@ public final class DownloadProviders {
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
onChangeDownloadThreads.invalidated(null);
InvalidationListener onChangeDownloadSource = observable -> {
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
settings().versionListSourceProperty().get(),
settings().fileDownloadSourceProperty().get()));
};
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
MIRROR_ROOTS.maybeProbeAsync();
}
/// Creates a download provider with independent version-list and file download preferences.
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
return new AutoDownloadProvider(
getCandidates(versionListSource),
getCandidates(fileDownloadSource));
}
/// Returns provider candidates ordered by the given source preference.
private static List<DownloadProvider> getCandidates(DownloadSource source) {
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
return switch (normalized) {
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
case OFFICIAL -> List.of(MOJANG_PROVIDER);
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
};
}
/**
* Get current primary preferred download provider
*/
/// Get current primary preferred download provider
public static DownloadProvider getDownloadProvider() {
return PROVIDER_WRAPPER;
MIRROR_ROOTS.maybeProbeAsync();
return PROVIDER;
}
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", uri);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
} else if (exception.getCause() instanceof ArtifactMalformedException) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
}
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
} else if (exception instanceof CancellationException) {
return i18n("message.cancelled");
}
return StringUtils.getStackTrace(exception);
/// Returns the current ranked BMCLAPI-compatible mirror roots (fastest first).
public static @Unmodifiable List<String> getRankedMirrorRoots() {
return MIRROR_ROOTS.getRankedRoots();
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
public enum EnumCommonDirectory {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import javafx.beans.property.ObjectProperty;
@@ -97,7 +82,28 @@ public final class FontManager {
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
private static final String[] BUNDLED_FONTS = {
"/assets/fonts/NotoSansSC-Regular.ttf",
"/assets/fonts/NotoSansSC-Bold.ttf",
"/assets/fonts/NotoSerifSC-Regular.ttf"
};
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
private static void loadBundledFonts() {
for (String resource : BUNDLED_FONTS) {
try (var in = FontManager.class.getResourceAsStream(resource)) {
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
LOG.warning("Failed to load bundled font " + resource);
}
} catch (java.io.IOException e) {
LOG.warning("Failed to load bundled font " + resource, e);
}
}
}
static {
loadBundledFonts();
updateFont();
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
@@ -34,8 +19,6 @@ import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
import java.util.Objects;
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
/// Persistent configuration for a game directory.
@JsonAdapter(GameDirectory.Serializer.class)
@NotNullByDefault
@@ -118,7 +101,7 @@ public final class GameDirectory implements Observable {
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
addPropertyChangedListener(onInvalidating(this::invalidate));
addPropertyChangedListener(arg -> invalidate());
}
/// Returns a debug string containing the game directory path and display metadata.
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
@@ -1,30 +1,18 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import javafx.beans.property.*;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.game.HMCLGameRepository;
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
import org.jackhuang.hmcl.util.PortablePath;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.LocalizedText;
@@ -33,17 +21,10 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import org.jetbrains.annotations.UnmodifiableView;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.*;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.SettingsManager.*;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/// Manages the merged runtime view of local and user game directories.
@@ -140,13 +121,18 @@ public final class GameDirectoryManager {
/// The selected game repository, or `null` before the fallback game directory is resolved.
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
/// The selected instance ID projected from the selected repository.
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
/// The selected instance projected from the selected repository's current snapshot.
private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance =
new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
private static final ChangeListener<String> selectedRepositoryInstanceListener =
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
/// Reacts when the selected repository publishes a new snapshot.
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
/// Initializes game directory state from the stores loaded by [SettingsManager].
///
/// This method creates the built-in local and user-home game directories when required, rebuilds
@@ -166,12 +152,6 @@ public final class GameDirectoryManager {
gameDirectories.getGameDirectories().add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
}
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
needRebuildGameDirectories = true;
GameDirectories gameDirectories = userGameDirectories();
gameDirectories.getGameDirectories().add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
}
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
if (needRebuildGameDirectories) {
@@ -180,15 +160,13 @@ public final class GameDirectoryManager {
assert !mergedGameDirectories.isEmpty();
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
// Dedicated server client: always prefer the fixed workspace `.minecraft` directory.
GameDirectory currentGameDirectory = null;
if (selectedId != null) {
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (gameDirectory.getId().equals(selectedId)) {
currentGameDirectory = gameDirectory;
break;
}
for (GameDirectory gameDirectory : mergedGameDirectories) {
if (DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())
|| isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
currentGameDirectory = gameDirectory;
break;
}
}
@@ -201,37 +179,41 @@ public final class GameDirectoryManager {
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
if (oldRepository != null) {
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
}
HMCLGameRepository repository = getOrCreateRepository(newValue);
selectedRepository.set(repository);
selectedInstance.set(repository.getSelectedInstance());
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
repository.refreshVersionsAsync().start();
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
if (repository.isLoaded()) {
onSelectedRepositorySnapshotChanged();
}
repository.refreshAsync().start();
});
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
runInFX(() -> {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository != null && repository == event.getSource()) {
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners)
listener.accept(repository);
}
});
});
}
/// Creates the built-in game directories only when no game directory exists.
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
private static void onSelectedRepositorySnapshotChanged() {
@Nullable HMCLGameRepository repository = selectedRepository.get();
if (repository == null || !repository.isLoaded()) {
return;
}
repository.refreshSelectedInstance();
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
listener.accept(repository);
}
}
/// Creates the fixed workspace game directory when no game directory exists.
private static boolean createDefaultGameDirectoriesIfEmpty() {
if (localGameDirectories().getGameDirectories().isEmpty()
&& userGameDirectories().getGameDirectories().isEmpty()) {
localGameDirectories().getGameDirectories()
.add(new GameDirectory(
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
userGameDirectories().getGameDirectories()
.add(new GameDirectory(
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
return true;
} else {
return false;
@@ -322,6 +304,7 @@ public final class GameDirectoryManager {
if (source == target) {
gameDirectory.setName(name);
gameDirectory.setPath(path);
rebuildGameDirectories();
return;
}
@@ -479,17 +462,26 @@ public final class GameDirectoryManager {
}
/// Returns the selected instance property projected from the selected repository.
public static ReadOnlyStringProperty selectedInstanceProperty() {
///
/// The value is `null` when the selected repository has no registered selected instance.
///
/// @return the read-only selected-instance property
public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
return selectedInstance.getReadOnlyProperty();
}
/// Returns the selected instance ID for the selected repository.
public static @Nullable String getSelectedInstance() {
/// Returns the selected instance from the selected repository's current snapshot.
///
/// @return the selected instance, or `null` when none is registered
public static @Nullable HMCLGameInstance getSelectedInstance() {
return getSelectedRepository().getSelectedInstance();
}
/// Sets the selected instance ID for the selected repository.
public static void setSelectedInstance(@Nullable String instance) {
/// Sets the selected instance for the selected repository.
///
/// @param instance the instance to select, or `null` to clear the selection
/// @throws IllegalArgumentException if `instance` belongs to another repository
public static void setSelectedInstance(@Nullable HMCLGameInstance instance) {
getSelectedRepository().setSelectedInstance(instance);
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jetbrains.annotations.Nullable;
public enum GameInstanceIconType {
DEFAULT("/assets/img/grass.png"),
GRASS("/assets/img/grass.png"),
CHEST("/assets/img/chest.png"),
CHICKEN("/assets/img/chicken.png"),
COMMAND("/assets/img/command.png"),
OPTIFINE("/assets/img/optifine.png"),
CRAFT_TABLE("/assets/img/craft_table.png"),
FABRIC("/assets/img/fabric.png"),
FORGE("/assets/img/forge.png"),
NEO_FORGE("/assets/img/neoforge.png"),
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png"),
/// Retained for settings migration; UI no longer offers this icon.
APRIL_FOOLS("/assets/img/command.png"),
CLEANROOM("/assets/img/cleanroom.png"),
LEGACY_FABRIC("/assets/img/legacyfabric.png")
;
// Please append new items at last
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> GameInstanceIconType.FORGE;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
case FABRIC -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
case QUILT -> GameInstanceIconType.QUILT;
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
default -> GameInstanceIconType.COMMAND;
};
}
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
return switch (componentType) {
case GAME -> GameInstanceIconType.GRASS;
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
case FORGE -> GameInstanceIconType.FORGE;
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
case LITELOADER -> GameInstanceIconType.CHICKEN;
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
default -> null;
};
}
private final String resourceUrl;
GameInstanceIconType(String resourceUrl) {
this.resourceUrl = resourceUrl;
}
public Image getIcon() {
return FXUtils.newBuiltinImage(resourceUrl);
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonDeserializationContext;
@@ -82,7 +67,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
public static final class Instance extends GameSettings implements JsonSchemaSetting {
/// The JSON schema supported by instance-specific game settings.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 0));
new JsonSchema("instance-game-settings", new JsonSchema.Version(1, 0, 1));
/// Creates an empty instance setting.
public Instance() {
@@ -152,10 +137,10 @@ public sealed abstract class GameSettings extends ObservableSetting {
/// The icon of the instance.
@SerializedName("icon")
private final SettingProperty<VersionIconType> icon = newSettingProperty("icon", VersionIconType.DEFAULT);
private final SettingProperty<GameInstanceIconType> icon = newSettingProperty("icon", GameInstanceIconType.DEFAULT);
/// Returns the instance icon property.
public SettingProperty<VersionIconType> iconProperty() {
public SettingProperty<GameInstanceIconType> iconProperty() {
return icon;
}
@@ -388,6 +373,19 @@ public sealed abstract class GameSettings extends ObservableSetting {
return detectedJava;
}
/// Property name for disabling automatic replacement with `javaw.exe` on Windows.
public static final String PROPERTY_NOT_USE_JAVAW = "notUseJavaw";
/// Whether to keep the selected Java executable instead of replacing it with `javaw.exe`; defaults to `false`.
@SerializedName(PROPERTY_NOT_USE_JAVAW)
private final InheritableProperty<Boolean> notUseJavaw = newInheritableProperty(PROPERTY_NOT_USE_JAVAW, false);
/// Returns the property disabling automatic replacement of a Windows Java executable with a sibling `javaw.exe` file.
/// If `true`, the selected executable is used unchanged. This property has no effect on other runtime platforms.
public InheritableProperty<Boolean> notUseJavawProperty() {
return notUseJavaw;
}
/// Property name for customized JVM options.
public static final String PROPERTY_JVM_OPTIONS = "jvmOptions";
@@ -643,6 +641,19 @@ public sealed abstract class GameSettings extends ObservableSetting {
return vulkanRenderer;
}
/// Property name for high performance GPU preference.
public static final String PROPERTY_HIGH_PERFORMANCE = "highPerformance";
/// Whether to launch games with high-performance GPU.
@SerializedName(PROPERTY_HIGH_PERFORMANCE)
private final InheritableProperty<Boolean> highPerformance =
newInheritableProperty(PROPERTY_HIGH_PERFORMANCE, false);
/// Returns the high performance GPU preference property.
public InheritableProperty<Boolean> highPerformanceProperty() {
return highPerformance;
}
/// Property name for customized environment variables.
public static final String PROPERTY_ENVIRONMENT_VARIABLES = "environmentVariables";
@@ -800,15 +811,15 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Property name for using native GLFW.
public static final String PROPERTY_USE_NATIVE_GLFW = "useNativeGLFW";
public static final String PROPERTY_USE_NATIVE_GLFW_OR_SDL = "useNativeGLFW";
/// If `true`, HMCL will use native GLFW.
@SerializedName(PROPERTY_USE_NATIVE_GLFW)
private final InheritableProperty<Boolean> useNativeGLFW = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW, false);
@SerializedName(PROPERTY_USE_NATIVE_GLFW_OR_SDL)
private final InheritableProperty<Boolean> useNativeGLFWorSDL = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW_OR_SDL, false);
/// Returns the native GLFW property.
public InheritableProperty<Boolean> useNativeGLFWProperty() {
return useNativeGLFW;
public InheritableProperty<Boolean> useNativeGLFWorSDLProperty() {
return useNativeGLFWorSDL;
}
/// Property name for using native OpenAL.
@@ -916,11 +927,11 @@ public sealed abstract class GameSettings extends ObservableSetting {
}
/// Finds the effective Java runtime.
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable Version version) throws InterruptedException {
public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable GameInstanceManifest manifest) throws InterruptedException {
JavaVersionType javaVersionType = getInheritable(GameSettings::javaTypeProperty);
switch (javaVersionType) {
case AUTO:
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
case CUSTOM:
try {
return JavaManager.getJava(Path.of(getInheritable(GameSettings::customJavaPathProperty)));
@@ -930,7 +941,7 @@ public sealed abstract class GameSettings extends ObservableSetting {
case VERSION: {
String javaVersion = getInheritable(GameSettings::customJavaVersionProperty);
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
int majorVersion = -1;
@@ -948,13 +959,13 @@ public sealed abstract class GameSettings extends ObservableSetting {
Collection<JavaRuntime> allJava = JavaManager.getAllJava().stream()
.filter(it -> it.getParsedVersion() == finalMajorVersion)
.collect(Collectors.toList());
return JavaManager.findSuitableJava(allJava, gameVersion, version);
return JavaManager.findSuitableJava(allJava, gameVersion, manifest);
}
case DETECTED: {
DetectedJava detectedJava = getInheritable(GameSettings::detectedJavaProperty);
String javaVersion = detectedJava.version();
if (StringUtils.isBlank(javaVersion)) {
return JavaManager.findSuitableJava(gameVersion, version);
return JavaManager.findSuitableJava(gameVersion, manifest);
}
if (StringUtils.isNotBlank(detectedJava.pathHash())) {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
@@ -44,7 +29,7 @@ import java.util.Objects;
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
/// The JSON schema supported by this game settings preset store.
public static final JsonSchema CURRENT_SCHEMA =
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 1));
/// Creates an empty game settings preset store.
public GameSettingsPresets() {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
/// The initial display mode of the game window.
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jackhuang.hmcl.util.gson.JsonSchema;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonObject;
@@ -156,7 +141,14 @@ final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
///
/// @param value the settings object to observe
void installAutoSave(T value) {
value.addListener(source -> save(value));
value.addListener(observable -> {
if (value.shouldSaveImmediately(observable)) {
save(value);
value.setSavePending(false);
} else {
value.setSavePending(true);
}
});
}
/// Saves a settings object.
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
@@ -34,12 +19,12 @@ import org.hildan.fxgson.creators.ObservableSetCreator;
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AccountID;
import org.jackhuang.hmcl.game.GameInstanceID;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
import org.jackhuang.hmcl.theme.BuiltinBackground;
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
import org.jackhuang.hmcl.theme.ThemeColor;
import org.jackhuang.hmcl.theme.ThemeReference;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
@@ -73,7 +58,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
/// Default launcher theme used when no stored theme reference is available.
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
/// Theme appearance override key for theme brightness mode.
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
@@ -191,32 +175,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
return language;
}
/// Whether preview builds are accepted by update checks.
@SerializedName("acceptPreviewUpdate")
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
/// Returns the preview update opt-in property.
public BooleanProperty acceptPreviewUpdateProperty() {
return acceptPreviewUpdate;
}
/// Whether automatic update dialogs are disabled.
@SerializedName("disableAutoShowUpdateDialog")
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
/// Returns the automatic update dialog disable property.
public BooleanProperty disableAutoShowUpdateDialogProperty() {
return disableAutoShowUpdateDialog;
}
/// Whether April Fools features are disabled.
@SerializedName("disableAprilFools")
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
/// Returns the April Fools disable property.
public BooleanProperty disableAprilFoolsProperty() {
return disableAprilFools;
}
/// The common Minecraft directory selection mode.
@SerializedName("commonDirectoryType")
@@ -264,23 +223,6 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
// UI
// Theme selection
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
@SerializedName("selectedTheme")
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
/// Returns the selected installed theme property.
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
return selectedTheme;
}
/// Returns the selected installed theme, falling back to the built-in default theme.
public ThemeReference getSelectedThemeOrDefault() {
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
}
// Theme appearance overrides
/// Theme appearance setting keys overridden by the launcher.
@@ -480,17 +422,19 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// Whether UI animations are disabled.
@SerializedName("animationDisabled")
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
FXUtils.REDUCED_MOTION == Boolean.TRUE
|| !JavaRuntime.CURRENT_JIT_ENABLED
|| !FXUtils.GPU_ACCELERATION_ENABLED
);
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
/// Returns the UI animation disable property.
public BooleanProperty animationDisabledProperty() {
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
return animationDisabled;
}
public boolean isAnimationDisabled() {
// Avoid accessing FXUtils too early during startup
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
}
// Networks
/// Whether HMCL automatically selects the number of download threads.
@@ -513,7 +457,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected game version list download source.
@SerializedName("versionListSource")
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected game version list download source property.
public ObjectProperty<DownloadSource> versionListSourceProperty() {
@@ -522,7 +466,7 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
/// The selected file download source.
@SerializedName("fileDownloadSource")
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.MIRROR);
/// Returns the selected file download source property.
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
@@ -621,34 +565,34 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
///
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
@SerializedName(PROPERTY_SELECTED_INSTANCE)
private final ObservableMap<GameDirectoryID, String> selectedInstance = FXCollections.observableHashMap();
private final ObservableMap<GameDirectoryID, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
/// Returns selected instance IDs keyed by game directory ID.
///
/// The map stores persisted selected instance values by game directory ID.
public ObservableMap<GameDirectoryID, String> getSelectedInstance() {
public ObservableMap<GameDirectoryID, GameInstanceID> getSelectedInstance() {
return selectedInstance;
}
/// Returns the selected instance ID for the given game directory ID.
///
/// The value is loaded by the game repository for the matching game directory.
public @Nullable String getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
public @Nullable GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
}
/// Sets the selected instance ID for the given game directory ID.
///
/// Blank values remove the persisted selected instance entry.
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable GameInstanceID selectedInstance) {
if (gameDirectoryId == null) {
return;
}
if (StringUtils.isBlank(selectedInstance)) {
this.selectedInstance.remove(gameDirectoryId);
} else {
if (selectedInstance != null) {
this.selectedInstance.put(gameDirectoryId, selectedInstance);
} else {
this.selectedInstance.remove(gameDirectoryId);
}
}
@@ -1,37 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import org.jackhuang.hmcl.util.gson.JsonSchema;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.ObservableSetting;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
@@ -54,6 +37,15 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
register();
}
/// Returns whether the changed field should trigger automatic persistence immediately.
@Override
public boolean shouldSaveImmediately(Observable observable) {
return observable != x
&& observable != y
&& observable != width
&& observable != height;
}
/// The schema used by this launcher state file.
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
@@ -179,25 +171,6 @@ public final class LauncherState extends ObservableSetting implements JsonSchema
this.height.set(height);
}
/// The latest update version that has already prompted the user.
@SerializedName("promptedVersion")
private final StringProperty promptedVersion = new SimpleStringProperty();
/// Returns the latest prompted update version.
public @Nullable String getPromptedVersion() {
return promptedVersion.get();
}
/// Returns the latest prompted update version property.
public StringProperty promptedVersionProperty() {
return promptedVersion;
}
/// Sets the latest prompted update version.
public void setPromptedVersion(@Nullable String promptedVersion) {
this.promptedVersion.set(promptedVersion);
}
/// Tip markers that prevent repeated prompts.
@SerializedName("shownTips")
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
/**
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.*;
@@ -352,7 +337,6 @@ public final class LegacyConfigMigrator {
JsonObject current = new JsonObject();
current.add(JsonSchema.PROPERTY_SCHEMA, JsonUtils.GSON.toJsonTree(UserState.CURRENT_SCHEMA, JsonSchema.class));
copyMember(legacy, current, "agreementVersion");
copyMember(legacy, current, "terracottaAgreementVersion");
copyMember(legacy, current, "platformPromptVersion");
return Objects.requireNonNull(JsonUtils.GSON.fromJson(current, UserState.class));
}
@@ -411,7 +395,6 @@ public final class LegacyConfigMigrator {
moveMember(json, state, "height");
migrateLegacyWindowContentSize(state, "width");
migrateLegacyWindowContentSize(state, "height");
moveMember(json, state, "promptedVersion");
moveMember(json, state, "shownTips");
LauncherState result = JsonUtils.GSON.fromJson(state, LauncherState.class);
@@ -442,18 +425,12 @@ public final class LegacyConfigMigrator {
if (authlibInjectorServers != null) {
servers.add("servers", authlibInjectorServers);
}
JsonElement addedLittleSkin = json.remove("addedLittleSkin");
boolean shouldAddLittleSkin = !(addedLittleSkin instanceof JsonPrimitive primitive
&& primitive.isBoolean()
&& primitive.getAsBoolean());
json.remove("addedLittleSkin");
AuthlibInjectorServerList result = JsonUtils.GSON.fromJson(servers, AuthlibInjectorServerList.class);
if (result == null) {
result = new AuthlibInjectorServerList();
}
if (shouldAddLittleSkin) {
result.addLittleSkinIfAbsent();
}
return result;
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonElement;
@@ -73,22 +58,22 @@ public final class LegacyGameSettingsMigrator {
}
/// Legacy `VersionIconType` ordinal order used by old local settings.
private static final VersionIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
VersionIconType.DEFAULT,
VersionIconType.GRASS,
VersionIconType.CHEST,
VersionIconType.CHICKEN,
VersionIconType.COMMAND,
VersionIconType.OPTIFINE,
VersionIconType.CRAFT_TABLE,
VersionIconType.FABRIC,
VersionIconType.FORGE,
VersionIconType.NEO_FORGE,
VersionIconType.FURNACE,
VersionIconType.QUILT,
VersionIconType.APRIL_FOOLS,
VersionIconType.CLEANROOM,
VersionIconType.LEGACY_FABRIC
private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
GameInstanceIconType.DEFAULT,
GameInstanceIconType.GRASS,
GameInstanceIconType.CHEST,
GameInstanceIconType.CHICKEN,
GameInstanceIconType.COMMAND,
GameInstanceIconType.OPTIFINE,
GameInstanceIconType.CRAFT_TABLE,
GameInstanceIconType.FABRIC,
GameInstanceIconType.FORGE,
GameInstanceIconType.NEO_FORGE,
GameInstanceIconType.FURNACE,
GameInstanceIconType.QUILT,
GameInstanceIconType.APRIL_FOOLS,
GameInstanceIconType.CLEANROOM,
GameInstanceIconType.LEGACY_FABRIC
};
/// Legacy `JavaVersionType` ordinal order used by old game settings.
@@ -130,14 +115,14 @@ public final class LegacyGameSettingsMigrator {
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
HMCLGameRepository repository,
String instanceId,
GameInstanceID instanceId,
@Nullable GameSettingsPresetID parent) {
Path instanceRoot = repository.getVersionRoot(instanceId);
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
if (!Files.exists(file)) {
return null;
}
Path receiptLocation = repository.getInstanceStateDirectory(instanceId)
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
if (MigrationReceipt.matches(receiptLocation, file)) {
LOG.info("Skipping already migrated legacy version setting " + file);
@@ -271,7 +256,7 @@ public final class LegacyGameSettingsMigrator {
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
GameSettings.PROPERTY_NATIVES_DIRECTORY,
GameSettings.PROPERTY_USE_NATIVE_GLFW,
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
GameSettings.PROPERTY_USE_NATIVE_OPENAL
));
}
@@ -342,7 +327,7 @@ public final class LegacyGameSettingsMigrator {
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
target.useCustomNativesProperty().setValue(useCustomNatives);
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
target.useNativeGLFWProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
}
@@ -488,10 +473,10 @@ public final class LegacyGameSettingsMigrator {
}
/// Parses the legacy icon selection with frozen ordinal order.
private static VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
if (primitive == null) {
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
try {
@@ -499,11 +484,11 @@ public final class LegacyGameSettingsMigrator {
int index = primitive.getAsInt();
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
? LEGACY_VERSION_ICON_TYPES[index]
: VersionIconType.DEFAULT;
: GameInstanceIconType.DEFAULT;
}
String value = primitive.getAsString();
for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) {
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
if (iconType.name().equalsIgnoreCase(value)) {
return iconType;
}
@@ -511,7 +496,7 @@ public final class LegacyGameSettingsMigrator {
} catch (RuntimeException ignored) {
}
return VersionIconType.DEFAULT;
return GameInstanceIconType.DEFAULT;
}
/// Reads an enum property from either ordinal or name.
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonParseException;
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Ranks BMCLAPI-compatible mirror roots by a lightweight latency probe.
///
/// Game downloads are many small/medium files. Racing the same file across mirrors wastes
/// volunteer OpenBMCLAPI bandwidth; ranking entry points and keeping file-level concurrency
/// (plus ordered fallback) is the better fit for that workload.
@NotNullByDefault
public final class MirrorRoots {
/// Public BMCLAPI entry points that speak the same path layout.
public static final @Unmodifiable List<String> DEFAULT_ROOTS = List.of(
"https://bmclapi2.bangbang93.com",
"https://bmclapi.bangbang93.com"
);
private static final String PROBE_PATH = "/mc/game/version_manifest.json";
private static final long REPROBE_INTERVAL_NANOS = 30L * 60L * 1_000_000_000L;
private final CopyOnWriteArrayList<String> rankedRoots = new CopyOnWriteArrayList<>(DEFAULT_ROOTS);
private final AtomicBoolean probing = new AtomicBoolean(false);
private volatile long lastProbeNanos;
/// Creates a ranker seeded with the built-in BMCLAPI roots, or a single override root.
public MirrorRoots(@Nullable String overrideRoot) {
if (StringUtils.isNotBlank(overrideRoot)) {
WebURL url;
try {
url = WebURL.parseBrowserInput(overrideRoot);
} catch (Exception e) {
url = null;
LOG.warning("Invalid BMCLAPI override URL: " + overrideRoot, e);
}
if (url != null) {
rankedRoots.clear();
rankedRoots.add(StringUtils.removeSuffix(url.toString(), "/"));
}
}
}
/// Returns the current ranked mirror roots (fastest first). Never empty.
public @Unmodifiable List<String> getRankedRoots() {
List<String> snapshot = List.copyOf(rankedRoots);
return snapshot.isEmpty() ? DEFAULT_ROOTS : snapshot;
}
/// Returns the current primary (fastest) mirror root.
public String getPrimaryRoot() {
return getRankedRoots().get(0);
}
/// Starts a background probe when none is running and the previous result is stale.
public void maybeProbeAsync() {
if (rankedRoots.size() <= 1)
return;
long now = System.nanoTime();
if (now - lastProbeNanos < REPROBE_INTERVAL_NANOS && lastProbeNanos != 0)
return;
if (!probing.compareAndSet(false, true))
return;
Schedulers.io().execute(() -> {
try {
probeAndRank();
} catch (Throwable t) {
LOG.warning("Failed to probe download mirrors", t);
} finally {
lastProbeNanos = System.nanoTime();
probing.set(false);
}
});
}
private void probeAndRank() {
List<String> candidates = List.copyOf(rankedRoots);
if (candidates.size() <= 1)
return;
record Score(String root, long nanos, boolean ok) {
}
List<Score> scores = new ArrayList<>(candidates.size());
for (String root : candidates) {
long started = System.nanoTime();
boolean ok = false;
try {
HttpURLConnection connection = NetworkUtils.createHttpConnection(root + PROBE_PATH);
connection.setInstanceFollowRedirects(true);
connection.setRequestMethod("GET");
connection.setRequestProperty("Range", "bytes=0-0");
connection.connect();
int code = connection.getResponseCode();
// Accept normal OK, partial content, or redirect-resolved success.
ok = code >= 200 && code < 400;
connection.disconnect();
} catch (Exception e) {
LOG.info("Mirror probe failed for " + root + ": " + e);
}
scores.add(new Score(root, System.nanoTime() - started, ok));
}
scores.sort(Comparator
.comparing((Score score) -> !score.ok)
.thenComparingLong(Score::nanos));
List<String> next = scores.stream().map(Score::root).toList();
if (!next.equals(candidates)) {
rankedRoots.clear();
rankedRoots.addAll(next);
LOG.info("Ranked download mirrors: " + next);
}
}
}
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonArray;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
public final class SambaException extends RuntimeException {
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import org.jetbrains.annotations.NotNullByDefault;
@@ -1,20 +1,5 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.setting;
import com.google.gson.JsonParseException;
@@ -55,6 +40,20 @@ public final class SettingsManager {
private SettingsManager() {
}
/// Saves deferred launcher state changes and shutdown file saver.
public static void shutdown() {
savePendingChanges();
FileSaver.shutdown();
}
/// Saves deferred launcher state changes.
public static void savePendingChanges() {
if (launcherState != null && launcherState.isSavable() && launcherState.isSavePending()) {
launcherState.setSavePending(false);
STATE_FILE.save(launcherState);
}
}
/// The local directory storing per-workspace configuration files.
private static final Path LOCAL_CONFIG_FILES_DIRECTORY = Metadata.HMCL_LOCAL_HOME.resolve("config");
Loaded 100 of 1173 files, more files were not shown because too many files have changed in this diff. Show more