Compare commits

...
Author SHA1 Message Date
Glavo 86085031f6 优化自动切换游戏语言功能 (#4353) 2025-08-31 14:57:23 +08:00
Glavo 30f47704f1 更新文言文翻译 (#4364) 2025-08-31 12:18:34 +08:00
Glavo 82e0cd1ca1 Fix #3669: 修复首次打开子页面时切换动画丢失的问题 (#4365) 2025-08-31 12:11:13 +08:00
Wulian233andGlavo 5374127241 修改部分文言条目 (#4359)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-31 00:59:14 +08:00
Glavo df489c0cfe 添加乌克兰语支持 (#4363) 2025-08-31 00:48:22 +08:00
BANERandGlavo d83f6106f5 Add I18N_uk.properties (#4346)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-31 00:33:16 +08:00
Glavo b7361c8da4 优化语言设置 (#4362) 2025-08-31 00:18:25 +08:00
Glavo 73531dbf60 优化文言文翻译 (#4361) 2025-08-30 22:21:45 +08:00
Andypsl8 5af6858d4c 更新文言文翻译 (#4356) 2025-08-30 02:06:33 +08:00
Glavo 1a3b473356 Fix #3076: 修复无法识别部分简体中文环境的问题 (#4350) 2025-08-29 20:31:05 +08:00
Glavo 67e4dddda1 添加文言文支持 (#4348) 2025-08-29 14:42:10 +08:00
Glavo 55816032b7 启动游戏时隐藏 GameAssetDownloadTask (#4349) 2025-08-29 12:25:49 +08:00
Glavo b9fd0e50a7 为迁移至 Java 17 做准备 (#4311) 2025-08-28 20:43:03 +08:00
Glavo 71d54955ad 修复 TaskListPane.StatusChangeListener 持有 ListCell 的强引用的问题 (#4347) 2025-08-28 20:21:55 +08:00
Burning_TNTandGlavo d34dc82db6 Feature: Decompress legacy HMCL log files while exporting. (#4342)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-27 21:44:33 +08:00
Glavo 73214ad599 不对回环地址应用代理 (#4338) 2025-08-27 21:43:54 +08:00
Wulian233 1ddb73595d 修复世界管理页面不支持显示极限模式的问题 (#4321) 2025-08-27 21:41:52 +08:00
Glavo f9ee9c8592 更严格地检测文件名合法性 (#4341) 2025-08-27 20:10:52 +08:00
Burning_TNT 0fdfc945c3 Fix: IJ support (#4343) 2025-08-26 22:43:26 +08:00
YuChuXi 97219c951e 游戏崩溃窗口中增加对拍照的提醒 (#4294) 2025-08-26 21:54:42 +08:00
Glavo 64cc64095d 增加 TaskListPane 中任务条目之间的间距 (#4340) 2025-08-26 21:51:59 +08:00
辞庐 91a613d966 HintPane 使用新版 Bootstrap 调色盘 (#4323) 2025-08-24 22:30:14 +08:00
Glavo 06b7b1e574 Fix #4325: 修复未将 15w14a 识别为愚人节版本的问题 (#4327) 2025-08-24 22:27:49 +08:00
Glavo 933b7cab2f 将 URI 中的错误代理对编码为 U+FFFD (#4329) 2025-08-24 22:27:18 +08:00
Glavo 885e25d07a 更新文档 (#4328) 2025-08-24 20:33:13 +08:00
Glavo 421f9a3b2b 支持以字符串形式序列化 VersionSetting 中的枚举成员 (#4299) 2025-08-21 21:56:49 +08:00
Glavo e52cfb036e 优化 Task.getCaller() (#4308) 2025-08-21 20:53:19 +08:00
3gf8jv4dv 87a3a4ece3 chore(locale): add new translations for russian and spanish (#4304) 2025-08-21 20:45:40 +08:00
3gf8jv4dv b152e2bfab fix(locale): add missing i18n when installing an instance with quilt api (#4303) 2025-08-21 20:45:08 +08:00
Glavo c9c97a45b7 修复 TaskListPane 中任务总数显示错误的问题 (#4310) 2025-08-21 20:43:19 +08:00
Glavo cba4ba2799 删除无用的 debug 日志 (#4309) 2025-08-21 20:42:51 +08:00
3gf8jv4dv ab9eea3620 fix(locale): revise mcmod-info-mirror description on thanks list (#4305) 2025-08-21 20:42:22 +08:00
Glavoand3gf8jv4dv 96f2d011ca 优化 TaskListPane 的显示效果 (#4306)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-21 16:45:07 +08:00
Glavo 9ee55cd17d 修复 ModrinthModpackExportTask 名称未被翻译的问题 (#4301) 2025-08-20 21:47:56 +08:00
Glavo 07ee7867da 修复 VersionIconType 成员的排列顺序 (#4298) 2025-08-20 21:20:31 +08:00
Glavo 940d4f722c 修复 TaskListPane 可能出现横向滚动条的问题 (#4297) 2025-08-20 20:34:12 +08:00
Glavo af56096162 优化对于 IntelliJ IDEA 的支持 (#4270) 2025-08-20 20:33:03 +08:00
Glavo 0e12c1a132 Fix #4290: 在 macOS 上禁止最大化和全屏模式 (#4293) 2025-08-20 15:40:48 +08:00
Glavo ca20509cb1 优化 TaskListPane (#4292) 2025-08-20 00:32:40 +08:00
HenryxjhandGlavo 58c93ff349 修复 Windows 平台 GPU 信息可能显示为 "[B@xxx" 的问题 (#4288)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-19 21:33:10 +08:00
Glavo aab3b22290 修复 TaskListPane 未正确处理 StagesHintTask 的问题 (#4285) 2025-08-19 16:22:51 +08:00
Wulian233 6cf933741f 基于实际屏幕分辨率添加游戏窗口分辨率选项 (#4275) 2025-08-18 20:05:34 +08:00
Glavo 9d17834227 Fix #4281: 支持解析 Indev 版本号 (#4282) 2025-08-18 20:04:22 +08:00
Glavo 7805508d4c Fix #2676: 优化版本号排序规则 (#4276) 2025-08-18 16:24:03 +08:00
Wulian233 72f6c2faf3 修复 1.21.5 以上的地图跳转种子地图错误 (#4274) 2025-08-18 16:21:07 +08:00
Glavo 7035767828 修复 pending 类型的游戏版本被错误识别为愚人节版的问题 (#4273) 2025-08-16 16:08:58 +08:00
Wulian233andGlavo 13a2777c87 为 NeoForge 下载添加正式版/快照分类 (#4268)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-16 15:58:31 +08:00
Glavo a4f9438d9e 移除 Pack200 支持 (#4267) 2025-08-16 15:45:12 +08:00
Glavo 4445021d66 Fix #4061: 在 Windows 7/8/8.1 上使用 llvmpipe-loader 加载 mesa (#4224) 2025-08-14 23:07:23 +08:00
Glavo f6a3b76a0b 暂时禁用 MCIM 加速 (#4265) 2025-08-14 20:20:46 +08:00
Glavo 42bb602ff5 优化游戏下载页 (#4264) 2025-08-14 20:14:40 +08:00
Glavo 4547448718 优化游戏窗口大小选择功能 (#4234) 2025-08-14 16:32:14 +08:00
Glavo 10bda7e6f4 转义 URI 中的中括号和大括号 (#4261) 2025-08-14 16:16:43 +08:00
Glavo f239140055 Update amdgpu.ids (#4262) 2025-08-14 15:54:30 +08:00
Glavo 6f4b557f63 支持最大化和全屏模式 (#4260) 2025-08-14 15:10:02 +08:00
Glavo 5118ce4e62 Fix #4256: 修复游戏下载页面可能崩溃的问题 (#4257) 2025-08-13 16:14:26 +08:00
ZkiteflyandGlavo a705b7cf95 Fix 3955: 启动时不应补全服务端自动更新整合包中已禁用的 mod (#3956)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 16:03:53 +08:00
GlavoandCopilot 0d5d1ae4e6 将 authlib-injector 嵌入启动器 (#4246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 15:37:09 +08:00
Glavo b57547415b 在未安装游戏版本时支持一键下载启动游戏 (#4248) 2025-08-13 15:36:53 +08:00
Wulian233andGlavo b60d889bf7 为愚人节分类添加图标 (#4249)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-13 15:17:47 +08:00
Glavo 2f463c1eda 在 ./.hmcl/java 中搜索 Java (#4179) 2025-08-11 20:59:30 +08:00
Wulian233 d1e006ed29 修复版本列表排序错误的问题 (#4244) 2025-08-11 20:57:05 +08:00
GlavoandCopilot ba58905c9c 优化游戏下载页版本分类功能 (#4141)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-11 16:52:03 +08:00
Glavo de221b9dae 修复非 Windows 平台上游戏进程的 user.home 可能被设置为 null 的问题 (#4241) 2025-08-11 16:08:27 +08:00
Glavo 0913c5e710 支持通过 MCIM 加速模组搜索 (#4225) 2025-08-11 16:06:59 +08:00
ZkiteflyandGlavo 41fd38d8d1 导出日志时附带最近日志 (#4051)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:55:01 +08:00
ENC_EuphonyandGlavo 7ec880852f 修复部分版本 wiki 链接不正确的问题 (#4239)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-10 21:41:44 +08:00
Glavo 9213b7fb2e Fix 4232: 修复初始化 CacheRepository 导致启动器崩溃的问题 (#4235) 2025-08-10 21:41:10 +08:00
Glavo 80f1367d9a Fix #4233: 修复 RemoteMod::getIntegrityCheck 未选择正确的算法的问题 (#4238) 2025-08-10 15:39:13 +08:00
Glavo f521a041a9 Bump Shadow Plugin to 9.0.1 (#4220) 2025-08-10 15:23:44 +08:00
be9efbe6ee 修复版本 Wiki 映射 (#4211)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:06:31 +08:00
辞庐and3gf8jv4dv 8f3c716ca7 Fix #4127: 修改整合包下载页面描述 (#4200)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-10 15:05:57 +08:00
Glavo 3cdddfa86d 在实例名称中包含非字母、数字、减号和下划线字符时弹出警告 (#4222) 2025-08-09 21:29:41 +08:00
Glavo b1d8edc72a FetchTask 启用透明 GZip 压缩 (#4219) 2025-08-08 16:24:48 +08:00
Glavo bbc546e2a0 创建 ByteArray 辅助类 (#4214) 2025-08-07 16:14:10 +08:00
Liu Wenyuan f621776cdd 修复 APNG Truecolour16Alpha 处理器 clone 方法返回类型错误的问题 (#4210) 2025-08-07 15:04:57 +08:00
ENC_Euphony 7ba9d9eadd 智能选择 popup 弹出位置 (#4203) 2025-08-07 15:01:18 +08:00
ZkiteflyandGlavo ac4c3a7501 模组管理支持搜索根据更多数据搜索内容 (#3230)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-06 21:02:36 +08:00
Glavo f95ebe2224 优化 GetTask 中 ByteArrayOutputStream 的初始大小 (#4209) 2025-08-06 20:43:51 +08:00
辞庐and3gf8jv4dv 7ee2c51b81 将 Apple M1 平台 改为 Apple Silicon 平台 (#4199)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-08-06 20:40:24 +08:00
Glavo 192c90816b 修复 #3716 与 main 的冲突 (#4208) 2025-08-06 20:24:46 +08:00
1a772e8c65 添加 Modrinth 整合包导出 (#3716)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 20:11:39 +08:00
3gf8jv4dv 95bed4712c chore(locale): port and fix translation in native directory path preview (#4197) 2025-08-06 16:51:29 +08:00
Glavo 2c6ceb3cb0 优化 data URI 支持 (#4206) 2025-08-06 16:48:37 +08:00
Glavo 3118c87c65 实验性支持 APNG 图片 (#4205) 2025-08-06 16:10:45 +08:00
ENC_Euphony f945a85441 Fix: 修复一些 typo (#4202) 2025-08-05 22:51:06 +08:00
辞庐 72ffbb4176 为弹窗文本添加平滑滚动 (#4201) 2025-08-05 22:50:47 +08:00
辞庐 77dc5dbe06 修复整合包格式不匹配提示未被正确格式化的问题 (#4176) 2025-08-04 20:32:42 +08:00
Glavo 0a1d6be7b7 优化 ModTranslations::loadFromResource (#4178) 2025-08-04 20:31:29 +08:00
Wulian233 bb3d03fc72 修复一些代码中的 typo (#4190) 2025-08-04 20:30:36 +08:00
Glavo a0ddaf6766 GetTask 应当遵循 Content-Type 中的字符集设置 (#4183) 2025-08-04 20:28:46 +08:00
辞庐andGlavo 0bd2adc3c6 Fix #4187: 修复 LWJGL 路径显示错误的问题 (#4192)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 20:24:19 +08:00
Glavo 77c3ef6ced 修复未正确处理 EnumBackgroundImage.TRANSLUCENT 的问题 (#4195) 2025-08-04 20:18:29 +08:00
3gf8jv4dv a66ddb9a20 chore(locale): port character length limit translation to es and ru (#4191) 2025-08-04 20:17:23 +08:00
3gf8jv4dv eef365b37e chore(locale): port background option translation to es and ru (#4194) 2025-08-04 17:10:19 +08:00
3gf8jv4dv 5cab603b6e chore(locale): port font anti-aliasing option translation to es and ru (#4193) 2025-08-04 17:09:54 +08:00
神奇的赵二 (OrzMiku) 6c5d07adad fix(#4166): 新增内存显示条禁用样式 (#4185) 2025-08-04 17:08:19 +08:00
3gf8jv4dv 1935a7021d fix(locale): corrent some typos in zh-tw (#4189) 2025-08-04 16:46:11 +08:00
3gf8jv4dv 31c46c963d fix(locale): corrent residual jvm terms in chinese (#4188) 2025-08-04 16:09:21 +08:00
吞云(TuYw)andGlavo 4bc6ffa0f0 支持修改主页背景的不透明度 (#3205)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-04 16:05:47 +08:00
Glavo b62659de62 支持纯色背景 (#4184) 2025-08-04 15:17:46 +08:00
Glavo 9a27c27b9a 支持使用 data URI 作为背景图片源 (#4182) 2025-08-03 20:37:02 +08:00
Glavo 74c647cc97 统一转义 URI 中的特殊字符 (#4181) 2025-08-03 19:34:34 +08:00
Glavo e2df8e25b1 修复无法找到不包含 release 文件的 Java 的问题 (#4180) 2025-08-03 19:20:11 +08:00
Glavo 9969dc60c5 优化 ArchiveFileTree (#4177) 2025-08-02 19:33:44 +08:00
辞庐andGlavo 6230b436ad Fix #4158 : 缺少 Microsoft OAuth App ID 时禁用微软账户的刷新选项 (#4160)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-08-02 18:15:03 +08:00
Glavo 3873459aaa 通过 x-bmclapi-hash 缓存资源 (#4169) 2025-08-02 16:04:26 +08:00
3gf8jv4dv 4ee3857427 fix(locale): add missing modrinth category i18n keys (#4149) 2025-08-02 15:43:12 +08:00
Glavo 2617dd3630 修复无法加载 WebP 图标的问题 (#4171) 2025-08-02 15:37:48 +08:00
辞庐 e0425102c8 fix: 无法从 BMCLAPI 下载 NeoForge 所有所需文件 (#4174) 2025-08-02 15:33:42 +08:00
辞庐 a470475c21 模组下载列表翻页后自动回到顶部 (#4170) 2025-08-02 15:22:47 +08:00
Glavo bcd73e6d15 修复下载缓存不生效的问题 (#4167) 2025-08-01 20:53:32 +08:00
Glavo 7d6f21dc3a Bump Gradle to 9.0.0 (#4168) 2025-08-01 19:26:11 +08:00
GlavoandCopilot 3eddfa23b7 优化 JsonUtils (#4161)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-31 20:41:27 +08:00
Glavo d5fa232704 忽略 %ProgramFiles%\Common Files\Oracle\Java 中的 Java (#4142) 2025-07-31 20:17:27 +08:00
Glavo c84afb3155 通过主页右下角弹出菜单切换版本后隐藏菜单 (#4163) 2025-07-31 19:41:43 +08:00
Glavo 401fef5774 创建 checkstyle task (#4162) 2025-07-31 19:41:02 +08:00
Glavo 9298fe48e1 支持通过环境变量强制使用 GPU 渲染和控制动画帧率 (#4164) 2025-07-31 19:39:45 +08:00
Glavo 7f51e0395c 遵循系统动画开关设置 (#4148) 2025-07-31 15:37:40 +08:00
辞庐 4f410fb8ad 修复“下载线程数”的指示器被提示覆盖的问题 (#4153) 2025-07-31 15:05:23 +08:00
Glavo 9bcf95287a Fix #4146: 修复导入异常格式的数据包时启动器崩溃的问题 (#4152) 2025-07-31 15:04:56 +08:00
3gf8jv4dv 5703377626 fix(locale): restore accidentally removed translations (#4155) 2025-07-31 15:04:41 +08:00
Glavo 3adb3a67e9 将代码中的 URL 替换为 URI (#4131) 2025-07-30 18:19:46 +08:00
辞庐 7918b333e6 README 添加 CurseForge API Key 覆写参数 (#4147) 2025-07-30 15:34:29 +08:00
Glavo 329b0e8f3a 在 Windows 平台优先使用注册表探测 GPU (#4145) 2025-07-30 15:32:42 +08:00
辞庐 3b17e0a897 优化主页公告关闭按钮 (#4064) 2025-07-29 23:13:06 +08:00
辞庐 e10f1dac20 修复日志窗口内结束游戏进程会结束启动的所有游戏的问题 (#4130) 2025-07-29 17:37:42 +08:00
27Onion NebellandGlavo 86c171545e Update version check logic to support version like "26-ea" (#4128)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-29 17:28:24 +08:00
ShulkerSakuraandGlavo c3a3881cfc Added 16 characters length detection of username. (#4088)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-28 16:09:46 +08:00
Glavo 7f04cdd6b1 在日志中记录更多系统信息 (#4126) 2025-07-28 15:58:13 +08:00
Wulian233 27361c65fe Fix #4063: 修复游戏版本列表错误地过滤掉部分版本的问题 (#4066) 2025-07-27 03:01:39 +08:00
Burning_TNT ffa7afcbab 优化 GitHub Action 工作流 (#4037) 2025-07-27 00:46:11 +08:00
8Mi_Yile 9aa1367823 更新 unlisted-versions-of-minecraft 镜像源地址 (#4120) 2025-07-27 00:43:00 +08:00
Roj234andGlavo acc96b95c8 修复多次启动游戏时内存泄露的问题 (#4121)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-07-27 00:40:22 +08:00
Glavo 46502875c5 简化 FileUtils (#4119) 2025-07-26 20:10:38 +08:00
Glavo 43b769923b 使用 readAllBytes 和 transferTo 简化代码 (#4115) 2025-07-26 19:49:05 +08:00
Zkitefly fe3fc0413c 更换 unlisted-versions-of-minecraft 镜像源 (#4095) 2025-07-26 01:29:12 +08:00
Glavo fd13088726 添加字体抗锯齿算法设置选项 (#4118) 2025-07-26 01:28:35 +08:00
3gf8jv4dv 195db2ee6d fix(locale): unify terminology usage of 'JVM' and 'Java' (#3690) 2025-07-25 17:05:05 +08:00
Glavo 96f68e1017 不再使用反射调用 Java 9~11 中引入的 API (#4114) 2025-07-25 17:02:37 +08:00
Glavo 24d3bbf4fe Bump HMCLauncher to 3.7.0.0 (#4113) 2025-07-25 16:09:20 +08:00
Glavoand3gf8jv4dv d60a923841 使用 Java 11 构建 HMCL (#4078)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-07-25 16:05:09 +08:00
333 changed files with 14275 additions and 3401 deletions

No files matched your search

@@ -1,11 +1,13 @@
name: Check Translations
name: Check Codes
on:
push:
paths:
- '**.java'
- '**.properties'
pull_request:
paths:
- '**.java'
- '**.properties'
jobs:
@@ -13,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-version: '17'
java-package: 'jdk+fx'
- name: Check style test
run: ./gradlew checkTranslations --no-daemon
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
-25
View File
@@ -1,25 +0,0 @@
name: Check Style
on:
push:
paths:
- '**.java'
pull_request:
paths:
- '**.java'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-package: 'jdk+fx'
- name: Check style main
run: ./gradlew checkstyleMain --no-daemon
- name: Check style test
run: ./gradlew checkstyleTest --no-daemon
+2 -2
View File
@@ -13,11 +13,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '11'
java-version: '17'
java-package: 'jdk+fx'
- name: Build with Gradle
run: ./gradlew build --no-daemon
+2
View File
@@ -13,11 +13,13 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*
# gradle build
/build/
/HMCL/build/
/HMCLCore/build/
/HMCLBoot/build/
/HMCLTransformerDiscoveryService/build/
/minecraft/libraries/HMCLTransformerDiscoveryService/build/
/buildSrc/build/
+86 -45
View File
@@ -37,8 +37,11 @@ val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
version = "$versionRoot.$buildNumber"
val embedResources by configurations.registering
dependencies {
implementation(project(":HMCLCore"))
implementation(project(":HMCLBoot"))
implementation("libs:JFoenix")
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
@@ -46,6 +49,8 @@ dependencies {
if (launcherExe == null) {
implementation(libs.hmclauncher)
}
embedResources(libs.authlib.injector)
}
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -89,18 +94,64 @@ fun attachSignature(jar: File) {
}
}
val java11 = sourceSets.create("java11") {
java {
srcDir("src/main/java11")
}
}
tasks.getByName<JavaCompile>(java11.compileJavaTaskName) {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
tasks.withType<JavaCompile> {
sourceCompatibility = "11"
targetCompatibility = "11"
}
tasks.checkstyleMain {
// Third-party code is not checked
exclude("**/org/jackhuang/hmcl/ui/image/apng/**")
}
tasks.compileJava {
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
}
val hmclProperties = buildList {
add("hmcl.version" to project.version.toString())
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.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
}
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")
}
}
}
}
val addOpens = listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
)
tasks.jar {
enabled = false
dependsOn(tasks["shadowJar"])
@@ -109,6 +160,8 @@ tasks.jar {
val jarPath = tasks.jar.get().archiveFile.get().asFile
tasks.shadowJar {
dependsOn(createPropertiesFile)
archiveClassifier.set(null as String?)
exclude("**/package-info.class")
@@ -118,7 +171,7 @@ tasks.shadowJar {
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
listOf(
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*","freebsd-*", "linux-*", "darwin-*",
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
).forEach { exclude("com/sun/jna/$it/**") }
@@ -126,41 +179,17 @@ tasks.shadowJar {
exclude(dependency("com.google.code.gson:.*:.*"))
exclude(dependency("net.java.dev.jna:jna:.*"))
exclude(dependency("libs:JFoenix:.*"))
exclude(project(":HMCLBoot"))
}
manifest {
attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Implementation-Version" to project.version,
"Microsoft-Auth-Id" to microsoftAuthId,
"Microsoft-Auth-Secret" to microsoftAuthSecret,
"CurseForge-Api-Key" to curseForgeApiKey,
"Build-Channel" to versionType,
"Class-Path" to "pack200.jar",
"Add-Opens" to listOf(
"java.base/java.lang",
"java.base/java.lang.reflect",
"java.base/jdk.internal.loader",
"javafx.base/com.sun.javafx.binding",
"javafx.base/com.sun.javafx.event",
"javafx.base/com.sun.javafx.runtime",
"javafx.graphics/javafx.css",
"javafx.graphics/com.sun.javafx.stage",
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach",
).joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
System.getenv("GITHUB_SHA")?.also {
attributes("GitHub-SHA" to it)
}
}
manifest.attributes(
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
"Implementation-Version" to project.version.toString(),
"Main-Class" to "org.jackhuang.hmcl.Main",
"Multi-Release" to "true",
"Add-Opens" to addOpens.joinToString(" "),
"Enable-Native-Access" to "ALL-UNNAMED"
)
if (launcherExe != null) {
into("assets") {
@@ -175,10 +204,12 @@ tasks.shadowJar {
}
tasks.processResources {
into("META-INF/versions/11") {
from(sourceSets["java11"].output)
dependsOn(createPropertiesFile)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
dependsOn(tasks["java11Classes"])
}
val makeExecutables by tasks.registering {
@@ -263,6 +294,16 @@ fun parseToolOptions(options: String?): MutableList<String> {
return result
}
// For IntelliJ IDEA
tasks.withType<JavaExec> {
if (name != "run") {
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
// if (javaVersion >= JavaVersion.VERSION_24) {
// jvmArgs("--enable-native-access=ALL-UNNAMED")
// }
}
}
tasks.register<JavaExec>("run") {
dependsOn(tasks.jar)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -17,41 +17,26 @@
*/
package org.jackhuang.hmcl;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.util.ModuleHelper;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.ui.SwingUtils;
import org.jackhuang.hmcl.util.SwingUtils;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.Collections;
import java.util.concurrent.CancellationException;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class Main {
public final class EntryPoint {
private Main() {
private EntryPoint() {
}
public static void main(String[] args) {
@@ -62,11 +47,22 @@ public final class Main {
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
checkDirectoryPath();
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
System.getProperties().putIfAbsent("prism.forceGPU", "true");
if (JavaRuntime.CURRENT_VERSION < 9)
// This environment check will take ~300ms
thread(Main::fixLetsEncrypt, "CA Certificate Check", true);
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
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);
}
}
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
initIcon();
@@ -97,6 +93,8 @@ public final class Main {
}
}
} catch (IOException e) {
// Logger has not been started yet, so print directly to System.err
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
e.printStackTrace(System.err);
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
}
@@ -112,16 +110,17 @@ public final class Main {
}
private static void initIcon() {
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(Main.class.getResource("/assets/img/icon-mac.png"));
java.awt.Image image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png"));
AwtUtils.setAppleIcon(image);
}
private static void checkDirectoryPath() {
String currentDirectory = new File("").getAbsolutePath();
if (currentDirectory.contains("!")) {
String currentDir = System.getProperty("user.dir", "");
if (currentDir.contains("!")) {
LOG.error("The current working path contains an exclamation mark: " + currentDir);
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
+ "The path is " + currentDirectory);
+ "The path is " + currentDir);
}
}
@@ -129,10 +128,10 @@ public final class Main {
try {
SelfDependencyPatcher.patch();
} catch (SelfDependencyPatcher.PatchException e) {
LOG.error("unable to patch JVM", e);
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.missing"));
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
LOG.error("unable to patch JVM", e);
LOG.error("Unable to patch JVM", e);
showErrorAndExit(i18n("fatal.javafx.incompatible"));
} catch (CancellationException e) {
LOG.error("User cancels downloading JavaFX", e);
@@ -149,7 +148,7 @@ public final class Main {
Class.forName("javafx.stage.Stage"); // javafx.graphics
Class.forName("javafx.scene.control.Skin"); // javafx.controls
} catch (Exception e) {
e.printStackTrace(System.err);
LOG.warning("JavaFX is incomplete or not found", e);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
@@ -157,9 +156,20 @@ public final class Main {
private static void addEnableNativeAccess() {
if (JavaRuntime.CURRENT_VERSION > 21) {
try {
ModuleHelper.addEnableNativeAccess(Class.forName("javafx.stage.Stage")); // javafx.graphics
// javafx.graphics
Module module = Class.forName("javafx.stage.Stage").getModule();
if (module.isNamed()) {
try {
MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup());
MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class,
"implAddEnableNativeAccess", MethodType.methodType(Module.class));
Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module);
} catch (Throwable e) {
e.printStackTrace(System.err);
}
}
} catch (ClassNotFoundException e) {
e.printStackTrace(System.err);
LOG.error("Failed to add enable native access for JavaFX", e);
showErrorAndExit(i18n("fatal.javafx.incomplete"));
}
}
@@ -170,11 +180,11 @@ public final class Main {
if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) {
try {
Class<?> clazz = Class.forName("sun.misc.Unsafe");
Method trySetMemoryAccessWarned = clazz.getDeclaredMethod("trySetMemoryAccessWarned");
trySetMemoryAccessWarned.setAccessible(true);
trySetMemoryAccessWarned.invoke(null);
boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup())
.findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class))
.invokeExact();
} catch (Throwable e) {
e.printStackTrace(System.err);
LOG.warning("Failed to enable unsafe memory access", e);
}
}
}
@@ -182,70 +192,8 @@ public final class Main {
/**
* Indicates that a fatal error has occurred, and that the application cannot start.
*/
static void showErrorAndExit(String message) {
System.err.println(message);
System.err.println("A fatal error has occurred, forcibly exiting.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.ERROR, message).showAndWait();
exit(1);
}
} catch (Throwable ignored) {
}
private static void showErrorAndExit(String message) {
SwingUtils.showErrorDialog(message);
exit(1);
}
/**
* Indicates that potential issues have been detected, and that the application may not function properly (but it can still run).
*/
static void showWarningAndContinue(String message) {
System.err.println(message);
System.err.println("Potential issues have been detected.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.WARNING, message).showAndWait();
return;
}
} catch (Throwable ignored) {
}
SwingUtils.showWarningDialog(message);
}
private static void fixLetsEncrypt() {
try {
KeyStore defaultKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
Path ksPath = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts");
try (InputStream ksStream = Files.newInputStream(ksPath)) {
defaultKeyStore.load(ksStream, "changeit".toCharArray());
}
KeyStore letsEncryptKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream letsEncryptFile = Main.class.getResourceAsStream("/assets/lekeystore.jks")) {
letsEncryptKeyStore.load(letsEncryptFile, "supersecretpassword".toCharArray());
}
KeyStore merged = KeyStore.getInstance(KeyStore.getDefaultType());
merged.load(null, new char[0]);
for (String alias : Collections.list(letsEncryptKeyStore.aliases()))
merged.setCertificateEntry(alias, letsEncryptKeyStore.getCertificate(alias));
for (String alias : Collections.list(defaultKeyStore.aliases()))
merged.setCertificateEntry(alias, defaultKeyStore.getCertificate(alias));
TrustManagerFactory instance = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
instance.init(merged);
SSLContext tls = SSLContext.getInstance("TLS");
tls.init(null, instance.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
LOG.info("Added Lets Encrypt root certificates as additional trust");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
KeyManagementException e) {
LOG.error("Failed to load lets encrypt certificate. Expect problems", e);
}
}
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ObservableBooleanValue;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
@@ -27,6 +28,7 @@ import javafx.scene.input.DataFormat;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
@@ -54,6 +56,8 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
@@ -78,16 +82,20 @@ public final class Launcher extends Application {
LOG.warning("Failed to get prism pipeline", e);
}
LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false));
LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false));
try {
try {
ConfigHolder.init();
} catch (SambaException ignored) {
Main.showWarningAndContinue(i18n("fatal.samba"));
} catch (SambaException e) {
showAlert(AlertType.WARNING, i18n("fatal.samba"));
} catch (IOException e) {
LOG.error("Failed to load config", e);
checkConfigInTempDir();
checkConfigOwner();
Main.showErrorAndExit(i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
EntryPoint.exit(1);
}
// https://lapcatsoftware.com/articles/app-translocation.html
@@ -106,7 +114,7 @@ public final class Launcher extends Application {
}
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
}
// runLater to ensure ConfigHolder.init() finished initialization
@@ -169,7 +177,7 @@ public final class Launcher extends Application {
private static void checkConfigInTempDir() {
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
Main.exit(0);
EntryPoint.exit(0);
}
}
@@ -209,7 +217,7 @@ public final class Launcher extends Application {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
Main.exit(1);
EntryPoint.exit(1);
}
@Override
@@ -36,14 +36,15 @@ public final class Metadata {
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 8;
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 11;
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 11;
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 ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
@@ -55,8 +56,8 @@ public final class Metadata {
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.getManifestAttribute("Build-Channel", "nightly");
public static final String GITHUB_SHA = JarUtils.getManifestAttribute("GitHub-SHA", null);
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
@@ -117,7 +118,6 @@ public final class Metadata {
supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64);
else
supportedArchitectures = EnumSet.noneOf(Architecture.class);
if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH))
return String.format("https://docs.hmcl.net/downloads/%s/%s.html",
OperatingSystem.CURRENT_OS.getCheckedName(),
@@ -21,15 +21,18 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.launch.DefaultLauncher;
import org.jackhuang.hmcl.launch.ProcessListener;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
@@ -58,61 +61,83 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
private void generateOptionsTxt() {
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
if (optionsFile.exists()) {
if (config().isDisableAutoGameOptions())
return;
}
if (configFolder.isDirectory()) {
if (findFiles(configFolder, "options.txt")) {
return;
Path runDir = repository.getRunDirectory(version.getId()).toPath();
Path optionsFile = runDir.resolve("options.txt");
Path configFolder = runDir.resolve("config");
if (Files.exists(optionsFile))
return;
if (Files.isDirectory(configFolder)) {
try (Stream<Path> stream = Files.walk(configFolder, 2, FileVisitOption.FOLLOW_LINKS)) {
if (stream.anyMatch(file -> "options.txt".equals(FileUtils.getName(file))))
return;
} catch (IOException e) {
LOG.warning("Failed to visit config folder", e);
}
}
if (!I18n.isUseChinese()) {
Locale locale = Locale.getDefault();
if (Locales.isEnglish(locale))
return;
}
String lang;
/*
1.0- :没有语言选项,遇到这些版本时不设置
1.1 ~ 5 :zh_CN 时正常,zh_cn 时崩溃(最后两位字母必须大写,否则将会 NPE 崩溃)
1.6 ~ 10 :zh_CN 时正常,zh_cn 时自动切换为英文
1.11 ~ 12:zh_cn 时正常,zh_CN 时虽然显示了中文但语言设置会错误地显示选择英文
1.13+ :zh_cn 时正常,zh_CN 时自动切换为英文
* 1.0 : No language option, do not set for these versions
* 1.1 ~ 1.5 : zh_CN works fine, zh_cn will crash (the last two letters must be uppercase, otherwise it will cause an NPE crash)
* 1.6 ~ 1.10 : zh_CN works fine, zh_cn will automatically switch to English
* 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) {
lang = null;
} else if (gameVersion.compareTo("1.11") < 0) {
lang = "zh_CN";
} else {
lang = "zh_cn";
}
if (gameVersion.compareTo("1.1") < 0)
return;
if (lang != null) {
try {
FileUtils.writeText(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
}
String lang = normalizedLanguageTag(locale, gameVersion);
if (lang.isEmpty())
return;
if (gameVersion.compareTo("1.11") >= 0)
lang = lang.toLowerCase(Locale.ROOT);
try {
Files.createDirectories(optionsFile.getParent());
Files.writeString(optionsFile, String.format("lang:%s\n", lang));
} catch (IOException e) {
LOG.warning("Unable to generate options.txt", e);
}
}
private boolean findFiles(File folder, String fileName) {
File[] fs = folder.listFiles();
if (fs != null) {
for (File f : fs) {
if (f.isDirectory())
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
return true;
if (f.getName().equals(fileName))
return true;
}
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String language = locale.getLanguage();
String region = locale.getCountry();
switch (language) {
case "zh":
case "cmn":
if (Locales.isSimplifiedChinese(locale))
return "zh_CN";
if (gameVersion.compareTo("1.16") >= 0
&& (region.equals("HK") || region.equals("MO")))
return "zh_HK";
return "zh_TW";
case "ru":
return "ru_RU";
case "uk":
return "uk_UA";
case "es":
return "es_ES";
case "ja":
return "ja_JP";
case "lzh":
return gameVersion.compareTo("1.16") >= 0
? "lzh"
: "";
default:
return "";
}
return false;
}
@Override
@@ -158,6 +158,8 @@ public class HMCLGameRepository extends DefaultGameRepository {
blackList.add("saves");
if (Files.exists(dstDir)) throw new IOException("Version exists");
Files.createDirectories(dstDir);
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
Path fromJson = srcDir.resolve(srcId + ".json");
@@ -170,7 +172,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
}
Files.copy(fromJson, toJson);
FileUtils.writeText(toJson.toFile(), JsonUtils.GSON.toJson(fromVersion.setId(dstId)));
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
@@ -194,7 +196,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
File file = getLocalVersionSettingFile(id);
if (file.exists())
try {
VersionSetting versionSetting = GSON.fromJson(FileUtils.readText(file), VersionSetting.class);
VersionSetting versionSetting = GSON.fromJson(Files.readString(file.toPath()), VersionSetting.class);
initLocalVersionSetting(id, versionSetting);
} catch (Exception ex) {
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
@@ -427,7 +429,7 @@ public class HMCLGameRepository extends DefaultGameRepository {
File json = getModpackConfiguration(version);
if (json.exists()) {
try {
String jsonText = FileUtils.readText(json);
String jsonText = Files.readString(json.toPath());
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
@@ -28,7 +28,6 @@ import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
@@ -66,7 +65,7 @@ public final class HMCLModpackInstallTask extends Task<Void> {
ModpackConfiguration<Modpack> config = null;
try {
if (json.exists()) {
config = JsonUtils.GSON.fromJson(FileUtils.readText(json), ModpackConfiguration.typeOf(Modpack.class));
config = JsonUtils.fromJsonFile(json.toPath(), 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.");
@@ -53,12 +53,13 @@ import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
import static javafx.application.Platform.runLater;
import static javafx.application.Platform.setImplicitExit;
@@ -125,6 +126,9 @@ public final class LauncherHelper {
}
private void launch0() {
// https://github.com/HMCL-dev/HMCL/pull/4121
PROCESSES.removeIf(it -> it.get() == null);
HMCLGameRepository repository = profile.getRepository();
DefaultDependencyManager dependencyManager = profile.getDependency();
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
@@ -156,8 +160,10 @@ public final class LauncherHelper {
}),
Task.composeAsync(() -> {
Renderer renderer = setting.getRenderer();
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Library lib = NativePatcher.getMesaLoader(java);
if (renderer != Renderer.DEFAULT
&& OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& OperatingSystem.WINDOWS_VERSION != null) {
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.WINDOWS_VERSION);
if (lib == null)
return null;
File file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
@@ -209,7 +215,7 @@ public final class LauncherHelper {
}
}).thenAcceptAsync(process -> { // process is LaunchTask's result
if (scriptFile == null) {
PROCESSES.add(process);
PROCESSES.add(new WeakReference<>(process));
if (launcherVisibility == LauncherVisibility.CLOSE)
Launcher.stopApplication();
else
@@ -263,27 +269,27 @@ public final class LauncherHelper {
if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
int responseCode = rce.getResponseCode();
URL url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", url);
message += i18n("download.code.404", uri);
else
message += i18n("download.failed", url, responseCode);
message += i18n("download.failed", uri, responseCode);
} else {
message += StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof DownloadException) {
URL url = ((DownloadException) ex).getUrl();
URI uri = ((DownloadException) ex).getUri();
if (ex.getCause() instanceof SocketTimeoutException) {
message = i18n("install.failed.downloading.timeout", url);
message = i18n("install.failed.downloading.timeout", uri);
} else if (ex.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else {
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
}
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
message = i18n("assets.index.malformed");
@@ -294,11 +300,11 @@ public final class LauncherHelper {
} else if (ex instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) ex;
int responseCode = rce.getResponseCode();
URL url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message = i18n("download.code.404", url);
message = i18n("download.code.404", uri);
else
message = i18n("download.failed", url, responseCode);
message = i18n("download.failed", uri, responseCode);
} else if (ex instanceof CommandTooLongException) {
message = i18n("launch.failed.command_too_long");
} else if (ex instanceof ExecutionPolicyLimitException) {
@@ -896,10 +902,10 @@ public final class LauncherHelper {
}
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
public static void stopManagedProcesses() {
while (!PROCESSES.isEmpty())
Optional.ofNullable(PROCESSES.poll()).ifPresent(ManagedProcess::stop);
Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop);
}
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
@@ -41,9 +42,9 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
protected abstract SortType getBackedRemoteModRepositorySortOrder();
@Override
public SearchResult search(String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
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(gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
@@ -65,7 +66,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
@@ -50,10 +50,7 @@ import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.*;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -140,7 +137,7 @@ public final class ModpackHelper {
throw new FileNotFoundException(file.getPath());
else
try {
return JsonUtils.GSON.fromJson(FileUtils.readText(file), ModpackConfiguration.class);
return JsonUtils.fromJsonFile(file.toPath(), ModpackConfiguration.class);
} catch (JsonParseException e) {
throw new IOException("Malformed modpack configuration");
}
@@ -210,14 +207,17 @@ public final class ModpackHelper {
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else if (modpack.getManifest() instanceof McbbsModpackManifest)
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.defaultScheduler(), success, failure)
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name));
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
else
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
.whenComplete(Schedulers.javafx(), success, failure);
.whenComplete(Schedulers.javafx(), success, failure)
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
}
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
@@ -158,13 +158,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
@@ -45,7 +45,6 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -110,7 +109,7 @@ public final class TexturesLoader {
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run();
new FileDownloadTask(texture.getUrl(), file).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
@@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.platform.Platform;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
@@ -116,11 +115,7 @@ public final class HMCLJavaRepository implements JavaRepository {
}
if (Files.isDirectory(javaDir)) {
JavaManifest manifest;
try (InputStream input = Files.newInputStream(file)) {
manifest = JsonUtils.fromJsonFully(input, JavaManifest.class);
}
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
}
}
@@ -191,7 +186,7 @@ public final class HMCLJavaRepository implements JavaRepository {
});
JavaManifest manifest = new JavaManifest(info, update, files);
FileUtils.writeText(getManifestFile(platform, gameJavaVersion), JsonUtils.GSON.toJson(manifest));
JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest);
return JavaRuntime.of(executable, info, true);
});
}
@@ -203,7 +198,7 @@ public final class HMCLJavaRepository implements JavaRepository {
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
FileUtils.writeText(getManifestFile(platform, name), JsonUtils.GSON.toJson(result));
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
return JavaRuntime.of(executable, result.getInfo(), true);
});
}
@@ -56,6 +56,7 @@ public final class JavaManager {
}
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
public static String getMojangJavaPlatform(Platform platform) {
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
@@ -411,8 +412,15 @@ public final class JavaManager {
if (System.getenv("PATH") != null) {
String[] paths = System.getenv("PATH").split(File.pathSeparator);
for (String path : paths) {
// https://github.com/HMCL-dev/HMCL/issues/4079
// https://github.com/Meloong-Git/PCL/issues/4261
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT)
.contains("\\common files\\oracle\\java\\")) {
continue;
}
try {
tryAddJavaExecutable(javaRuntimes, Paths.get(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
} catch (InvalidPathException ignored) {
}
}
@@ -480,12 +488,15 @@ public final class JavaManager {
try {
info = JavaInfo.fromReleaseFile(releaseFile);
} catch (IOException e) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e2) {
e2.addSuppressed(e);
LOG.warning("Failed to lookup Java executable at " + executable, e2);
}
LOG.warning("Failed to read release file " + releaseFile, e);
}
}
if (info == null) {
try {
info = JavaInfoUtils.fromExecutable(executable, false);
} catch (IOException e) {
LOG.warning("Failed to lookup Java executable at " + executable, e);
}
}
@@ -561,6 +572,10 @@ public final class JavaManager {
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
javaRuntimes.put(java.getBinary(), java);
}
}
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
@@ -36,6 +36,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import javax.net.ssl.SSLException;
@@ -68,18 +69,6 @@ public final class Accounts {
private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider();
private static void triggerAuthlibInjectorUpdateCheck() {
if (AUTHLIB_INJECTOR_DOWNLOADER instanceof AuthlibInjectorDownloader) {
Schedulers.io().execute(() -> {
try {
((AuthlibInjectorDownloader) AUTHLIB_INJECTOR_DOWNLOADER).checkUpdate();
} catch (IOException e) {
LOG.warning("Failed to check update for authlib-injector", e);
}
});
}
}
public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
@@ -337,8 +326,6 @@ public final class Accounts {
});
}
triggerAuthlibInjectorUpdateCheck();
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
continue;
@@ -346,7 +333,7 @@ public final class Accounts {
try {
server.fetchMetadataResponse();
} catch (IOException e) {
LOG.warning("Failed to fetch authlib-injector server metdata: " + server, e);
LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e);
}
});
}
@@ -371,24 +358,18 @@ public final class Accounts {
// ==== authlib-injector ====
private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() {
String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location");
if (authlibinjectorLocation == null) {
return new AuthlibInjectorDownloader(
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve("authlib-injector.jar"),
DownloadProviders::getDownloadProvider) {
@Override
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
Optional<AuthlibInjectorArtifactInfo> local = super.getArtifactInfoImmediately();
if (local.isPresent()) {
return local;
}
// search authlib-injector.jar in current directory, it's used as a fallback
return parseArtifact(Metadata.CURRENT_DIRECTORY.resolve("authlib-injector.jar"));
}
};
} else {
if (authlibinjectorLocation != null) {
LOG.info("Using specified authlib-injector: " + authlibinjectorLocation);
return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation));
}
String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null);
if (authlibInjectorVersion == null)
throw new AssertionError("Missing hmcl.authlib-injector.version");
String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar";
return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName),
Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName));
}
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
@@ -24,7 +24,6 @@ import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import java.io.IOException;
@@ -73,8 +72,7 @@ public final class AuthlibInjectorServers implements Validation {
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
AuthlibInjectorServers configInstance;
try {
String content = FileUtils.readText(configLocation);
configInstance = JsonUtils.GSON.fromJson(content, AuthlibInjectorServers.class);
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
} catch (IOException | JsonParseException e) {
LOG.warning("Malformed authlib-injectors.json", e);
return;
@@ -29,6 +29,7 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.scene.paint.Paint;
import org.hildan.fxgson.creators.ObservableListCreator;
import org.hildan.fxgson.creators.ObservableMapCreator;
import org.hildan.fxgson.creators.ObservableSetCreator;
@@ -37,6 +38,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.util.gson.EnumOrdinalDeserializer;
import org.jackhuang.hmcl.util.gson.FileTypeAdapter;
import org.jackhuang.hmcl.util.gson.PaintAdapter;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
@@ -60,6 +62,7 @@ public final class Config implements Observable {
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
.registerTypeAdapter(Paint.class, new PaintAdapter())
.setPrettyPrinting()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
@@ -87,6 +90,12 @@ public final class Config implements Observable {
@SerializedName("bgurl")
private StringProperty backgroundImageUrl = new SimpleStringProperty();
@SerializedName("bgImageOpacity")
private IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
@SerializedName("bgpaint")
private ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
@SerializedName("commonDirType")
private ObjectProperty<EnumCommonDirectory> commonDirType = new SimpleObjectProperty<>(EnumCommonDirectory.DEFAULT);
@@ -172,11 +181,14 @@ public final class Config implements Observable {
private BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
@SerializedName("authlibInjectorServers")
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[] { server });
private ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
@SerializedName("addedLittleSkin")
private BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
@SerializedName("disableAutoGameOptions")
private BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false);
@SerializedName("promptedVersion")
private StringProperty promptedVersion = new SimpleStringProperty();
@@ -274,6 +286,30 @@ public final class Config implements Observable {
this.backgroundImageUrl.set(backgroundImageUrl);
}
public Paint getBackgroundPaint() {
return backgroundPaint.get();
}
public ObjectProperty<Paint> backgroundPaintProperty() {
return backgroundPaint;
}
public void setBackgroundPaint(Paint backgroundPaint) {
this.backgroundPaint.set(backgroundPaint);
}
public int getBackgroundImageOpacity() {
return backgroundImageOpacity.get();
}
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
this.backgroundImageOpacity.set(backgroundImageOpacity);
}
public IntegerProperty backgroundImageOpacityProperty() {
return backgroundImageOpacity;
}
public EnumCommonDirectory getCommonDirType() {
return commonDirType.get();
}
@@ -598,6 +634,18 @@ public final class Config implements Observable {
this.addedLittleSkin.set(addedLittleSkin);
}
public BooleanProperty disableAutoGameOptionsProperty() {
return disableAutoGameOptions;
}
public boolean isDisableAutoGameOptions() {
return disableAutoGameOptions.get();
}
public void setDisableAutoGameOptions(boolean disableAutoGameOptions) {
this.disableAutoGameOptions.set(disableAutoGameOptions);
}
public int getConfigVersion() {
return configVersion.get();
}
@@ -101,6 +101,7 @@ public final class ConfigHolder {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
&& configLocation.getFileSystem() == FileSystems.getDefault()
&& configLocation.toFile().canWrite()) {
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
// There are some serious problems with the implementation of Samba or OpenJDK
throw new SambaException();
} else {
@@ -157,7 +158,7 @@ public final class ConfigHolder {
LOG.warning("Failed to get owner");
}
try {
String content = FileUtils.readText(configLocation);
String content = Files.readString(configLocation);
Config deserialized = Config.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
@@ -179,7 +180,7 @@ public final class ConfigHolder {
private static GlobalConfig loadGlobalConfig() throws IOException {
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
String content = Files.readString(GLOBAL_CONFIG_PATH);
GlobalConfig deserialized = GlobalConfig.fromJson(content);
if (deserialized == null) {
LOG.info("Config is empty");
@@ -29,7 +29,7 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import javax.net.ssl.SSLHandshakeException;
import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.Arrays;
import java.util.Map;
@@ -137,26 +137,26 @@ public final class DownloadProviders {
public static String localizeErrorMessage(Throwable exception) {
if (exception instanceof DownloadException) {
URL url = ((DownloadException) exception).getUrl();
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
return i18n("install.failed.downloading.timeout", url);
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(), url);
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception.getCause() instanceof FileNotFoundException) {
return i18n("download.code.404", url);
return i18n("download.code.404", uri);
} else if (exception.getCause() instanceof AccessDeniedException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
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", url) + "\n" + i18n("exception.artifact_malformed");
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
} else if (exception.getCause() instanceof SSLHandshakeException) {
return i18n("install.failed.downloading.detail", url) + "\n" + i18n("exception.ssl_handshake");
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
} else {
return i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause());
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
}
} else if (exception instanceof ArtifactMalformedException) {
return i18n("exception.artifact_malformed");
@@ -22,5 +22,6 @@ public enum EnumBackgroundImage {
CUSTOM,
CLASSIC,
NETWORK,
TRANSLUCENT
TRANSLUCENT, // Deprecated
PAINT
}
@@ -21,7 +21,6 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.text.Font;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -29,9 +28,6 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -133,8 +129,12 @@ public final class FontManager {
return null;
try {
Locale locale = Locale.getDefault();
if (locale.getLanguage().equals("lzh"))
locale = Locale.TRADITIONAL_CHINESE;
String result = SystemUtils.run(fcMatch.toString(),
":lang=" + Locale.getDefault().toLanguageTag(),
":lang=" + locale.toLanguageTag(),
"--format", "%{family}\\n%{file}").trim();
String[] results = result.split("\\n");
@@ -152,46 +152,33 @@ public final class FontManager {
return null;
}
if (JavaRuntime.CURRENT_VERSION >= 9) {
try {
MethodHandle methodHandle = MethodHandles.publicLookup().findStatic(Font.class, "loadFonts",
MethodType.methodType(Font[].class, String.class, double.class));
Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
return null;
} else if (fonts.length == 0) {
LOG.warning("No fonts loaded from " + path);
return null;
}
Font[] fonts = (Font[]) methodHandle.invokeExact(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (fonts == null) {
LOG.warning("Failed to load font from " + path);
return null;
} else if (fonts.length == 0) {
LOG.warning("No fonts loaded from " + path);
return null;
}
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(family)) {
return font;
}
}
if (family.indexOf(',') >= 0) {
for (String candidateFamily : family.split(",")) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
return font;
}
}
}
}
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
return fonts[0];
} catch (NoSuchMethodException | IllegalAccessException ignored) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(family)) {
return font;
}
}
Font font = Font.loadFont(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE);
if (font == null)
LOG.warning("Failed to load font from " + path);
return font;
if (family.indexOf(',') >= 0) {
for (String candidateFamily : family.split(",")) {
for (Font font : fonts) {
if (font.getFamily().equalsIgnoreCase(candidateFamily)) {
return font;
}
}
}
}
LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path));
return fonts[0];
} catch (Throwable e) {
LOG.warning("Failed to get default font with fc-match", e);
return null;
@@ -21,10 +21,7 @@ import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
@@ -57,6 +54,8 @@ public final class GlobalConfig implements Observable {
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
@@ -131,6 +130,18 @@ public final class GlobalConfig implements Observable {
enableOfflineAccount.set(value);
}
public StringProperty fontAntiAliasingProperty() {
return fontAntiAliasing;
}
public String getFontAntiAliasing() {
return fontAntiAliasing.get();
}
public void setFontAntiAliasing(String value) {
this.fontAntiAliasing.set(value);
}
public ObservableSet<String> getUserJava() {
return userJava;
}
@@ -146,7 +157,8 @@ public final class GlobalConfig implements Observable {
"logRetention",
"userJava",
"disabledJava",
"enableOfflineAccount"
"enableOfflineAccount",
"fontAntiAliasing"
));
@Override
@@ -159,6 +171,7 @@ public final class GlobalConfig implements Observable {
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
if (src.enableOfflineAccount.get())
jsonObject.addProperty("enableOfflineAccount", true);
@@ -186,6 +199,7 @@ public final class GlobalConfig implements Observable {
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
JsonElement userJava = obj.get("userJava");
if (userJava != null && userJava.isJsonArray()) {
@@ -18,12 +18,13 @@
package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.*;
import java.util.Collections;
import java.util.List;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
@@ -31,8 +32,18 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ProxyManager {
private static final ProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT = Lang.requireNonNullElse(ProxySelector.getDefault(), NO_PROXY);
private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY);
private static final ProxySelector SYSTEM_DEFAULT;
static {
ProxySelector systemProxySelector = ProxySelector.getDefault();
SYSTEM_DEFAULT = systemProxySelector != null
? new ProxySelectorWrapper(systemProxySelector)
: NO_PROXY;
}
private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT;
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
private static ProxySelector getProxySelector() {
if (config().hasProxy()) {
@@ -46,14 +57,14 @@ public final class ProxyManager {
LOG.warning("Illegal proxy port: " + port);
return NO_PROXY;
} else {
return new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port)));
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
}
} else {
return ProxyManager.SYSTEM_DEFAULT;
}
}
private static Authenticator getAuthenticator() {
private static SimpleAuthenticator getAuthenticator() {
if (config().hasProxy() && config().hasProxyAuth()) {
String username = config().getProxyUser();
String password = config().getProxyPass();
@@ -67,30 +78,75 @@ public final class ProxyManager {
}
static void init() {
ProxySelector.setDefault(getProxySelector());
InvalidationListener updateProxySelector = observable -> ProxySelector.setDefault(getProxySelector());
ProxySelector.setDefault(new ProxySelector() {
@Override
public List<Proxy> select(URI uri) {
return defaultProxySelector.select(uri);
}
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
defaultProxySelector.connectFailed(uri, sa, ioe);
}
});
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
var defaultAuthenticator = ProxyManager.defaultAuthenticator;
return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null;
}
});
defaultProxySelector = getProxySelector();
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
config().proxyTypeProperty().addListener(updateProxySelector);
config().proxyHostProperty().addListener(updateProxySelector);
config().proxyPortProperty().addListener(updateProxySelector);
config().hasProxyProperty().addListener(updateProxySelector);
Authenticator.setDefault(getAuthenticator());
InvalidationListener updateAuthenticator = observable -> Authenticator.setDefault(getAuthenticator());
defaultAuthenticator = getAuthenticator();
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
config().hasProxyProperty().addListener(updateAuthenticator);
config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator);
}
private static final class SimpleProxySelector extends ProxySelector {
private static abstract class AbstractProxySelector extends ProxySelector {
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (uri == null || sa == null || ioe == null) {
throw new IllegalArgumentException("Arguments can't be null.");
}
}
}
private static final class SimpleProxySelector extends AbstractProxySelector {
private final List<Proxy> proxies;
SimpleProxySelector(Proxy proxy) {
this(Collections.singletonList(proxy));
this.proxies = List.of(proxy);
}
SimpleProxySelector(List<Proxy> proxies) {
this.proxies = proxies;
@Override
public List<Proxy> select(URI uri) {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
@Override
public String toString() {
return "SimpleProxySelector" + proxies;
}
}
/// Wraps another ProxySelector to avoid using proxy for loopback addresses.
private static final class ProxySelectorWrapper extends AbstractProxySelector {
private final ProxySelector source;
ProxySelectorWrapper(ProxySelector source) {
this.source = source;
}
@Override
@@ -98,19 +154,10 @@ public final class ProxyManager {
if (uri == null)
throw new IllegalArgumentException("URI can't be null.");
return proxies;
}
if (NetworkUtils.isLoopbackAddress(uri))
return NO_PROXY.proxies;
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (uri == null || sa == null || ioe == null) {
throw new IllegalArgumentException("Arguments can't be null.");
}
}
@Override
public String toString() {
return "SimpleProxySelector" + proxies;
return source.select(uri);
}
}
@@ -124,7 +171,7 @@ public final class ProxyManager {
}
@Override
protected PasswordAuthentication getPasswordAuthentication() {
public PasswordAuthentication getPasswordAuthentication() {
return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null;
}
}
@@ -23,7 +23,6 @@ import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.charset.Charset;
@@ -68,7 +67,7 @@ public final class StyleSheets {
Path temp = Files.createTempFile("hmcl", ".css");
// For JavaFX 17 or earlier, CssParser uses the default charset
// https://bugs.openjdk.org/browse/JDK-8279328
FileUtils.writeText(temp, styleSheet, Charset.defaultCharset());
Files.writeString(temp, styleSheet, Charset.defaultCharset());
temp.toFile().deleteOnExit();
return temp.toUri().toString();
} catch (IOException | NullPointerException e) {
@@ -33,7 +33,8 @@ public enum VersionIconType {
FORGE("/assets/img/forge.png"),
NEO_FORGE("/assets/img/neoforge.png"),
FURNACE("/assets/img/furnace.png"),
QUILT("/assets/img/quilt.png");
QUILT("/assets/img/quilt.png"),
APRIL_FOOLS("/assets/img/april_fools.png");
// Please append new items at last
@@ -797,14 +797,6 @@ public final class VersionSetting implements Cloneable, Observable {
return obj;
}
private static <T> T getOrDefault(T[] values, JsonElement index, T defaultValue) {
if (index == null)
return defaultValue;
int idx = index.getAsInt();
return idx >= 0 && idx < values.length ? values[idx] : defaultValue;
}
@Override
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject))
@@ -839,14 +831,14 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setLauncherVisibility(getOrDefault(LauncherVisibility.values(), obj.get("launcherVisibility"), LauncherVisibility.HIDE));
vs.setProcessPriority(getOrDefault(ProcessPriority.values(), obj.get("processPriority"), ProcessPriority.NORMAL));
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
vs.setGameDirType(getOrDefault(GameDirectoryType.values(), obj.get("gameDirType"), GameDirectoryType.ROOT_FOLDER));
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
vs.setNativesDirType(getOrDefault(NativesDirectoryType.values(), obj.get("nativesDirType"), NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(getOrDefault(VersionIconType.values(), obj.get("versionIcon"), VersionIconType.DEFAULT));
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
if (obj.get("javaVersionType") != null) {
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
@@ -176,6 +176,18 @@ public final class Controllers {
public static void initialize(Stage stage) {
LOG.info("Start initializing application");
if (System.getProperty("prism.lcdtext") == null) {
String fontAntiAliasing = globalConfig().getFontAntiAliasing();
if ("lcd".equalsIgnoreCase(fontAntiAliasing)) {
LOG.info("Enable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "true");
} else if ("gray".equalsIgnoreCase(fontAntiAliasing)
|| OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) {
LOG.info("Disable sub-pixel antialiasing");
System.getProperties().put("prism.lcdtext", "false");
}
}
Controllers.stage = stage;
stageSizeChangeListener = o -> {
@@ -205,7 +217,11 @@ public final class Controllers {
if (targetProperty != null
&& Controllers.stage != null
&& !Controllers.stage.isIconified()) {
&& !Controllers.stage.isIconified()
// https://github.com/HMCL-dev/HMCL/issues/4290
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS ||
!Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized())
) {
targetProperty.set(sourceProperty.get());
}
};
@@ -310,20 +326,21 @@ public final class Controllers {
}
}
if (JavaRuntime.CURRENT_VERSION < 10) {
if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
Number shownTipVersion = null;
try {
shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP);
} catch (ClassCastException e) {
LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e);
}
if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) {
MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING);
String downloadLink = Metadata.getSuggestedJavaDownloadLink();
if (downloadLink != null)
builder.addHyperLink(i18n("fatal.deprecated_java_version.download_link", 21), downloadLink);
builder.addHyperLink(
i18n("fatal.deprecated_java_version.download_link", Metadata.RECOMMENDED_JAVA_VERSION),
downloadLink
);
Controllers.dialog(builder
.ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION))
.build());
@@ -18,16 +18,16 @@
package org.jackhuang.hmcl.ui;
import com.jfoenix.controls.*;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.WeakListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.*;
import javafx.collections.ObservableMap;
import javafx.event.Event;
import javafx.event.EventDispatcher;
import javafx.event.EventType;
@@ -41,11 +41,9 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
@@ -54,11 +52,12 @@ import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.png.PNGType;
import org.glavo.png.PNGWriter;
import org.glavo.png.javafx.PNGJavaFXUtils;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
import org.jackhuang.hmcl.ui.image.ImageLoader;
import org.jackhuang.hmcl.ui.image.ImageUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -66,19 +65,19 @@ import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import org.jetbrains.annotations.Nullable;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.*;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.ref.WeakReference;
import java.net.*;
import java.nio.file.Files;
@@ -105,6 +104,11 @@ public final class FXUtils {
public static final int JAVAFX_MAJOR_VERSION;
/// @see Platform.Preferences
public static final @Nullable ObservableMap<String, Object> PREFERENCES;
public static final @Nullable ObservableBooleanValue DARK_MODE;
public static final @Nullable Boolean REDUCED_MOTION;
static {
String jfxVersion = System.getProperty("javafx.version");
int majorVersion = -1;
@@ -115,27 +119,51 @@ public final class FXUtils {
}
}
JAVAFX_MAJOR_VERSION = majorVersion;
ObservableMap<String, Object> preferences = null;
ObservableBooleanValue darkMode = null;
Boolean reducedMotion = null;
if (JAVAFX_MAJOR_VERSION >= 22) {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
Class<?> preferencesClass = Class.forName("javafx.application.Platform$Preferences");
@SuppressWarnings("unchecked")
var preferences0 = (ObservableMap<String, Object>) lookup.findStatic(Platform.class, "getPreferences", MethodType.methodType(preferencesClass))
.invoke();
preferences = preferences0;
@SuppressWarnings("unchecked")
var colorSchemeProperty =
(ReadOnlyObjectProperty<? extends Enum<?>>)
lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class))
.invoke(preferences);
darkMode = Bindings.createBooleanBinding(() ->
"DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty);
if (JAVAFX_MAJOR_VERSION >= 24) {
reducedMotion = (boolean)
lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class))
.invoke(preferences);
}
} catch (Throwable e) {
LOG.warning("Failed to get preferences", e);
}
}
PREFERENCES = preferences;
DARK_MODE = darkMode;
REDUCED_MOTION = reducedMotion;
}
public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace";
public static final List<String> IMAGE_EXTENSIONS = Lang.immutableListOf(
"png", "jpg", "jpeg", "bmp", "gif", "webp"
"png", "jpg", "jpeg", "bmp", "gif", "webp", "apng"
);
private static final Map<String, Image> builtinImageCache = new ConcurrentHashMap<>();
private static final Map<String, Path> remoteImageCache = new ConcurrentHashMap<>();
public static void shutdown() {
for (Map.Entry<String, Path> entry : remoteImageCache.entrySet()) {
try {
Files.deleteIfExists(entry.getValue());
} catch (IOException e) {
LOG.warning(String.format("Failed to delete cache file %s.", entry.getValue()), e);
}
remoteImageCache.remove(entry.getKey());
}
builtinImageCache.clear();
}
@@ -328,8 +356,15 @@ public final class FXUtils {
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
tooltip.setShowDelay(showDelay);
tooltip.setShowDuration(showDuration);
tooltip.setHideDelay(hideDelay);
Tooltip.install(node, tooltip);
}
public static void installFastTooltip(Node node, Tooltip tooltip) {
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
public static void installFastTooltip(Node node, String tooltip) {
@@ -337,7 +372,7 @@ public final class FXUtils {
}
public static void installSlowTooltip(Node node, Tooltip tooltip) {
runInFX(() -> TooltipInstaller.INSTALLER.installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
}
public static void installSlowTooltip(Node node, String tooltip) {
@@ -700,6 +735,240 @@ public final class FXUtils {
property.removeListener(binding);
}
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<ColorPicker> colorPickerRef;
private final WeakReference<Property<Paint>> propertyRef;
private final int hashCode;
private boolean updating = false;
private PaintBidirectionalBinding(ColorPicker colorPicker, Property<Paint> property) {
this.colorPickerRef = new WeakReference<>(colorPicker);
this.propertyRef = new WeakReference<>(property);
this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property);
}
@Override
public void invalidated(Observable sourceProperty) {
if (!updating) {
final ColorPicker colorPicker = colorPickerRef.get();
final Property<Paint> property = propertyRef.get();
if (colorPicker == null || property == null) {
if (colorPicker != null) {
colorPicker.valueProperty().removeListener(this);
}
if (property != null) {
property.removeListener(this);
}
} else {
updating = true;
try {
if (property == sourceProperty) {
Paint newValue = property.getValue();
if (newValue instanceof Color)
colorPicker.setValue((Color) newValue);
else
colorPicker.setValue(null);
} else {
Paint newValue = colorPicker.getValue();
property.setValue(newValue);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return colorPickerRef.get() == null || propertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof FXUtils.PaintBidirectionalBinding))
return false;
var that = (FXUtils.PaintBidirectionalBinding) o;
final ColorPicker colorPicker = this.colorPickerRef.get();
final Property<Paint> property = this.propertyRef.get();
final ColorPicker thatColorPicker = that.colorPickerRef.get();
final Property<?> thatProperty = that.propertyRef.get();
if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null)
return false;
return colorPicker == thatColorPicker && property == thatProperty;
}
}
public static void bindPaint(ColorPicker colorPicker, Property<Paint> property) {
PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property);
colorPicker.valueProperty().removeListener(binding);
property.removeListener(binding);
if (property.getValue() instanceof Color)
colorPicker.setValue((Color) property.getValue());
else
colorPicker.setValue(null);
colorPicker.valueProperty().addListener(binding);
property.addListener(binding);
}
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
private final WeakReference<JFXComboBox<String>> comboBoxRef;
private final WeakReference<IntegerProperty> widthPropertyRef;
private final WeakReference<IntegerProperty> heightPropertyRef;
private final int hashCode;
private boolean updating = false;
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
IntegerProperty widthProperty,
IntegerProperty heightProperty) {
this.comboBoxRef = new WeakReference<>(comboBox);
this.widthPropertyRef = new WeakReference<>(widthProperty);
this.heightPropertyRef = new WeakReference<>(heightProperty);
this.hashCode = System.identityHashCode(comboBox)
^ System.identityHashCode(widthProperty)
^ System.identityHashCode(heightProperty);
}
@Override
public void invalidated(Observable observable) {
if (!updating) {
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null) {
if (comboBox != null) {
comboBox.focusedProperty().removeListener(this);
comboBox.sceneProperty().removeListener(this);
}
if (widthProperty != null)
widthProperty.removeListener(this);
if (heightProperty != null)
heightProperty.removeListener(this);
} else {
updating = true;
try {
int width = widthProperty.get();
int height = heightProperty.get();
if (observable instanceof ReadOnlyProperty<?>
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
String value = comboBox.valueProperty().get();
if (value == null)
value = "";
int idx = value.indexOf('x');
if (idx < 0)
idx = value.indexOf('*');
if (idx < 0) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
String widthStr = value.substring(0, idx).trim();
String heightStr = value.substring(idx + 1).trim();
int newWidth;
int newHeight;
try {
newWidth = Integer.parseInt(widthStr);
newHeight = Integer.parseInt(heightStr);
} catch (NumberFormatException e) {
LOG.warning("Bad window size: " + value);
comboBox.setValue(width + "x" + height);
return;
}
widthProperty.set(newWidth);
heightProperty.set(newHeight);
} else {
comboBox.setValue(width + "x" + height);
}
} finally {
updating = false;
}
}
}
}
@Override
public boolean wasGarbageCollected() {
return this.comboBoxRef.get() == null
|| this.widthPropertyRef.get() == null
|| this.heightPropertyRef.get() == null;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof WindowsSizeBidirectionalBinding))
return false;
var that = (WindowsSizeBidirectionalBinding) obj;
var comboBox = this.comboBoxRef.get();
var widthProperty = this.widthPropertyRef.get();
var heightProperty = this.heightPropertyRef.get();
var thatComboBox = that.comboBoxRef.get();
var thatWidthProperty = that.widthPropertyRef.get();
var thatHeightProperty = that.heightPropertyRef.get();
if (comboBox == null || widthProperty == null || heightProperty == null
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
return false;
}
return comboBox == thatComboBox
&& widthProperty == thatWidthProperty
&& heightProperty == thatHeightProperty;
}
}
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().addListener(binding);
comboBox.sceneProperty().addListener(binding);
widthProperty.addListener(binding);
heightProperty.addListener(binding);
}
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
comboBox.focusedProperty().removeListener(binding);
comboBox.sceneProperty().removeListener(binding);
widthProperty.removeListener(binding);
heightProperty.removeListener(binding);
}
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
int itemCount = children.length;
int childSelectedCount = 0;
@@ -767,47 +1036,53 @@ public final class FXUtils {
stage.getIcons().add(newBuiltinImage(icon));
}
private static Image loadWebPImage(InputStream input) throws IOException {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
return SwingFXUtils.toFXImage(reader.read(0, reader.getDefaultReadParam()), null);
} finally {
reader.dispose();
}
}
public static Image loadImage(Path path) throws Exception {
try (InputStream input = Files.newInputStream(path)) {
if ("webp".equalsIgnoreCase(FileUtils.getExtension(path)))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
return loadImage(path, 0, 0, false, false);
}
public static Image loadImage(Path path,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception {
try (var input = new BufferedInputStream(Files.newInputStream(path))) {
String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT);
ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext);
if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
}
}
public static Image loadImage(URL url) throws Exception {
URLConnection connection = NetworkUtils.createConnection(url);
if (connection instanceof HttpURLConnection) {
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
}
public static Image loadImage(String url) throws Exception {
URI uri = NetworkUtils.toURI(url);
try (InputStream input = connection.getInputStream()) {
String path = url.getPath();
if (path != null && "webp".equalsIgnoreCase(StringUtils.substringAfterLast(path, '.')))
return loadWebPImage(input);
else {
Image image = new Image(input);
if (image.isError())
throw image.getException();
return image;
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection)
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
String contentType = Objects.requireNonNull(connection.getContentType(), "");
Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType);
if (matcher.find())
contentType = matcher.group("type");
ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType);
if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) {
input.mark(ImageUtils.HEADER_BUFFER_SIZE);
byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
input.reset();
loader = ImageUtils.guessLoader(headerBuffer);
}
if (loader == null)
loader = ImageUtils.DEFAULT;
return loader.load(input, 0, 0, false, false);
}
}
@@ -851,78 +1126,22 @@ public final class FXUtils {
}
}
/**
* Load image from the internet. It will cache the data of images for the further usage.
* The cached data will be deleted when HMCL is closed or hidden.
*
* @param url the url of image. The image resource should be a file on the internet.
* @return the image resource within the jar.
*/
public static Image newRemoteImage(String url) {
return newRemoteImage(url, 0, 0, false, false, false);
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth));
}
/**
* Load image from the internet. It will cache the data of images for the further usage.
* The cached data will be deleted when HMCL is closed or hidden.
*
* @param url the url of image. The image resource should be a file on the internet.
* @param requestedWidth the image's bounding box width
* @param requestedHeight the image's bounding box height
* @param preserveRatio indicates whether to preserve the aspect ratio of
* the original image when scaling to fit the image within the
* specified bounding box
* @param smooth indicates whether to use a better quality filtering
* algorithm or a faster one when scaling this image to fit within
* the specified bounding box
* @return the image resource within the jar.
*/
public static Image newRemoteImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth, boolean backgroundLoading) {
Path currentPath = remoteImageCache.get(url);
if (currentPath != null) {
if (Files.isReadable(currentPath)) {
try (InputStream inputStream = Files.newInputStream(currentPath)) {
return new Image(inputStream, requestedWidth, requestedHeight, preserveRatio, smooth);
} catch (IOException e) {
LOG.warning("An exception encountered while reading data from cached image file.", e);
}
}
// The file is unavailable or unreadable.
remoteImageCache.remove(url);
try {
Files.deleteIfExists(currentPath);
} catch (IOException e) {
LOG.warning("An exception encountered while deleting broken cached image file.", e);
}
}
Image image = new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth, backgroundLoading);
image.progressProperty().addListener((observable, oldValue, newValue) -> {
if (newValue.doubleValue() >= 1.0 && !image.isError() && image.getPixelReader() != null && image.getWidth() > 0.0 && image.getHeight() > 0.0) {
Task.runAsync(() -> {
Path newPath = Files.createTempFile("hmcl-net-resource-cache-", ".cache");
try ( // Make sure the file is released from JVM before we put the path into remoteImageCache.
OutputStream outputStream = Files.newOutputStream(newPath);
PNGWriter writer = new PNGWriter(outputStream, PNGType.RGBA, PNGWriter.DEFAULT_COMPRESS_LEVEL)
) {
writer.write(PNGJavaFXUtils.asArgbImage(image));
} catch (IOException e) {
try {
Files.delete(newPath);
} catch (IOException e2) {
e2.addSuppressed(e);
throw e2;
}
throw e;
public static ObservableValue<Image> newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
var image = new SimpleObjectProperty<Image>();
getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth)
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
image.set(result);
} else {
LOG.warning("An exception encountered while loading remote image: " + url, exception);
}
if (remoteImageCache.putIfAbsent(url, newPath) != null) {
Files.delete(newPath); // The image has been loaded in another task. Delete the image here in order not to pollute the tmp folder.
}
}).start();
}
});
})
.start();
return image;
}
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.util.StringUtils;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
@@ -159,28 +160,19 @@ public final class HTMLRenderer {
private void appendImage(Node node) {
String src = node.absUrl("src");
URI imageUri = null;
try {
if (!src.isEmpty())
imageUri = URI.create(src);
} catch (Exception ignored) {
}
String alt = node.attr("alt");
if (imageUri != null) {
URI uri = URI.create(src);
if (StringUtils.isNotBlank(src)) {
String widthAttr = node.attr("width");
String heightAttr = node.attr("height");
double width = 0;
double height = 0;
int width = 0;
int height = 0;
if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) {
try {
width = Double.parseDouble(widthAttr);
height = Double.parseDouble(heightAttr);
width = (int) Double.parseDouble(widthAttr);
height = (int) Double.parseDouble(heightAttr);
} catch (NumberFormatException ignored) {
}
@@ -190,10 +182,12 @@ public final class HTMLRenderer {
}
}
Image image = FXUtils.newRemoteImage(uri.toString(), width, height, true, true, false);
if (image.isError()) {
LOG.warning("Failed to load image: " + uri, image.getException());
} else {
try {
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
.run();
if (image == null)
throw new AssertionError("Image loading task returned null");
ImageView imageView = new ImageView(image);
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
@@ -204,6 +198,8 @@ public final class HTMLRenderer {
}
children.add(imageView);
return;
} catch (Throwable e) {
LOG.warning("Failed to load image: " + src, e);
}
}
@@ -38,7 +38,6 @@ import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
@@ -186,7 +185,7 @@ public final class LogWindow extends Stage {
}
private void onTerminateGame() {
LauncherHelper.stopManagedProcesses();
LogWindow.this.gameProcess.stop();
}
private void onClear() {
@@ -224,7 +223,7 @@ public final class LogWindow extends Stage {
try {
if (gameProcess.isRunning()) {
GameDumpGenerator.writeDumpTo(gameProcess.getPID(), dumpFile);
GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile);
FXUtils.showFileInExplorer(dumpFile);
}
} catch (Throwable e) {
@@ -1,123 +0,0 @@
/*
* 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/>.
*/
package org.jackhuang.hmcl.ui;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public class TooltipInstaller {
public static final TooltipInstaller INSTALLER;
static {
TooltipInstaller installer = null;
try {
installer = new NewInstaller();
} catch (Throwable e) {
try {
installer = new OldInstaller();
} catch (Throwable e2) {
e2.addSuppressed(e);
LOG.warning("Failed to initialize TooltipInstaller", e2);
}
}
INSTALLER = installer != null ? installer : new TooltipInstaller();
}
TooltipInstaller() {
}
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
Tooltip.install(node, tooltip);
}
// For Java 8
private static final class OldInstaller extends TooltipInstaller {
private static final Constructor<?> createTooltipBehavior;
private static final Method installTooltipBehavior;
static {
try {
Class<?> behaviorClass = Class.forName("javafx.scene.control.Tooltip$TooltipBehavior");
createTooltipBehavior = behaviorClass.getDeclaredConstructor(Duration.class, Duration.class, Duration.class, boolean.class);
createTooltipBehavior.setAccessible(true);
installTooltipBehavior = behaviorClass.getDeclaredMethod("install", Node.class, Tooltip.class);
installTooltipBehavior.setAccessible(true);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
Object behavior = createTooltipBehavior.newInstance(showDelay, showDuration, hideDelay, false);
installTooltipBehavior.invoke(behavior, node, tooltip);
} catch (ReflectiveOperationException e) {
LOG.warning("Failed to set tooltip show delay", e);
Tooltip.install(node, tooltip);
}
}
}
// For Java 9+
private static final class NewInstaller extends TooltipInstaller {
private static final MethodHandle setTooltipShowDelay;
private static final MethodHandle setTooltipShowDuration;
private static final MethodHandle setTooltipHideDelay;
static {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
MethodType methodType = MethodType.methodType(void.class, Duration.class);
setTooltipShowDelay = lookup.findVirtual(Tooltip.class, "setShowDelay", methodType);
setTooltipShowDuration = lookup.findVirtual(Tooltip.class, "setShowDuration", methodType);
setTooltipHideDelay = lookup.findVirtual(Tooltip.class, "setHideDelay", methodType);
} catch (Exception e) {
throw new ExceptionInInitializerError(e);
}
}
@Override
public void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
try {
setTooltipShowDelay.invokeExact(tooltip, showDelay);
setTooltipShowDuration.invokeExact(tooltip, showDuration);
setTooltipHideDelay.invokeExact(tooltip, hideDelay);
} catch (Throwable e) {
LOG.warning("Failed to set tooltip show delay", e);
}
Tooltip.install(node, tooltip);
}
}
}
@@ -33,6 +33,7 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme;
@@ -128,6 +129,10 @@ public final class AccountListItemSkin extends SkinBase<AccountListItem> {
JFXButton btnRefresh = new JFXButton();
SpinnerPane spinnerRefresh = new SpinnerPane();
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");
if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
btnRefresh.setDisable(true);
FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot"));
}
btnRefresh.setOnAction(e -> {
spinnerRefresh.showSpinner();
skinnable.refreshAsync()
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.ClassTitle;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jackhuang.hmcl.util.platform.NativeUtils;
@@ -48,7 +49,6 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import java.net.URI;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Locale;
@@ -190,7 +190,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
item.titleProperty().bind(title);
String host = "";
try {
host = URI.create(server.getUrl()).getHost();
host = NetworkUtils.toURI(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
}
@@ -31,7 +31,6 @@ import org.jackhuang.hmcl.ui.construct.DialogAware;
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import javax.net.ssl.SSLException;
import java.io.IOException;
@@ -196,7 +195,8 @@ public final class AddAuthlibInjectorServerPane extends TransitionPane implement
lblServerName.setText(serverBeingAdded.getName());
lblServerUrl.setText(serverBeingAdded.getUrl());
lblServerWarning.setVisible("http".equals(NetworkUtils.toURL(serverBeingAdded.getUrl()).getProtocol()));
//noinspection HttpUrlsUsage
lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://"));
this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT);
} else {
@@ -267,7 +267,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
}).executor(true);
};
if (factory instanceof OfflineAccountFactory && username != null && !USERNAME_CHECKER_PATTERN.matcher(username).matches()) {
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
JFXButton btnYes = new JFXButton(i18n("button.ok"));
btnYes.getStyleClass().add("dialog-error");
btnYes.setOnAction(e -> doCreate.run());
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.animation;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
/**
@@ -36,7 +37,7 @@ public final class AnimationUtils {
public static void init() {
}
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled();
private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled() && FXUtils.REDUCED_MOTION != Boolean.TRUE;
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
public static boolean isAnimationEnabled() {
@@ -30,8 +30,7 @@ public class TransitionPane extends StackPane implements AnimationHandler {
private Duration duration;
private Node previousNode, currentNode;
{
currentNode = getChildren().stream().findFirst().orElse(null);
public TransitionPane() {
FXUtils.setOverflowHidden(this);
}
@@ -91,8 +90,12 @@ public class TransitionPane extends StackPane implements AnimationHandler {
private void updateContent(Node newView) {
if (getWidth() > 0 && getHeight() > 0) {
previousNode = currentNode;
if (previousNode == null)
previousNode = EMPTY_PANE;
if (previousNode == null) {
if (getChildren().isEmpty())
previousNode = EMPTY_PANE;
else
previousNode = getChildren().get(0);
}
} else
previousNode = EMPTY_PANE;
@@ -108,6 +108,7 @@ public final class MessageDialogPane extends HBox {
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
FXUtils.smoothScrolling(scrollPane);
scrollPane.setPrefHeight(350);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
@@ -24,11 +24,13 @@ import javafx.beans.property.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Paint;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.StringUtils;
@@ -297,4 +299,35 @@ public final class MultiFileItem<T> extends VBox {
return pane;
}
}
public static final class PaintOption<T> extends Option<T> {
private final ColorPicker colorPicker = new ColorPicker();
public PaintOption(String title, T data) {
super(title, data);
}
public PaintOption<T> bindBidirectional(Property<Paint> property) {
FXUtils.bindPaint(colorPicker, property);
return this;
}
@Override
protected Node createItem(ToggleGroup group) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(3));
FXUtils.setLimitHeight(pane, 30);
left.setText(title);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
left.setToggleGroup(group);
left.setUserData(data);
pane.setLeft(left);
colorPicker.disableProperty().bind(left.selectedProperty().not());
BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT);
pane.setRight(colorPicker);
return pane;
}
}
}
@@ -22,13 +22,10 @@ import javafx.application.Platform;
import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jetbrains.annotations.NotNull;
@@ -43,7 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
private final Consumer<FileDownloadTask.SpeedEvent> speedEventHandler;
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
private final Label lblProgress;
@@ -61,16 +58,10 @@ public class TaskExecutorDialogPane extends BorderPane {
lblTitle = new Label();
lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;");
ScrollPane scrollPane = new ScrollPane();
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
{
taskListPane = new TaskListPane();
scrollPane.setContent(taskListPane);
}
taskListPane = new TaskListPane();
VBox.setVgrow(taskListPane, Priority.ALWAYS);
center.getChildren().setAll(lblTitle, scrollPane);
center.getChildren().setAll(lblTitle, taskListPane);
}
BorderPane bottom = new BorderPane();
@@ -108,7 +99,7 @@ public class TaskExecutorDialogPane extends BorderPane {
String finalUnit = unit;
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
};
FileDownloadTask.speedEvent.channel(FileDownloadTask.SpeedEvent.class).registerWeak(speedEventHandler);
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
onEscPressed(this, btnCancel::fire);
}
@@ -17,15 +17,20 @@
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXProgressBar;
import javafx.application.Platform;
import javafx.beans.WeakListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
@@ -52,6 +57,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask;
@@ -63,54 +69,86 @@ import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.lang.ref.WeakReference;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class TaskListPane extends StackPane {
private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 0);
private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 8, 26);
private TaskExecutor executor;
private final AdvancedListBox listBox = new AdvancedListBox();
private final JFXListView<Node> listView = new JFXListView<>();
private final Map<Task<?>, ProgressListNode> nodes = new HashMap<>();
private final List<StageNode> stageNodes = new ArrayList<>();
private final Map<String, StageNode> stageNodes = new HashMap<>();
private final ObjectProperty<Insets> progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY);
private final DoubleProperty cellWidth = new SimpleDoubleProperty();
private Cell lastCell;
public TaskListPane() {
listBox.setSpacing(0);
listView.setPadding(new Insets(12, 0, 0, 0));
listView.setCellFactory(l -> new Cell());
listView.setSelectionModel(null);
FXUtils.onChangeAndOperate(listView.widthProperty(), width -> {
double w = width.doubleValue();
cellWidth.set(w <= 12.0 ? w : w - 12.0);
});
getChildren().setAll(listBox);
getChildren().setAll(listView);
}
@FXThread
private void addStages(@NotNull Collection<String> stages) {
for (String stage : stages) {
stageNodes.computeIfAbsent(stage, s -> {
StageNode node = new StageNode(stage);
listView.getItems().add(node);
return node;
});
}
}
@FXThread
private void updateProgressNodePadding() {
progressNodePadding.set(stageNodes.isEmpty() ? DEFAULT_PROGRESS_NODE_PADDING : STAGED_PROGRESS_NODE_PADDING);
}
public void setExecutor(TaskExecutor executor) {
List<String> stages = Lang.removingDuplicates(executor.getStages());
this.executor = executor;
executor.addTaskListener(new TaskListener() {
@Override
public void onStart() {
Platform.runLater(() -> {
stageNodes.clear();
listBox.clear();
stageNodes.addAll(stages.stream().map(StageNode::new).collect(Collectors.toList()));
stageNodes.forEach(listBox::add);
if (stages.isEmpty()) progressNodePadding.setValue(new Insets(0, 0, 8, 0));
else progressNodePadding.setValue(new Insets(0, 0, 8, 26));
listView.getItems().clear();
addStages(executor.getStages());
updateProgressNodePadding();
});
}
@Override
public void onReady(Task<?> task) {
if (task instanceof Task.StagesHintTask) {
Platform.runLater(() -> {
addStages(((Task<?>.StagesHintTask) task).getStages());
updateProgressNodePadding();
});
}
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::begin);
StageNode node = stageNodes.get(task.getStage());
if (node != null)
node.begin();
});
}
}
@@ -123,6 +161,8 @@ public final class TaskListPane extends StackPane {
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
@@ -156,7 +196,7 @@ public final class TaskListPane extends StackPane {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask) {
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
@@ -169,25 +209,25 @@ public final class TaskListPane extends StackPane {
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
StageNode stageNode = stageNodes.stream().filter(x -> x.stage.equals(task.getInheritedStage())).findAny().orElse(null);
listBox.add(listBox.indexOf(stageNode) + 1, node);
StageNode stageNode = stageNodes.get(task.getInheritedStage());
listView.getItems().add(listView.getItems().indexOf(stageNode) + 1, node);
});
}
@Override
public void onFinished(Task<?> task) {
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::succeed);
});
}
Platform.runLater(() -> {
if (task.getStage() != null) {
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.succeed();
}
ProgressListNode node = nodes.remove(task);
if (node == null)
return;
node.unbind();
listBox.remove(node);
if (node != null) {
node.unbind();
listView.getItems().remove(node);
}
});
}
@@ -195,25 +235,23 @@ public final class TaskListPane extends StackPane {
public void onFailed(Task<?> task, Throwable throwable) {
if (task.getStage() != null) {
Platform.runLater(() -> {
stageNodes.stream().filter(x -> x.stage.equals(task.getStage())).findAny().ifPresent(StageNode::fail);
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.fail();
});
}
ProgressListNode node = nodes.remove(task);
if (node == null)
return;
Platform.runLater(() -> {
node.setThrowable(throwable);
});
if (node != null)
Platform.runLater(() -> node.setThrowable(throwable));
}
@Override
public void onPropertiesUpdate(Task<?> task) {
if (task instanceof Task.CountTask) {
runInFX(() -> {
stageNodes.stream()
.filter(x -> x.stage.equals(((Task<?>.CountTask) task).getCountStage()))
.findAny()
.ifPresent(StageNode::count);
StageNode stageNode = stageNodes.get(((Task<?>.CountTask) task).getCountStage());
if (stageNode != null)
stageNode.count();
});
return;
@@ -222,121 +260,251 @@ public final class TaskListPane extends StackPane {
if (task.getStage() != null) {
int total = tryCast(task.getProperties().get("total"), Integer.class).orElse(0);
runInFX(() -> {
stageNodes.stream()
.filter(x -> x.stage.equals(task.getStage()))
.findAny()
.ifPresent(stageNode -> {
stageNode.setTotal(total);
});
StageNode stageNode = stageNodes.get(task.getStage());
if (stageNode != null)
stageNode.addTotal(total);
});
}
}
});
}
private static class StageNode extends BorderPane {
private final String stage;
private final class Cell extends ListCell<Node> {
private static final double STATUS_ICON_SIZE = 14;
private final BorderPane pane = new BorderPane();
private final StackPane left = new StackPane();
private final Label title = new Label();
private final Label message = new Label();
private final JFXProgressBar bar = new JFXProgressBar();
private WeakReference<StageNode> prevStageNodeRef;
private StatusChangeListener statusChangeListener;
private Cell() {
setPadding(new Insets(0, 0, 4, 0));
prefWidthProperty().bind(cellWidth);
FXUtils.setLimitHeight(left, STATUS_ICON_SIZE);
FXUtils.setLimitWidth(left, STATUS_ICON_SIZE);
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
BorderPane.setMargin(left, new Insets(0, 12, 0, 0));
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
pane.setCenter(title);
DoubleBinding barWidth = Bindings.createDoubleBinding(() -> {
Insets padding = pane.getPadding();
Insets insets = pane.getInsets();
return pane.getWidth() - padding.getLeft() - padding.getRight() - insets.getLeft() - insets.getRight();
}, pane.paddingProperty(), pane.widthProperty());
bar.minWidthProperty().bind(barWidth);
bar.prefWidthProperty().bind(barWidth);
bar.maxWidthProperty().bind(barWidth);
setGraphic(pane);
}
private void updateLeftIcon(StageNode.Status status) {
left.getChildren().setAll(status.svg.createIcon(Theme.blackFill(), STATUS_ICON_SIZE));
}
@Override
protected void updateItem(Node item, boolean empty) {
super.updateItem(item, empty);
// https://mail.openjdk.org/pipermail/openjfx-dev/2022-July/034764.html
if (this == lastCell && !isVisible())
return;
lastCell = this;
pane.paddingProperty().unbind();
title.textProperty().unbind();
message.textProperty().unbind();
bar.progressProperty().unbind();
StageNode prevStageNode;
if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null)
prevStageNode.status.removeListener(statusChangeListener);
if (item instanceof ProgressListNode) {
var progressListNode = (ProgressListNode) item;
title.setText(progressListNode.title);
message.textProperty().bind(progressListNode.message);
bar.progressProperty().bind(progressListNode.progress);
pane.paddingProperty().bind(progressNodePadding);
pane.setLeft(null);
pane.setRight(message);
pane.setBottom(bar);
} else if (item instanceof StageNode) {
var stageNode = (StageNode) item;
title.textProperty().bind(stageNode.title);
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(left);
pane.setRight(message);
pane.setBottom(null);
updateLeftIcon(stageNode.status.get());
if (statusChangeListener == null)
statusChangeListener = new StatusChangeListener(this);
stageNode.status.addListener(statusChangeListener);
prevStageNodeRef = new WeakReference<>(stageNode);
} else { // item == null
title.setText("");
message.setText("");
bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
pane.setPadding(Insets.EMPTY);
pane.setLeft(null);
pane.setRight(null);
pane.setBottom(null);
}
}
}
private static final class StatusChangeListener implements ChangeListener<StageNode.Status>, WeakListener {
private final WeakReference<Cell> cellRef;
private StatusChangeListener(Cell cell) {
this.cellRef = new WeakReference<>(cell);
}
@Override
public boolean wasGarbageCollected() {
return cellRef.get() == null;
}
@Override
public void changed(ObservableValue<? extends StageNode.Status> observable,
StageNode.Status oldValue,
StageNode.Status newValue) {
Cell cell = cellRef.get();
if (cell == null) {
if (observable != null)
observable.removeListener(this);
return;
}
cell.updateLeftIcon(newValue);
}
}
private static abstract class Node {
}
private static final class StageNode extends Node {
private enum Status {
WAITING(SVG.MORE_HORIZ),
RUNNING(SVG.ARROW_FORWARD),
SUCCESS(SVG.CHECK),
FAILED(SVG.CLOSE);
private final SVG svg;
Status(SVG svg) {
this.svg = svg;
}
}
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.WAITING);
private final StringProperty title = new SimpleStringProperty();
private final String message;
private int count = 0;
private int total = 0;
private boolean started = false;
public StageNode(String stage) {
this.stage = stage;
private StageNode(String stage) {
String stageKey;
String stageValue;
String stageKey = StringUtils.substringBefore(stage, ':');
String stageValue = StringUtils.substringAfter(stage, ':');
int idx = stage.indexOf(':');
if (idx >= 0) {
stageKey = stage.substring(0, idx);
stageValue = stage.substring(idx + 1);
} else {
stageKey = stage;
stageValue = null;
}
// CHECKSTYLE:OFF
// @formatter:off
switch (stageKey) {
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.modpack": message = i18n("install.modpack"); break;
case "hmcl.modpack.download": message = i18n("launch.state.modpack"); break;
case "hmcl.install.assets": message = i18n("assets.download"); break;
case "hmcl.install.libraries": message = i18n("libraries.download"); break;
case "hmcl.install.game": message = i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); break;
case "hmcl.install.forge": message = i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); break;
case "hmcl.install.neoforge": message = i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); break;
case "hmcl.install.liteloader": message = i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.optifine": message = i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); break;
case "hmcl.install.fabric": message = i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); break;
case "hmcl.install.fabric-api": message = i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
default: message = i18n(stageKey); break;
case "hmcl.install.quilt": message = i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); break;
case "hmcl.install.quilt-api": message = i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); break;
default: message = i18n(stageKey); break;
}
// @formatter:on
// CHECKSTYLE:ON
title.setText(message);
BorderPane.setAlignment(title, Pos.CENTER_LEFT);
BorderPane.setMargin(title, new Insets(0, 0, 0, 8));
setPadding(new Insets(0, 0, 8, 4));
setCenter(title);
setLeft(FXUtils.limitingSize(SVG.MORE_HORIZ.createIcon(Theme.blackFill(), 14), 14, 14));
title.set(message);
}
public void begin() {
if (started) return;
started = true;
setLeft(FXUtils.limitingSize(SVG.ARROW_FORWARD.createIcon(Theme.blackFill(), 14), 14, 14));
private void begin() {
if (status.get() == Status.WAITING) {
status.set(Status.RUNNING);
}
}
public void fail() {
setLeft(FXUtils.limitingSize(SVG.CLOSE.createIcon(Theme.blackFill(), 14), 14, 14));
status.set(Status.FAILED);
}
public void succeed() {
setLeft(FXUtils.limitingSize(SVG.CHECK.createIcon(Theme.blackFill(), 14), 14, 14));
status.set(Status.SUCCESS);
}
public void count() {
updateCounter(++count, total);
}
public void setTotal(int total) {
this.total = total;
public void addTotal(int n) {
this.total += n;
updateCounter(count, total);
}
public void updateCounter(int count, int total) {
if (total > 0)
title.setText(String.format("%s - %d/%d", message, count, total));
else
title.setText(message);
title.setValue(total > 0
? message + " - " + count + "/" + total
: message
);
}
}
private class ProgressListNode extends BorderPane {
private final JFXProgressBar bar = new JFXProgressBar();
private final Label title = new Label();
private final Label state = new Label();
private final DoubleBinding binding = Bindings.createDoubleBinding(() ->
getWidth() - getPadding().getLeft() - getPadding().getRight() - getInsets().getLeft() - getInsets().getRight(),
paddingProperty(), widthProperty());
private static final class ProgressListNode extends Node {
private final String title;
private final StringProperty message = new SimpleStringProperty("");
private final DoubleProperty progress = new SimpleDoubleProperty(0.0);
public ProgressListNode(Task<?> task) {
bar.progressProperty().bind(task.progressProperty());
title.setText(task.getName());
state.textProperty().bind(task.messageProperty());
setLeft(title);
setRight(state);
setBottom(bar);
bar.minWidthProperty().bind(binding);
bar.prefWidthProperty().bind(binding);
bar.maxWidthProperty().bind(binding);
paddingProperty().bind(progressNodePadding);
private ProgressListNode(Task<?> task) {
this.title = task.getName();
message.bind(task.messageProperty());
progress.bind(task.progressProperty());
}
public void unbind() {
bar.progressProperty().unbind();
state.textProperty().unbind();
message.unbind();
progress.unbind();
}
public void setThrowable(Throwable throwable) {
unbind();
state.setText(throwable.getLocalizedMessage());
bar.setProgress(0);
message.set(throwable.getLocalizedMessage());
progress.set(0.);
}
}
}
@@ -21,10 +21,7 @@ import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -58,9 +55,9 @@ public class URLValidator extends ValidatorBase {
hasErrors.set(!nullable);
else {
try {
new URL(textField.getText()).toURI();
NetworkUtils.toURI(textField.getText());
hasErrors.set(false);
} catch (IOException | URISyntaxException e) {
} catch (IllegalArgumentException e) {
hasErrors.set(true);
}
}
@@ -31,12 +31,13 @@ import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.input.DragEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.image.PixelReader;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.Launcher;
@@ -44,6 +45,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.account.AddAuthlibInjectorServerPane;
@@ -55,26 +57,23 @@ import org.jackhuang.hmcl.ui.construct.Navigator;
import org.jackhuang.hmcl.ui.construct.JFXDialogPane;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.*;
import java.util.stream.Stream;
import static java.util.stream.Collectors.toList;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.io.FileUtils.getExtension;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DecoratorController {
private static final String PROPERTY_DIALOG_CLOSE_HANDLER = DecoratorController.class.getName() + ".dialog.closeListener";
@@ -124,18 +123,13 @@ public class DecoratorController {
// Setup background
decorator.setContentBackground(getBackground());
changeBackgroundListener = o -> {
final int currentCount = ++this.changeBackgroundCount;
CompletableFuture.supplyAsync(this::getBackground, Schedulers.io())
.thenAcceptAsync(background -> {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
}, Schedulers.javafx());
};
changeBackgroundListener = o -> updateBackground();
WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener);
config().backgroundImageTypeProperty().addListener(weakListener);
config().backgroundImageProperty().addListener(weakListener);
config().backgroundImageUrlProperty().addListener(weakListener);
config().backgroundPaintProperty().addListener(weakListener);
config().backgroundImageOpacityProperty().addListener(weakListener);
// pass key events to current dialog / current page
decorator.addEventFilter(KeyEvent.ANY, e -> {
@@ -171,6 +165,17 @@ public class DecoratorController {
// press ESC to go back
onEscPressed(navigator, this::back);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
// press F11 to toggle full screen
navigator.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.F11) {
stage.setFullScreen(!stage.isFullScreen());
e.consume();
}
});
}
try {
// For JavaFX 12+
MouseButton button = MouseButton.valueOf("BACK");
@@ -196,6 +201,20 @@ public class DecoratorController {
@SuppressWarnings("FieldCanBeLocal") // Strong reference
private final InvalidationListener changeBackgroundListener;
private void updateBackground() {
final int currentCount = ++this.changeBackgroundCount;
Task.supplyAsync(Schedulers.io(), this::getBackground)
.setName("Update background")
.whenComplete(Schedulers.javafx(), (background, exception) -> {
if (exception == null) {
if (this.changeBackgroundCount == currentCount)
decorator.setContentBackground(background);
} else {
LOG.warning("Failed to update background", exception);
}
}).start();
}
private Background getBackground() {
EnumBackgroundImage imageType = config().getBackgroundImageType();
@@ -214,7 +233,7 @@ public class DecoratorController {
String backgroundImageUrl = config().getBackgroundImageUrl();
if (backgroundImageUrl != null) {
try {
image = FXUtils.loadImage(new URL(backgroundImageUrl));
image = FXUtils.loadImage(backgroundImageUrl);
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -223,13 +242,60 @@ public class DecoratorController {
case CLASSIC:
image = newBuiltinImage("/assets/img/background-classic.jpg");
break;
case TRANSLUCENT:
case TRANSLUCENT: // Deprecated
return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY));
case PAINT:
Paint paint = config().getBackgroundPaint();
double opacity = Lang.clamp(0, config().getBackgroundImageOpacity(), 100) / 100.;
if (paint instanceof Color || paint == null) {
Color color = (Color) paint;
if (color == null)
color = Color.WHITE; // Default to white if no color is set
if (opacity < 1.)
color = new Color(color.getRed(), color.getGreen(), color.getBlue(), opacity);
return new Background(new BackgroundFill(color, CornerRadii.EMPTY, Insets.EMPTY));
} else {
// TODO: Support opacity for non-color paints
return new Background(new BackgroundFill(paint, CornerRadii.EMPTY, Insets.EMPTY));
}
}
if (image == null) {
image = loadDefaultBackgroundImage();
}
return new Background(new BackgroundImage(image, BackgroundRepeat.NO_REPEAT, BackgroundRepeat.NO_REPEAT, BackgroundPosition.DEFAULT, new BackgroundSize(800, 480, false, false, true, true)));
return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity());
}
private Background createBackgroundWithOpacity(Image image, int opacity) {
if (opacity <= 0) {
return new Background(new BackgroundFill(new Color(1, 1, 1, 0), CornerRadii.EMPTY, Insets.EMPTY));
} else if (opacity >= 100) {
return new Background(new BackgroundImage(
image,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
} else {
WritableImage tempImage = new WritableImage((int) image.getWidth(), (int) image.getHeight());
PixelReader pixelReader = image.getPixelReader();
PixelWriter pixelWriter = tempImage.getPixelWriter();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
Color color = pixelReader.getColor(x, y);
Color newColor = new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * opacity / 100);
pixelWriter.setColor(x, y, newColor);
}
}
return new Background(new BackgroundImage(
tempImage,
BackgroundRepeat.NO_REPEAT,
BackgroundRepeat.NO_REPEAT,
BackgroundPosition.DEFAULT,
new BackgroundSize(800, 480, false, false, true, true)
));
}
}
/**
@@ -18,8 +18,11 @@
package org.jackhuang.hmcl.ui.decorator;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -29,6 +32,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
@@ -43,6 +47,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationProducer;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
public class DecoratorSkin extends SkinBase<Decorator> {
private final StackPane root, parent;
@@ -50,6 +55,8 @@ public class DecoratorSkin extends SkinBase<Decorator> {
private final Stage primaryStage;
private final TransitionPane navBarPane;
private final InvalidationListener onWindowsStatusChange;
private double mouseInitX, mouseInitY, stageInitX, stageInitY, stageInitWidth, stageInitHeight;
/**
@@ -80,9 +87,34 @@ public class DecoratorSkin extends SkinBase<Decorator> {
skinnable.getSnackbar().registerSnackbarContainer(parent);
root.addEventFilter(MouseEvent.MOUSE_RELEASED, this::onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, this::onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
EventHandler<MouseEvent> onMouseReleased = this::onMouseReleased;
EventHandler<MouseEvent> onMouseDragged = this::onMouseDragged;
EventHandler<MouseEvent> onMouseMoved = this::onMouseMoved;
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
onWindowsStatusChange = observable -> {
if (primaryStage.isIconified() || primaryStage.isFullScreen() || primaryStage.isMaximized()) {
root.removeEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.removeEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.removeEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
} else {
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
};
WeakInvalidationListener weakOnWindowsStatusChange = new WeakInvalidationListener(onWindowsStatusChange);
primaryStage.iconifiedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.maximizedProperty().addListener(weakOnWindowsStatusChange);
primaryStage.fullScreenProperty().addListener(weakOnWindowsStatusChange);
onWindowsStatusChange.invalidated(null);
} else {
onWindowsStatusChange = null;
root.addEventFilter(MouseEvent.MOUSE_RELEASED, onMouseReleased);
root.addEventFilter(MouseEvent.MOUSE_DRAGGED, onMouseDragged);
root.addEventFilter(MouseEvent.MOUSE_MOVED, onMouseMoved);
}
shadowContainer.getChildren().setAll(parent);
root.getChildren().setAll(shadowContainer);
@@ -156,6 +188,16 @@ public class DecoratorSkin extends SkinBase<Decorator> {
BorderPane titleBar = new BorderPane();
titleContainer.getChildren().add(titleBar);
// https://github.com/HMCL-dev/HMCL/issues/4290
if (OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
titleBar.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
primaryStage.setMaximized(!primaryStage.isMaximized());
event.consume();
}
});
}
Rectangle buttonsContainerPlaceHolder = new Rectangle();
{
navBarPane = new TransitionPane();
@@ -52,7 +52,6 @@ import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
@@ -156,7 +155,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
Controllers.taskDialog(Task.composeAsync(() -> {
FileDownloadTask task = new FileDownloadTask(NetworkUtils.toURL(file.getFile().getUrl()), dest.toFile());
var task = new FileDownloadTask(file.getFile().getUrl(), dest);
task.setName(file.getName());
return task;
}).whenComplete(Schedulers.javafx(), exception -> {
@@ -27,7 +27,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.RequiredValidator;
import org.jackhuang.hmcl.ui.construct.Validator;
import org.jackhuang.hmcl.ui.wizard.WizardController;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.Map;
@@ -77,11 +76,24 @@ public class InstallersPage extends AbstractInstallersPage {
public void cleanup(Map<String, Object> settings) {
}
private static boolean checkName(String name) {
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (!(c >= '0' && c <= '9')
&& !(c >= 'a' && c <= 'z')
&& !(c >= 'A' && c <= 'Z')
&& c != '-' && c != '_' && c != '.'
)
return false;
}
return true;
}
protected void onInstall() {
String name = txtName.getText();
// Check for non-ASCII characters.
if (!StringUtils.isASCII(name)) {
if (!checkName(name)) {
Controllers.dialog(new MessageDialogPane.Builder(
i18n("install.name.invalid"),
i18n("message.warning"),
@@ -18,11 +18,11 @@
package org.jackhuang.hmcl.ui.download;
import javafx.scene.Node;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
import org.jackhuang.hmcl.mod.Modpack;
import org.jackhuang.hmcl.mod.ModpackCompletionException;
import org.jackhuang.hmcl.mod.UnsupportedModpackException;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.setting.Profile;
@@ -102,7 +102,7 @@ public class ModpackInstallWizardProvider implements WizardProvider {
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
} catch (MismatchedModpackTypeException e) {
Controllers.dialog(i18n("modpack.mismatched_type"), i18n("message.error"), MessageType.ERROR);
Controllers.dialog(i18n("modpack.mismatched_type", e.getRequired(), e.getFound()), i18n("message.error"), MessageType.ERROR);
} catch (IOException e) {
Controllers.dialog(i18n("modpack.invalid"), i18n("message.error"), MessageType.ERROR);
}
@@ -29,9 +29,7 @@ import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.game.ModpackHelper;
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.GetTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.*;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
@@ -43,7 +41,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
@@ -126,10 +123,9 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
}
private void onChooseRemoteFile() {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (urlString, resolve, reject) -> {
Controllers.prompt(i18n("modpack.choose.remote.tooltip"), (url, resolve, reject) -> {
try {
URL url = new URL(urlString);
if (urlString.endsWith("server-manifest.json")) {
if (url.endsWith("server-manifest.json")) {
// if urlString ends with .json, we assume that the url is server-manifest.json
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
@@ -150,7 +146,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
resolve.run();
Controllers.taskDialog(
new FileDownloadTask(url, modpack.toFile(), null)
new FileDownloadTask(url, modpack)
.whenComplete(Schedulers.javafx(), e -> {
if (e == null) {
resolve.run();
@@ -37,7 +37,7 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -85,6 +85,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
stages.add(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()));
if ("game".equals(remoteVersion.getLibraryId())) {
stages.add("hmcl.install.libraries");
stages.add("hmcl.install.assets");
}
} else if (value instanceof RemoveVersionAction) {
@@ -133,28 +134,28 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
int responseCode = rce.getResponseCode();
URL url = rce.getUrl();
String uri = rce.getUri();
if (responseCode == 404)
message += i18n("download.code.404", url);
message += i18n("download.code.404", uri);
else
message += i18n("download.failed", url, responseCode);
message += i18n("download.failed", uri, responseCode);
} else {
message += StringUtils.getStackTrace(exception.getCause());
}
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception instanceof DownloadException) {
URL url = ((DownloadException) exception).getUrl();
URI uri = ((DownloadException) exception).getUri();
if (exception.getCause() instanceof SocketTimeoutException) {
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else if (exception.getCause() instanceof ResponseCodeException) {
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else {
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
}
} else if (exception instanceof UnsupportedInstallationException) {
switch (((UnsupportedInstallationException) exception).getReason()) {
@@ -17,24 +17,17 @@
*/
package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXCheckBox;
import com.jfoenix.controls.JFXListView;
import com.jfoenix.controls.JFXSpinner;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.VersionList;
@@ -49,295 +42,73 @@ import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.ComponentList;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.IconedTwoLineListItem;
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.wizard.Navigation;
import org.jackhuang.hmcl.ui.wizard.Refreshable;
import org.jackhuang.hmcl.ui.wizard.WizardPage;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.wrap;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class VersionsPage extends BorderPane implements WizardPage, Refreshable {
public final class VersionsPage extends Control implements WizardPage, Refreshable {
private final String gameVersion;
private final String libraryId;
private final String title;
private final Navigation navigation;
private final JFXListView<RemoteVersion> list;
private final JFXSpinner spinner;
private final StackPane failedPane;
private final StackPane emptyPane;
private final TransitionPane root;
private final JFXCheckBox chkRelease;
private final JFXCheckBox chkSnapshot;
private final JFXCheckBox chkOld;
private final ComponentList centrePane;
private final StackPane center;
private final VersionList<?> versionList;
private CompletableFuture<?> executor;
private final Runnable callback;
private final HBox searchBar;
private final StringProperty queryString = new SimpleStringProperty();
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
HintPane hintPane = new HintPane();
hintPane.setText(i18n("sponsor.bmclapi"));
hintPane.getStyleClass().add("sponsor-pane");
FXUtils.onClicked(hintPane, this::onSponsor);
BorderPane.setMargin(hintPane, new Insets(10, 10, 0, 10));
this.setTop(hintPane);
root = new TransitionPane();
BorderPane toolbarPane = new BorderPane();
JFXButton btnRefresh;
{
spinner = new JFXSpinner();
center = new StackPane();
center.setStyle("-fx-padding: 10;");
{
centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
HBox checkPane = new HBox();
checkPane.setSpacing(10);
{
chkRelease = new JFXCheckBox(i18n("version.game.releases"));
chkRelease.setSelected(true);
HBox.setMargin(chkRelease, new Insets(10, 0, 10, 0));
chkSnapshot = new JFXCheckBox(i18n("version.game.snapshots"));
HBox.setMargin(chkSnapshot, new Insets(10, 0, 10, 0));
chkOld = new JFXCheckBox(i18n("version.game.old"));
HBox.setMargin(chkOld, new Insets(10, 0, 10, 0));
checkPane.getChildren().setAll(chkRelease, chkSnapshot, chkOld);
}
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
TransitionPane rightToolbarPane = new TransitionPane();
{
HBox refreshPane = new HBox();
refreshPane.setAlignment(Pos.CENTER_RIGHT);
btnRefresh = new JFXButton(i18n("button.refresh"));
btnRefresh.getStyleClass().add("jfx-tool-bar-button");
btnRefresh.setOnAction(e -> onRefresh());
JFXButton btnSearch = new JFXButton(i18n("search"));
btnSearch.getStyleClass().add("jfx-tool-bar-button");
btnSearch.setGraphic(wrap(SVG.SEARCH.createIcon(Theme.blackFill(), -1)));
searchBar = new HBox();
{
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 0));
JFXTextField searchField = new JFXTextField();
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
JFXButton closeSearchBar = new JFXButton();
closeSearchBar.getStyleClass().add("jfx-tool-bar-button");
closeSearchBar.setGraphic(wrap(SVG.CLOSE.createIcon(Theme.blackFill(), -1)));
closeSearchBar.setOnAction(e -> {
searchField.clear();
rightToolbarPane.setContent(refreshPane, ContainerAnimations.FADE);
});
onEscPressed(searchField, closeSearchBar::fire);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> queryString.set(searchField.getText()));
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
pause.setRate(1);
pause.playFromStart();
});
searchBar.getChildren().setAll(searchField, closeSearchBar);
btnSearch.setOnAction(e -> {
rightToolbarPane.setContent(searchBar, ContainerAnimations.FADE);
searchField.requestFocus();
});
}
refreshPane.getChildren().setAll(new HBox(btnSearch, btnRefresh));
rightToolbarPane.setContent(refreshPane, ContainerAnimations.NONE);
}
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
toolbarPane.setLeft(checkPane);
toolbarPane.setRight(rightToolbarPane);
centrePane.getContent().setAll(toolbarPane, list);
}
center.getChildren().setAll(centrePane);
}
failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, this::onRefresh);
failedPane.getChildren().setAll(label);
}
emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, this::onBack);
emptyPane.getChildren().setAll(label);
}
}
this.setCenter(root);
versionList = downloadProvider.getVersionListById(libraryId);
boolean hasType = versionList.hasType();
chkRelease.setManaged(hasType);
chkRelease.setVisible(hasType);
chkSnapshot.setManaged(hasType);
chkSnapshot.setVisible(hasType);
chkOld.setManaged(hasType);
chkOld.setVisible(hasType);
if (hasType) {
centrePane.getContent().setAll(toolbarPane, list);
} else {
centrePane.getContent().setAll(list);
}
ComponentList.setVgrow(list, Priority.ALWAYS);
InvalidationListener listener = o -> {
List<RemoteVersion> versions = loadVersions();
String query = queryString.get();
if (!StringUtils.isBlank(query)) {
Predicate<RemoteVersion> predicate;
if (query.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(query.substring("regex:".length()));
predicate = it -> pattern.matcher(it.getSelfVersion()).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = query.toLowerCase(Locale.ROOT);
predicate = it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString);
}
versions = versions.stream().filter(predicate).collect(Collectors.toList());
}
list.getItems().setAll(versions);
};
chkRelease.selectedProperty().addListener(listener);
chkSnapshot.selectedProperty().addListener(listener);
chkOld.selectedProperty().addListener(listener);
queryString.addListener(listener);
btnRefresh.setGraphic(wrap(SVG.REFRESH.createIcon(Theme.blackFill(), -1)));
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
navigation.getSettings().put(libraryId, list.getSelectionModel().getSelectedItem());
callback.run();
});
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
refresh();
}
private List<RemoteVersion> loadVersions() {
return versionList.getVersions(gameVersion).stream()
.filter(it -> {
switch (it.getVersionType()) {
case RELEASE:
return chkRelease.isSelected();
case PENDING:
case SNAPSHOT:
return chkSnapshot.isSelected();
case OLD:
return chkOld.isSelected();
default:
return true;
}
})
.sorted().collect(Collectors.toList());
@Override
protected Skin<?> createDefaultSkin() {
return new VersionsPageSkin(this);
}
@Override
public void refresh() {
VersionList<?> currentVersionList = versionList;
root.setContent(spinner, ContainerAnimations.FADE);
executor = currentVersionList.refreshAsync(gameVersion).whenComplete((result, exception) -> {
if (exception == null) {
List<RemoteVersion> items = loadVersions();
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
if (currentVersionList.getVersions(gameVersion).isEmpty()) {
root.setContent(emptyPane, ContainerAnimations.FADE);
status.set(Status.LOADING);
Task<?> task = versionList.refreshAsync(gameVersion)
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
.whenComplete(Schedulers.javafx(), (items, exception) -> {
if (exception == null) {
versions.setAll(items);
status.set(Status.SUCCESS);
} else {
if (items.isEmpty()) {
chkRelease.setSelected(true);
chkSnapshot.setSelected(true);
chkOld.setSelected(true);
} else {
list.getItems().setAll(items);
}
root.setContent(center, ContainerAnimations.FADE);
LOG.warning("Failed to fetch versions list", exception);
status.set(Status.FAILED);
}
});
} else {
LOG.warning("Failed to fetch versions list", exception);
Platform.runLater(() -> {
if (versionList != currentVersionList) return;
root.setContent(failedPane, ContainerAnimations.FADE);
});
}
// https://github.com/HMCL-dev/HMCL/issues/938
System.gc();
});
task.start();
}
@Override
@@ -348,8 +119,6 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
@Override
public void cleanup(Map<String, Object> settings) {
settings.remove(libraryId);
if (executor != null)
executor.cancel(true);
}
private void onRefresh() {
@@ -364,10 +133,24 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
}
private enum Status {
LOADING,
FAILED,
SUCCESS,
}
private enum VersionTypeFilter {
ALL,
RELEASE,
SNAPSHOTS,
APRIL_FOOLS,
OLD
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
final IconedTwoLineListItem content = new IconedTwoLineListItem();
final RipplerContainer ripplerContainer = new RipplerContainer(content);
final StackPane pane = new StackPane();
private final IconedTwoLineListItem content = new IconedTwoLineListItem();
private final RipplerContainer ripplerContainer = new RipplerContainer(content);
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
@@ -398,7 +181,7 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
}
setGraphic(pane);
content.setTitle(remoteVersion.getSelfVersion());
content.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
@@ -406,22 +189,30 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
}
if (remoteVersion instanceof GameRemoteVersion) {
switch (remoteVersion.getVersionType()) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
String wikiSuffix = getWikiUrlSuffix(remoteVersion.getGameVersion());
switch (versionType) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
content.setExternalLink(i18n("wiki.version.game.release", remoteVersion.getGameVersion()));
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
case PENDING:
case SNAPSHOT:
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
content.setExternalLink(i18n("wiki.version.game.snapshot", remoteVersion.getGameVersion()));
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
content.getTags().setAll(i18n("version.game.april_fools"));
content.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
}
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
content.setExternalLink(null);
content.setExternalLink(i18n("wiki.version.game", wikiSuffix));
break;
}
} else {
@@ -449,5 +240,285 @@ public final class VersionsPage extends BorderPane implements WizardPage, Refres
content.setExternalLink(null);
}
}
private String getWikiUrlSuffix(String gameVersion) {
String id = gameVersion.toLowerCase(Locale.ROOT);
switch (id) {
case "0.30-1":
case "0.30-2":
case "c0.30_01c":
return i18n("wiki.version.game.search", "Classic_0.30");
case "in-20100206-2103":
return i18n("wiki.version.game.search", "Indev_20100206");
case "inf-20100630-1":
return i18n("wiki.version.game.search", "Infdev_20100630");
case "inf-20100630-2":
return i18n("wiki.version.game.search", "Alpha_v1.0.0");
case "1.19_deep_dark_experimental_snapshot-1":
return "1.19-exp1";
case "in-20100130":
return i18n("wiki.version.game.search", "Indev_0.31_20100130");
case "b1.6-tb3":
return i18n("wiki.version.game.search", "Beta_1.6_Test_Build_3");
case "1.14_combat-212796":
return i18n("wiki.version.game.search", "1.14.3_-_Combat_Test");
case "1.14_combat-0":
return i18n("wiki.version.game.search", "Combat_Test_2");
case "1.14_combat-3":
return i18n("wiki.version.game.search", "Combat_Test_3");
case "1_15_combat-1":
return i18n("wiki.version.game.search", "Combat_Test_4");
case "1_15_combat-6":
return i18n("wiki.version.game.search", "Combat_Test_5");
case "1_16_combat-0":
return i18n("wiki.version.game.search", "Combat_Test_6");
case "1_16_combat-1":
return i18n("wiki.version.game.search", "Combat_Test_7");
case "1_16_combat-2":
return i18n("wiki.version.game.search", "Combat_Test_7b");
case "1_16_combat-3":
return i18n("wiki.version.game.search", "Combat_Test_7c");
case "1_16_combat-4":
return i18n("wiki.version.game.search", "Combat_Test_8");
case "1_16_combat-5":
return i18n("wiki.version.game.search", "Combat_Test_8b");
case "1_16_combat-6":
return i18n("wiki.version.game.search", "Combat_Test_8c");
}
if (id.startsWith("1.0.0-rc2")) return "RC2";
if (id.startsWith("2.0")) return i18n("wiki.version.game.search", "2.0");
if (id.startsWith("b1.8-pre1")) return "Beta_1.8-pre1";
if (id.startsWith("b1.1-")) return i18n("wiki.version.game.search", "Beta_1.1");
if (id.startsWith("a1.1.0")) return "Alpha_v1.1.0";
if (id.startsWith("a1.0.14")) return "Alpha_v1.0.14";
if (id.startsWith("a1.0.13_01")) return "Alpha_v1.0.13_01";
if (id.startsWith("in-20100214")) return i18n("wiki.version.game.search", "Indev_20100214");
if (id.contains("experimental-snapshot")) {
return id.replace("_experimental-snapshot-", "-exp");
}
if (id.startsWith("inf-")) return id.replace("inf-", "Infdev_");
if (id.startsWith("in-")) return id.replace("in-", "Indev_");
if (id.startsWith("rd-")) return "pre-Classic_" + id;
if (id.startsWith("b")) return id.replace("b", "Beta_");
if (id.startsWith("a")) return id.replace("a", "Alpha_v");
if (id.startsWith("c")) return id.replace("c", "Classic_").replace("st", "SURVIVAL_TEST");
return i18n("wiki.version.game.search", id);
}
}
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
private final JFXListView<RemoteVersion> list;
private final TransitionPane transitionPane;
private final JFXSpinner spinner;
private final JFXTextField nameField;
private final JFXComboBox<VersionTypeFilter> categoryField = new JFXComboBox<>();
VersionsPageSkin(VersionsPage control) {
super(control);
BorderPane root = new BorderPane();
GridPane searchPane = new GridPane();
root.setTop(searchPane);
searchPane.getStyleClass().addAll("card");
BorderPane.setMargin(searchPane, new Insets(10, 10, 0, 10));
ColumnConstraints nameColumn = new ColumnConstraints();
nameColumn.setMinWidth(USE_PREF_SIZE);
ColumnConstraints column1 = new ColumnConstraints();
column1.setHgrow(Priority.ALWAYS);
ColumnConstraints column2 = new ColumnConstraints();
column2.setMaxWidth(150);
ColumnConstraints column3 = new ColumnConstraints();
if (control.versionList.hasType())
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
else
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
searchPane.setHgap(16);
searchPane.setVgap(10);
{
int rowIndex = 0;
{
nameField = new JFXTextField();
nameField.setPromptText(i18n("version.search.prompt"));
nameField.textProperty().addListener(o -> updateList());
if ("game".equals(control.libraryId)) {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS,
VersionTypeFilter.APRIL_FOOLS,
VersionTypeFilter.OLD
);
categoryField.getSelectionModel().select(VersionTypeFilter.RELEASE);
} else {
categoryField.getItems().setAll(
VersionTypeFilter.ALL,
VersionTypeFilter.RELEASE,
VersionTypeFilter.SNAPSHOTS
);
categoryField.getSelectionModel().select(VersionTypeFilter.ALL);
}
categoryField.setConverter(stringConverter(type -> i18n("version.game." + type.name().toLowerCase(Locale.ROOT))));
categoryField.getSelectionModel().selectedItemProperty().addListener(o -> updateList());
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
refreshButton.setOnAction(event -> control.onRefresh());
if (control.versionList.hasType()) {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
new Label(i18n("version.game.type")), categoryField,
refreshButton
);
} else {
searchPane.addRow(rowIndex++,
new Label(i18n("version.search")), nameField,
refreshButton
);
}
}
// {
// HBox actionsBox = new HBox(8);
// GridPane.setColumnSpan(actionsBox, 4);
// actionsBox.setAlignment(Pos.CENTER_RIGHT);
//
// JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
// refreshButton.setOnAction(event -> control.onRefresh());
//
// actionsBox.getChildren().setAll(refreshButton);
//
// searchPane.addRow(rowIndex++, actionsBox);
// }
}
{
SpinnerPane spinnerPane = new SpinnerPane();
root.setCenter(spinnerPane);
transitionPane = new TransitionPane();
spinner = new JFXSpinner();
StackPane centerWrapper = new StackPane();
centerWrapper.setStyle("-fx-padding: 10;");
{
ComponentList centrePane = new ComponentList();
centrePane.getStyleClass().add("no-padding");
{
list = new JFXListView<>();
list.getStyleClass().add("jfx-list-view-float");
VBox.setVgrow(list, Priority.ALWAYS);
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control.libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
control.navigation.getSettings().put(control.libraryId, list.getSelectionModel().getSelectedItem());
control.callback.run();
});
ComponentList.setVgrow(list, Priority.ALWAYS);
// ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here
ignoreEvent(list, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
centrePane.getContent().setAll(list);
}
centerWrapper.getChildren().setAll(centrePane);
}
StackPane failedPane = new StackPane();
failedPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.refresh"));
FXUtils.onClicked(label, control::onRefresh);
failedPane.getChildren().setAll(label);
}
StackPane emptyPane = new StackPane();
emptyPane.getStyleClass().add("notice-pane");
{
Label label = new Label(i18n("download.failed.empty"));
FXUtils.onClicked(label, control::onBack);
emptyPane.getChildren().setAll(label);
}
FXUtils.onChangeAndOperate(control.status, status -> {
if (status == Status.LOADING)
transitionPane.setContent(spinner, ContainerAnimations.FADE);
else if (status == Status.SUCCESS)
transitionPane.setContent(centerWrapper, ContainerAnimations.FADE);
else // if (status == Status.FAILED)
transitionPane.setContent(failedPane, ContainerAnimations.FADE);
});
root.setCenter(transitionPane);
}
this.getChildren().setAll(root);
}
private void updateList() {
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
if (filter != null)
versions = versions.filter(it -> {
RemoteVersion.Type versionType = it.getVersionType();
switch (filter) {
case RELEASE:
return versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS:
return versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
case APRIL_FOOLS:
return versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD:
return versionType == RemoteVersion.Type.OLD;
case ALL:
default:
return true;
}
});
String nameQuery = nameField.getText();
if (!StringUtils.isBlank(nameQuery)) {
if (nameQuery.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(nameQuery.substring("regex:".length()));
versions = versions.filter(it -> pattern.matcher(it.getSelfVersion()).find());
} catch (Throwable e) {
LOG.warning("Illegal regular expression: " + nameQuery, e);
}
} else {
String lowerQueryString = nameQuery.toLowerCase(Locale.ROOT);
versions = versions.filter(it -> it.getSelfVersion().toLowerCase(Locale.ROOT).contains(lowerQueryString));
}
}
//noinspection DataFlowIssue
list.getItems().setAll(versions.collect(Collectors.toList()));
list.scrollTo(0);
}
}
}
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.setting.Config;
import org.jackhuang.hmcl.setting.FontManager;
import org.jackhuang.hmcl.setting.Profile;
@@ -76,6 +77,10 @@ public final class ExportWizardProvider implements WizardProvider {
File tempModpack;
Task<?> exportTask;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public boolean doPreExecute() {
return true;
@@ -100,6 +105,9 @@ public final class ExportWizardProvider implements WizardProvider {
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
exportTask = exportAsServer(exportInfo, dest);
break;
case ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH:
exportTask = exportAsModrinth(exportInfo, dest);
break;
default:
throw new IllegalStateException("Unrecognized modpack type " + modpackType);
}
@@ -161,6 +169,10 @@ public final class ExportWizardProvider implements WizardProvider {
return new Task<Void>() {
Task<?> dependency = null;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new McbbsModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -177,6 +189,10 @@ public final class ExportWizardProvider implements WizardProvider {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
VersionSetting vs = profile.getVersionSetting(version);
@@ -221,6 +237,10 @@ public final class ExportWizardProvider implements WizardProvider {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ServerModpackExportTask(profile.getRepository(), version, exportInfo, modpackFile);
@@ -233,6 +253,31 @@ public final class ExportWizardProvider implements WizardProvider {
};
}
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, File modpackFile) {
return new Task<Void>() {
Task<?> dependency;
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() {
dependency = new ModrinthModpackExportTask(
profile.getRepository(),
version,
exportInfo,
modpackFile
);
}
@Override
public Collection<Task<?>> getDependencies() {
return Collections.singleton(dependency);
}
};
}
@Override
public Node createPage(WizardController controller, int step, Map<String, Object> settings) {
switch (step) {
@@ -53,6 +53,7 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH;
import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER;
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
import static org.jackhuang.hmcl.util.Lang.tryCast;
@@ -80,6 +81,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private final SimpleStringProperty launchArguments = new SimpleStringProperty("");
private final SimpleStringProperty javaArguments = new SimpleStringProperty("");
private final SimpleStringProperty mcbbsThreadId = new SimpleStringProperty("");
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepository, String version) {
this.controller = controller;
@@ -102,7 +105,13 @@ public final class ModpackInfoPage extends Control implements WizardPage {
private void onNext() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("modpack.wizard.step.initialization.save"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
if (controller.getSettings().get(MODPACK_TYPE) == ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH) {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.mrpack"));
fileChooser.setInitialFileName(name.get() + ".mrpack");
} else {
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("modpack"), "*.zip"));
fileChooser.setInitialFileName(name.get() + ".zip");
}
File file = fileChooser.showSaveDialog(Controllers.getStage());
if (file == null) {
controller.onEnd();
@@ -122,6 +131,8 @@ public final class ModpackInfoPage extends Control implements WizardPage {
exportInfo.setLaunchArguments(launchArguments.get());
exportInfo.setJavaArguments(javaArguments.get());
exportInfo.setAuthlibInjectorServer(authlibInjectorServer.get());
exportInfo.setNoCreateRemoteFiles(noCreateRemoteFiles.get());
exportInfo.setSkipCurseForgeRemoteFiles(skipCurseForgeRemoteFiles.get());
if (StringUtils.isNotBlank(mcbbsThreadId.get())) {
exportInfo.setOrigins(Collections.singletonList(new McbbsModpackManifest.Origin(
@@ -178,6 +189,10 @@ public final class ModpackInfoPage extends Control implements WizardPage {
Hyperlink hyperlink = new Hyperlink(i18n("modpack.wizard.step.initialization.server"));
hyperlink.setOnAction(e -> FXUtils.openLink(Metadata.DOCS_URL + "/modpack/serverpack.html"));
borderPane.setTop(hyperlink);
} if (skinnable.controller.getSettings().get(MODPACK_TYPE) == MODPACK_TYPE_MODRINTH) {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.modrinth.info"));
borderPane.setTop(pane);
} else {
HintPane pane = new HintPane(MessageDialogPane.MessageType.INFO);
pane.setText(i18n("modpack.wizard.step.initialization.warning"));
@@ -366,6 +381,32 @@ public final class ModpackInfoPage extends Control implements WizardPage {
button.setMaxHeight(16);
pane.setRight(button);
}
if (skinnable.options.isRequireNoCreateRemoteFiles()) {
BorderPane noCreateRemoteFiles = new BorderPane();
noCreateRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.no_create_remote_files")));
list.getContent().add(noCreateRemoteFiles);
JFXToggleButton noCreateRemoteFilesButton = new JFXToggleButton();
noCreateRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.noCreateRemoteFiles);
noCreateRemoteFilesButton.setSize(8);
noCreateRemoteFilesButton.setMinHeight(16);
noCreateRemoteFilesButton.setMaxHeight(16);
noCreateRemoteFiles.setRight(noCreateRemoteFilesButton);
}
if (skinnable.options.isRequireSkipCurseForgeRemoteFiles()) {
BorderPane skipCurseForgeRemoteFiles = new BorderPane();
skipCurseForgeRemoteFiles.setLeft(new Label(i18n("modpack.wizard.step.initialization.skip_curseforge_remote_files")));
list.getContent().add(skipCurseForgeRemoteFiles);
JFXToggleButton skipCurseForgeRemoteFilesButton = new JFXToggleButton();
skipCurseForgeRemoteFilesButton.selectedProperty().bindBidirectional(skinnable.skipCurseForgeRemoteFiles);
skipCurseForgeRemoteFilesButton.setSize(8);
skipCurseForgeRemoteFilesButton.setMinHeight(16);
skipCurseForgeRemoteFilesButton.setMaxHeight(16);
skipCurseForgeRemoteFiles.setRight(skipCurseForgeRemoteFilesButton);
}
}
{
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo;
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask;
import org.jackhuang.hmcl.mod.server.ServerModpackExportTask;
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.ui.wizard.WizardController;
@@ -56,7 +57,8 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
title,
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
);
}
@@ -100,4 +102,5 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
public static final String MODPACK_TYPE_MULTIMC = "multimc";
public static final String MODPACK_TYPE_SERVER = "server";
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
}
@@ -15,17 +15,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
package org.jackhuang.hmcl.ui.image;
/**
* @author Glavo
*/
public final class ModuleHelper {
public static void addEnableNativeAccess(Class<?> clazzInModule) {
// do nothing
}
private ModuleHelper() {
}
public interface AnimationImage {
}
@@ -0,0 +1,28 @@
/*
* 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/>.
*/
package org.jackhuang.hmcl.ui.image;
import javafx.scene.image.Image;
import java.io.InputStream;
public interface ImageLoader {
Image load(InputStream input,
int requestedWidth, int requestedHeight,
boolean preserveRatio, boolean smooth) throws Exception;
}
@@ -0,0 +1,411 @@
/*
* 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/>.
*/
package org.jackhuang.hmcl.ui.image;
import com.twelvemonkeys.imageio.plugins.webp.WebPImageReaderSpi;
import javafx.animation.Timeline;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage;
import org.jackhuang.hmcl.ui.image.apng.Png;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Bitmap;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888BitmapSequence;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
import org.jackhuang.hmcl.util.SwingFXUtils;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
* @author Glavo
*/
public final class ImageUtils {
// ImageLoaders
public static final ImageLoader DEFAULT = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
Image image = new Image(input,
requestedWidth, requestedHeight,
preserveRatio, smooth);
if (image.isError())
throw image.getException();
return image;
};
public static final ImageLoader WEBP = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
WebPImageReaderSpi spi = new WebPImageReaderSpi();
ImageReader reader = spi.createReaderInstance(null);
BufferedImage bufferedImage;
try (ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
reader.setInput(imageInput, true, true);
bufferedImage = reader.read(0, reader.getDefaultReadParam());
} finally {
reader.dispose();
}
return SwingFXUtils.toFXImage(bufferedImage, requestedWidth, requestedHeight, preserveRatio, smooth);
};
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
if (!"true".equals(System.getProperty("hmcl.experimental.apng", "true")))
return DEFAULT.load(input, requestedWidth, requestedHeight, preserveRatio, smooth);
try {
var sequence = Png.readArgb8888BitmapSequence(input);
final int width = sequence.header.width;
final int height = sequence.header.height;
boolean doScale;
if (requestedWidth > 0 && requestedHeight > 0
&& (requestedWidth != width || requestedHeight != height)) {
doScale = true;
if (preserveRatio) {
double scaleX = (double) requestedWidth / width;
double scaleY = (double) requestedHeight / height;
double scale = Math.min(scaleX, scaleY);
requestedWidth = (int) (width * scale);
requestedHeight = (int) (height * scale);
}
} else {
doScale = false;
}
if (sequence.isAnimated()) {
try {
return toImage(sequence, doScale, requestedWidth, requestedHeight);
} catch (Throwable e) {
LOG.warning("Failed to load animated image", e);
}
}
Argb8888Bitmap defaultImage = sequence.defaultImage;
int targetWidth;
int targetHeight;
int[] pixels;
if (doScale) {
targetWidth = requestedWidth;
targetHeight = requestedHeight;
pixels = scale(defaultImage.array,
defaultImage.width, defaultImage.height,
targetWidth, targetHeight);
} else {
targetWidth = defaultImage.width;
targetHeight = defaultImage.height;
pixels = defaultImage.array;
}
WritableImage image = new WritableImage(targetWidth, targetHeight);
image.getPixelWriter().setPixels(0, 0, targetWidth, targetHeight,
PixelFormat.getIntArgbInstance(), pixels,
0, targetWidth);
return image;
} catch (PngException e) {
throw new IOException(e);
}
};
public static final Map<String, ImageLoader> EXT_TO_LOADER = Map.of(
"webp", WEBP,
"apng", APNG
);
public static final Map<String, ImageLoader> CONTENT_TYPE_TO_LOADER = Map.of(
"image/webp", WEBP,
"image/apng", APNG
);
public static final Set<String> DEFAULT_EXTS = Set.of(
"jpg", "jpeg", "bmp", "gif"
);
public static final Set<String> DEFAULT_CONTENT_TYPES = Set.of(
"image/jpeg", "image/bmp", "image/gif"
);
// ------
public static final int HEADER_BUFFER_SIZE = 1024;
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
private static final byte[] WEBP_HEADER = {'W', 'E', 'B', 'P'};
public static boolean isWebP(byte[] headerBuffer) {
return headerBuffer.length > 12
&& Arrays.equals(headerBuffer, 0, 4, RIFF_HEADER, 0, 4)
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
}
private static final byte[] PNG_HEADER = {
(byte) 0x89, (byte) 0x50, (byte) 0x4e, (byte) 0x47,
(byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a,
};
private static final class PngChunkHeader {
private static final int IDAT_HEADER = 0x49444154;
private static final int acTL_HEADER = 0x6163544c;
private final int length;
private final int chunkType;
private PngChunkHeader(int length, int chunkType) {
this.length = length;
this.chunkType = chunkType;
}
private static @Nullable PngChunkHeader readHeader(ByteBuffer headerBuffer) {
if (headerBuffer.remaining() < 8)
return null;
int length = headerBuffer.getInt();
int chunkType = headerBuffer.getInt();
return new PngChunkHeader(length, chunkType);
}
}
public static boolean isApng(byte[] headerBuffer) {
if (headerBuffer.length <= 20)
return false;
if (!Arrays.equals(
headerBuffer, 0, 8,
PNG_HEADER, 0, 8))
return false;
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
PngChunkHeader header;
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
// https://wiki.mozilla.org/APNG_Specification#Structure
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
// The `acTL` structure is described below.
if (header.chunkType == PngChunkHeader.IDAT_HEADER)
break;
if (header.chunkType == PngChunkHeader.acTL_HEADER)
return true;
final int numBytes = header.length + 4;
if (buffer.remaining() > numBytes)
buffer.position(buffer.position() + numBytes);
else
break;
}
return false;
}
public static @Nullable ImageLoader guessLoader(byte[] headerBuffer) {
if (isWebP(headerBuffer))
return WEBP;
if (isApng(headerBuffer))
return APNG;
return null;
}
public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("^\\s(?<type>image/[\\w-])");
// APNG
private static int[] scale(int[] pixels,
int sourceWidth, int sourceHeight,
int targetWidth, int targetHeight) {
assert pixels.length == sourceWidth * sourceHeight;
double xScale = ((double) sourceWidth) / targetWidth;
double yScale = ((double) sourceHeight) / targetHeight;
int[] result = new int[targetWidth * targetHeight];
for (int row = 0; row < targetHeight; row++) {
for (int col = 0; col < targetWidth; col++) {
int sourceX = (int) (col * xScale);
int sourceY = (int) (row * yScale);
int color = pixels[sourceY * sourceWidth + sourceX];
result[row * targetWidth + col] = color;
}
}
return result;
}
private static Image toImage(Argb8888BitmapSequence sequence,
boolean doScale,
int targetWidth, int targetHeight) throws PngException {
final int width = sequence.header.width;
final int height = sequence.header.height;
List<Argb8888BitmapSequence.Frame> frames = sequence.getAnimationFrames();
var framePixels = new int[frames.size()][];
var durations = new int[framePixels.length];
int[] buffer = new int[Math.multiplyExact(width, height)];
for (int frameIndex = 0; frameIndex < frames.size(); frameIndex++) {
var frame = frames.get(frameIndex);
PngFrameControl control = frame.control;
if (frameIndex == 0 && (
control.xOffset != 0 || control.yOffset != 0
|| control.width != width || control.height != height)) {
throw new PngIntegrityException("Invalid first frame: " + control);
}
if (control.xOffset < 0 || control.yOffset < 0
|| width < 0 || height < 0
|| control.xOffset + control.width > width
|| control.yOffset + control.height > height
|| control.delayNumerator < 0 || control.delayDenominator < 0
) {
throw new PngIntegrityException("Invalid frame control: " + control);
}
int[] currentFrameBuffer = buffer.clone();
if (control.blendOp == 0) {
for (int row = 0; row < control.height; row++) {
System.arraycopy(frame.bitmap.array,
row * control.width,
currentFrameBuffer,
(control.yOffset + row) * width + control.xOffset,
control.width);
}
} else if (control.blendOp == 1) {
// APNG_BLEND_OP_OVER - Alpha blending
for (int row = 0; row < control.height; row++) {
for (int col = 0; col < control.width; col++) {
int srcIndex = row * control.width + col;
int dstIndex = (control.yOffset + row) * width + control.xOffset + col;
int srcPixel = frame.bitmap.array[srcIndex];
int dstPixel = currentFrameBuffer[dstIndex];
int srcAlpha = (srcPixel >>> 24) & 0xFF;
if (srcAlpha == 0) {
continue;
} else if (srcAlpha == 255) {
currentFrameBuffer[dstIndex] = srcPixel;
} else {
int srcR = (srcPixel >>> 16) & 0xFF;
int srcG = (srcPixel >>> 8) & 0xFF;
int srcB = srcPixel & 0xFF;
int dstAlpha = (dstPixel >>> 24) & 0xFF;
int dstR = (dstPixel >>> 16) & 0xFF;
int dstG = (dstPixel >>> 8) & 0xFF;
int dstB = dstPixel & 0xFF;
int invSrcAlpha = 255 - srcAlpha;
int outAlpha = srcAlpha + (dstAlpha * invSrcAlpha + 127) / 255;
int outR, outG, outB;
if (outAlpha == 0) {
outR = outG = outB = 0;
} else {
outR = (srcR * srcAlpha + dstR * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outG = (srcG * srcAlpha + dstG * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
outB = (srcB * srcAlpha + dstB * dstAlpha * invSrcAlpha / 255 + outAlpha / 2) / outAlpha;
}
outAlpha = Math.min(outAlpha, 255);
outR = Math.min(outR, 255);
outG = Math.min(outG, 255);
outB = Math.min(outB, 255);
currentFrameBuffer[dstIndex] = (outAlpha << 24) | (outR << 16) | (outG << 8) | outB;
}
}
}
} else {
throw new PngIntegrityException("Unsupported blendOp " + control.blendOp + " at frame " + frameIndex);
}
if (doScale)
framePixels[frameIndex] = scale(currentFrameBuffer,
width, height,
targetWidth, targetHeight);
else
framePixels[frameIndex] = currentFrameBuffer;
if (control.delayNumerator == 0) {
durations[frameIndex] = 10;
} else {
int durationsMills = 1000 * control.delayNumerator;
if (control.delayDenominator == 0)
durationsMills /= 100;
else
durationsMills /= control.delayDenominator;
durations[frameIndex] = durationsMills;
}
switch (control.disposeOp) {
case 0: // APNG_DISPOST_OP_NONE
System.arraycopy(currentFrameBuffer, 0, buffer, 0, currentFrameBuffer.length);
break;
case 1: // APNG_DISPOSE_OP_BACKGROUND
for (int row = 0; row < control.height; row++) {
int fromIndex = (control.yOffset + row) * width + control.xOffset;
Arrays.fill(buffer, fromIndex, fromIndex + control.width, 0);
}
break;
case 2: // APNG_DISPOSE_OP_PREVIOUS
// Do nothing, keep the previous frame.
break;
default:
throw new PngIntegrityException("Unsupported disposeOp " + control.disposeOp + " at frame " + frameIndex);
}
}
PngAnimationControl animationControl = sequence.getAnimationControl();
int cycleCount;
if (animationControl != null) {
cycleCount = animationControl.numPlays;
if (cycleCount == 0)
cycleCount = Timeline.INDEFINITE;
} else {
cycleCount = Timeline.INDEFINITE;
}
if (doScale)
return new AnimationImageImpl(targetWidth, targetHeight, framePixels, durations, cycleCount);
else
return new AnimationImageImpl(width, height, framePixels, durations, cycleCount);
}
private ImageUtils() {
}
}
@@ -0,0 +1,57 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.argb8888.*;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngMap;
import org.jackhuang.hmcl.ui.image.apng.map.PngMapReader;
import org.jackhuang.hmcl.ui.image.apng.reader.DefaultPngChunkReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainer;
import org.jackhuang.hmcl.ui.image.apng.util.PngContainerBuilder;
import java.io.InputStream;
/**
* Convenient one liners for loading PNG images.
*/
public class Png {
/**
* Read the provided stream and produce a PngMap of the data.
*
* @param is stream to read from
* @param sourceName optional name, mainly for debugging.
* @return PngMap of the data.
* @throws PngException
*/
public static PngMap readMap(InputStream is, String sourceName) throws PngException {
return PngReadHelper.read(is, new PngMapReader(sourceName));
}
public static PngContainer readContainer(InputStream is) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(new PngContainerBuilder()));
}
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
return PngReadHelper.read(is, reader);
}
public static <ResultT> ResultT read(InputStream is, PngChunkProcessor<ResultT> processor) throws PngException {
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888Bitmap readArgb8888Bitmap(InputStream is) throws PngException {
Argb8888Processor<Argb8888Bitmap> processor = new Argb8888Processor<>(new DefaultImageArgb8888Director());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
public static Argb8888BitmapSequence readArgb8888BitmapSequence(InputStream is) throws PngException {
Argb8888Processor<Argb8888BitmapSequence> processor = new Argb8888Processor<>(new Argb8888BitmapSequenceDirector());
return PngReadHelper.read(is, new DefaultPngChunkReader<>(processor));
}
}
@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* A PNG file has an animation type - most commonly, not animated.
* <p>
* This is primarily relevant to tracking whether the default image in a PNG
* is to be
* a) processed as normal (e.g. in a standard or "non-animated" PNG file);
* b) processed and used as the first frame in an animated PNG; or
* c) not processed and discarded (e.g. in an animated PNG which does not use the first frame).
*/
public enum PngAnimationType {
NOT_ANIMATED,
ANIMATED_KEEP_DEFAULT_IMAGE,
ANIMATED_DISCARD_DEFAULT_IMAGE;
}
@@ -0,0 +1,123 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* One four-letter (32-byte) code identifying the type of a PNG chunk.
* <p>
* Common chunk codes are defined as constants that can be used in a switch statement.
* Users can add their own chunk codes separately.
*/
public class PngChunkCode {
public static final PngChunkCode IHDR = new PngChunkCode(PngConstants.IHDR_VALUE, "IHDR");
public static final PngChunkCode PLTE = new PngChunkCode(PngConstants.PLTE_VALUE, "PLTE");
public static final PngChunkCode IDAT = new PngChunkCode(PngConstants.IDAT_VALUE, "IDAT");
public static final PngChunkCode IEND = new PngChunkCode(PngConstants.IEND_VALUE, "IEND");
public static final PngChunkCode gAMA = new PngChunkCode(PngConstants.gAMA_VALUE, "gAMA");
public static final PngChunkCode bKGD = new PngChunkCode(PngConstants.bKGD_VALUE, "bKGD");
public static final PngChunkCode tRNS = new PngChunkCode(PngConstants.tRNS_VALUE, "tRNS");
public static final PngChunkCode acTL = new PngChunkCode(PngConstants.acTL_VALUE, "acTL");
public static final PngChunkCode fcTL = new PngChunkCode(PngConstants.fcTL_VALUE, "fcTL");
public static final PngChunkCode fdAT = new PngChunkCode(PngConstants.fdAT_VALUE, "fdAT");
public final int numeric;
public final String letters;
PngChunkCode(int numeric, String letters) {
this.numeric = numeric;
this.letters = letters;
}
/**
* Find out if the chunk is "critical" as defined by http://www.w3.org/TR/PNG/#5Chunk-naming-conventions
*
* <pre>
* cHNk <-- 32 bit chunk type represented in text form
* ||||
* |||+- Safe-to-copy bit is 1 (lower case letter; bit 5 is 1)
* ||+-- Reserved bit is 0 (upper case letter; bit 5 is 0)
* |+--- Private bit is 0 (upper case letter; bit 5 is 0)
* +---- Ancillary bit is 1 (lower case letter; bit 5 is 1)
* </pre>
*
* @return true if this chunk is considered critical according to the PNG spec.
*/
public boolean isCritical() {
// Ancillary bit: bit 5 o first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) == 0;
}
public boolean isAncillary() {
// Ancillary bit: bit 5 of first byte
return (numeric & PngConstants.BIT_CHUNK_IS_ANCILLARY) > 0;
}
public boolean isPrivate() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) > 0;
}
public boolean isPublic() {
return (numeric & PngConstants.BIT_CHUNK_IS_PRIVATE) == 0;
}
public boolean isReserved() {
return (numeric & PngConstants.BIT_CHUNK_IS_RESERVED) > 0;
}
public boolean isSafeToCopy() {
return (numeric & PngConstants.BIT_CHUNK_IS_SAFE_TO_COPY) > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngChunkCode that = (PngChunkCode) o;
if (numeric != that.numeric) return false;
return !(letters != null ? !letters.equals(that.letters) : that.letters != null);
}
@Override
public int hashCode() {
int result = numeric;
result = 31 * result + (letters != null ? letters.hashCode() : 0);
return result;
}
@Override
public String toString() {
return letters + "(" + numeric + ")";
}
public static PngChunkCode from(int code) {
switch (code) {
case PngConstants.IHDR_VALUE:
return IHDR;
case PngConstants.gAMA_VALUE:
return gAMA;
case PngConstants.bKGD_VALUE:
return bKGD;
case PngConstants.tRNS_VALUE:
return tRNS;
case PngConstants.IDAT_VALUE:
return IDAT;
case PngConstants.IEND_VALUE:
return IEND;
case PngConstants.acTL_VALUE:
return acTL;
case PngConstants.fdAT_VALUE:
return fdAT;
default:
byte[] s = new byte[4];
s[0] = (byte) ((code & 0xff000000) >> 24);
s[1] = (byte) ((code & 0x00ff0000) >> 16);
s[2] = (byte) ((code & 0x0000ff00) >> 8);
s[3] = (byte) ((code & 0x000000ff));
return new PngChunkCode(code, new String(s));
}
}
}
@@ -0,0 +1,61 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* PNG images support 5 colour types as defined at http://www.w3.org/TR/PNG/#11IHDR
*/
public enum PngColourType {
PNG_GREYSCALE(0, 1, "1, 2, 4, 8, 16", "Greyscale", "Each pixel is a greyscale sample"),
PNG_TRUECOLOUR(2, 3, "8, 16", "Truecolour", "Each pixel is an R,G,B triple"),
PNG_INDEXED_COLOUR(3, 1, "1, 2, 4, 8", "Indexed-colour", "Each pixel is a palette index; a PLTE chunk shall appear."),
PNG_GREYSCALE_WITH_ALPHA(4, 2, "4, 8, 16", "Greyscale with alpha", "Each pixel is a greyscale sample followed by an alpha sample."),
PNG_TRUECOLOUR_WITH_ALPHA(6, 4, "8, 16", "Truecolour with alpha", "Each pixel is an R,G,B triple followed by an alpha sample.");
public final int code;
public final int componentsPerPixel;
public final String allowedBitDepths;
public final String name;
public final String descriptino;
PngColourType(int code, int componentsPerPixel, String allowedBitDepths, String name, String descriptino) {
this.code = code;
this.componentsPerPixel = componentsPerPixel;
this.allowedBitDepths = allowedBitDepths;
this.name = name;
this.descriptino = descriptino;
}
public boolean isIndexed() {
return (code & 0x01) > 0;
}
public boolean hasAlpha() {
return (code & 0x04) > 0;
}
public boolean supportsSubByteDepth() {
return code == 0 || code == 3;
}
public static PngColourType fromByte(byte b) throws PngException {
switch (b) {
case 0:
return PNG_GREYSCALE;
case 2:
return PNG_TRUECOLOUR;
case 3:
return PNG_INDEXED_COLOUR;
case 4:
return PNG_GREYSCALE_WITH_ALPHA;
case 6:
return PNG_TRUECOLOUR_WITH_ALPHA;
default:
throw new PngIntegrityException(String.format("Valid PNG colour types are 0, 2, 3, 4, 6. Type '%d' is invalid", b));
}
}
}
@@ -0,0 +1,120 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
public class PngConstants {
public static final int LENGTH_SIGNATURE = 8;
public static final int LENGTH_fcTL_CHUNK = 4 + 4 + 4 + 4 + 4 + 2 + 2 + 1 + 1;
public static final int LENGTH_acTL_CHUNK = 4 + 4;
public static final byte[] BYTES_SIGNATURE = new byte[]{
(byte) 0x89, 'P', 'N', 'G', (byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A
};
public static final int IHDR_VALUE = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R'; // 1229472850;
public static final int PLTE_VALUE = 'P' << 24 | 'L' << 16 | 'T' << 8 | 'E';
public static final int IDAT_VALUE = 'I' << 24 | 'D' << 16 | 'A' << 8 | 'T'; // 1229209940;
public static final int IEND_VALUE = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D'; // 1229278788;
public static final int gAMA_VALUE = 'g' << 24 | 'A' << 16 | 'M' << 8 | 'A'; // 1732332865;
public static final int bKGD_VALUE = 'b' << 24 | 'K' << 16 | 'G' << 8 | 'D'; // 1649100612;
public static final int tRNS_VALUE = 't' << 24 | 'R' << 16 | 'N' << 8 | 'S';
public static final int acTL_VALUE = 'a' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fcTL_VALUE = 'f' << 24 | 'c' << 16 | 'T' << 8 | 'L';
public static final int fdAT_VALUE = 'f' << 24 | 'd' << 16 | 'A' << 8 | 'T';
public static final int ERROR_NOT_PNG = 1;
public static final int ERROR_EOF = 2;
public static final int ERROR_EOF_EXPECTED = 3;
public static final int ERROR_UNKNOWN_IO_FAILURE = 4;
public static final int ERROR_FEATURE_NOT_SUPPORTED = 5;
public static final int ERROR_INTEGRITY = 6;
public static final int ONE_K = 1 << 10;
public static final int DEFAULT_TARGET_BUFFER_SIZE = 32 * ONE_K;
public static final byte[] GREY_PALETTE_16 = new byte[]{
(byte) 0x00, // 0
(byte) 0x11, // 1
(byte) 0x22, // 2
(byte) 0x33, // 3
(byte) 0x44, // 4
(byte) 0x55, // 5
(byte) 0x66, // 6
(byte) 0x77, // 7
(byte) 0x88, // 8
(byte) 0x99, // 9
(byte) 0xaa, // 10
(byte) 0xbb, // 11
(byte) 0xcc, // 12
(byte) 0xdd, // 13
(byte) 0xee, // 14
(byte) 0xff, // 15
};
// public static final byte[] MASKS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//
// public static final byte[] MASKS_2 = new byte[] {
// (byte)0x03, // bit 0-1
// (byte)0x0C, // bit 2-3
// (byte)0x30, // bit 4-5
// (byte)0xC0, // bit 6-7
// };
//
// public static final byte[] MASKS_4 = new byte[] {
// (byte)0x0F, // bit 0-3
// (byte)0xF0, // bit 4-7
// };
public static final byte[] SHIFTS_1 = new byte[]{
(byte) 0, // bit 0
(byte) 1, // bit 1
(byte) 2, // bit 2
(byte) 3, // bit 3
(byte) 4, // bit 4
(byte) 5, // bit 5
(byte) 6, // bit 6
(byte) 7, // bit 7
};
public static final byte[] SHIFTS_2 = new byte[]{
(byte) 0, // bit 0-1
(byte) 2, // bit 2-3
(byte) 4, // bit 4-5
(byte) 6 // bit 6-7
};
public static final byte[] SHIFTS_4 = new byte[]{
(byte) 0x00, // bit 0-3
(byte) 0x04, // bit 4-7
};
public static final int BIT_CHUNK_IS_ANCILLARY = 0x20000000;
public static final int BIT_CHUNK_IS_PRIVATE = 0x00200000;
public static final int BIT_CHUNK_IS_RESERVED = 0x00002000;
public static final int BIT_CHUNK_IS_SAFE_TO_COPY = 0x00000020;
// public static final byte[] SHIFTS_1 = new byte[] {
// (byte)0x01, // bit 0
// (byte)0x02, // bit 1
// (byte)0x04, // bit 2
// (byte)0x08, // bit 3
// (byte)0x10, // bit 4
// (byte)0x20, // bit 5
// (byte)0x40, // bit 6
// (byte)0x80, // bit 7
// };
//public static final PngChunkCodes Foo = new PngChunkCodes(123, "Foo");
}
@@ -0,0 +1,125 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
/**
* Each "undo*" function reverses the effect of a filter on a given single scanline.
*/
public class PngFilter {
public static void undoSubFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// int ai = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
//
// final int x = bytes[i];
// final int a = bytes[pixelStart + ai];
int ai = pixelStart;
for (int i = pixelStart + filterUnit; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[ai];
//bytes[rowPosition] = (byte)((bytes[rowPosition] + left) & 0xff); // TODO & 0xff
bytes[i] = (byte) ((x + a) & 0xff);
ai++;
}
}
public static void undoUpFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
// for (int i=1; i<bytesPerLine; i++) {
// rowPosition++; // before the first op to skip filter byte
// bytes[rowPosition] = (byte)((bytes[rowPosition] + previousRow[i]) & 0xff);
// }
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte) ((x + b) & 0xff);
bi++;
}
}
public static void undoAverageFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
int ai = pixelStart - filterUnit;
int bi = 0;
for (int i = pixelStart; i < pixelEnd; i++) {
final int x = bytes[i];
final int a = (ai < pixelStart) ? 0 : (0xff & bytes[ai]);
final int b = (0xff & previousRow[bi]);
// int ai = pixelStart;
// int bi = 0;
// for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
// final int x = bytes[i];
// final int a = bytes[ai];
// final int b = previousRow[bi];
//bytes[i] = (byte)((x+((a+b)>>1))&0xff);
//int z = x+(a+b)/2;
int z = x + ((a + b) / 2);
bytes[i] = (byte) (0xff & z);
ai++;
bi++;
}
/*
int bi = 0;
for (int i=pixelStart; i<pixelStart+filterUnit; i++) {
final int x = bytes[i];
final int b = previousRow[bi];
bytes[i] = (byte)((x + (b >> 1)) & 0xff);
bi++;
}
for (int i=pixelStart+filterUnit; i<pixelEnd; i++) {
final int x = bytes[i];
final int a = bytes[pixelStart+bi-filterUnit];
final int b = previousRow[bi];
bytes[i] = (byte)((x + ((a+b) >> 1)) & 0xff);
bi++;
}*/
}
/**
* See http://www.libpng.org/pub/png/spec/1.2/PNG-Filters.html
*
*/
public static void undoPaethFilter(byte[] bytes, int pixelStart, int pixelEnd, int filterUnit, byte[] previousRow) {
//int scratch;
//int previousLeft = 0;
//int left = 0;
int ai = pixelStart - filterUnit;
int bi = 0;
int ci = -filterUnit;
//for (int i=0; i<bytesPerLine-1; i++) {
//for (int i=filterUnit; i<bytesPerLine-1; i++) {
for (int i = pixelStart; i < pixelEnd; i++) {
final int a, b, c, x;
x = bytes[i];
if (ai < pixelStart) {
a = c = 0;
} else {
a = 0xff & bytes[ai];
c = 0xff & previousRow[ci];
}
b = 0xff & previousRow[bi];
final int p = a + b - c;
final int pa = p >= a ? p - a : a - p;
final int pb = p >= b ? p - b : b - p;
final int pc = p >= c ? p - c : c - p;
final int predicted = (pa <= pb && pa <= pc) ? a
: (pb <= pc) ? b
: c;
bytes[i] = (byte) ((x + predicted) & 0xff);
ai++;
bi++;
ci++;
}
}
}
@@ -0,0 +1,166 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngScanlineProcessor;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/**
* A PngScanlineBuffer allows up-front once-off memory allocation that can be used
* across an entire PNG (no matter how many frames) to accumulate and process image data.
*/
public class PngScanlineBuffer {
protected final byte[] bytes;
protected final int filterUnit;
protected byte[] previousLine;
protected int readPosition;
protected int writePosition;
//protected int numBytesPerLine;
public PngScanlineBuffer(int size, int maxBytesPerLine, int filterUnit) {
this.bytes = new byte[size];
this.previousLine = new byte[maxBytesPerLine];
//this.scratchLine = new byte[maxBytesPerLine];
//this.numBytesPerLine = numBytesPerLine;
this.filterUnit = filterUnit;
reset();
}
public byte[] getBytes() {
return bytes;
}
public int getReadPosition() {
return readPosition;
}
public int getWritePosition() {
return writePosition;
}
public boolean canRead(int minNumBytes) {
return availableRead() >= minNumBytes;
}
public int availableRead() {
return writePosition - readPosition;
}
public int availableWrite() {
return bytes.length - writePosition;
}
public void skip(int numBytes) {
if (numBytes < 0 || numBytes > availableRead()) {
throw new IllegalArgumentException(String.format("Skip bytes must be 0 > n >= %d: %d", availableRead(), numBytes));
}
readPosition += numBytes;
}
public void reset() {
this.readPosition = 0;
this.writePosition = 0;
Arrays.fill(previousLine, (byte) 0);
}
public void shift() {
// It is difficult to say whether there is a more efficient approach - e.g. a for loop -
// to shifting the bytes to the start of the array. For now, I consider System.arraycopy
// to be "good enough".
int len = availableRead();
System.arraycopy(bytes, readPosition, bytes, 0, len);
writePosition = len;
readPosition = 0;
}
/**
* Read the decompressed data into the buffer.
*
* @param is an InputStream yielding decompressed data.
* @return count of how many bytes read, or -1 if EOF hit.
* @throws IOException
*/
public int read(InputStream is) throws IOException {
int r = is.read(bytes, writePosition, availableWrite());
if (r > 0) {
writePosition += r;
}
return r;
}
public boolean decompress(InputStream inputStream, PngScanlineProcessor processor) throws IOException, PngException {
int bytesPerLine = processor.getBytesPerLine();
try (InputStream iis = processor.makeInflaterInputStream(inputStream)) {
while (true) {
// Ally says
// I LOVE Y.O.U.
int len = read(iis);
if (len <= 0) {
return processor.isFinished();
}
// Or could do: len -= bytesPerLine until len < bytesPerLine
while (canRead(bytesPerLine)) {
undoFilterScanline(bytesPerLine);
processor.processScanline(bytes, readPosition + 1);//(), getReadPosition());
skip(bytesPerLine);
}
shift();
}
}
}
public void undoFilterScanline(int bytesPerLine) {
int filterCode = bytes[readPosition];
switch (filterCode) {
case 0:
// NOP
break;
case 1:
PngFilter.undoSubFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 2:
PngFilter.undoUpFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 3:
PngFilter.undoAverageFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
case 4:
PngFilter.undoPaethFilter(bytes, readPosition + 1, readPosition + bytesPerLine, filterUnit, previousLine);
break;
default: // I toyed with an exception here. But why? Just treat as if no filter.
//throw new IllegalArgumentException(String.format("Filter type %d invalid; valid is 0..4", filterCode);
break;
}
// when un-filtering scanlines, the previous row is a copy of the *un-filtered* row (not the original).
System.arraycopy(bytes, readPosition + 1, previousLine, 0, bytesPerLine - 1); // keep a copy of the unmodified row
}
public static PngScanlineBuffer from(PngHeader header) {
return from(header, PngConstants.DEFAULT_TARGET_BUFFER_SIZE);
}
public static PngScanlineBuffer from(PngHeader header, int minBufferSize) {
return new PngScanlineBuffer(sizeFrom(header, minBufferSize), header.bytesPerRow, header.filterOffset);
}
public static int sizeFrom(PngHeader header, int minBufferSize) {
int bytesPerRow = header.bytesPerRow;
int bytesFullBitmap = bytesPerRow * header.height;
if (bytesFullBitmap < minBufferSize) {
return bytesFullBitmap;
}
int numRows = Math.max(1, minBufferSize / bytesPerRow);
return numRows * bytesPerRow;
}
}
@@ -0,0 +1,48 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
/**
* A bitmap where each pixel is represented by 8-bits for alpha, red, green and blue
* respectively, and each pixel stored in a single 32-bit integer. This is expressly designed
* to be compatible with the input array used to build Android Bitmap objects, though of course
* its use is not limited to that.
*/
public final class Argb8888Bitmap {
public final int[] array;
public final int width;
public final int height;
public Argb8888Bitmap(int width, int height) {
this(new int[width * height], width, height);
}
public Argb8888Bitmap(int[] array, int width, int height) {//}, int x, int y) {
this.array = array;
this.width = width;
this.height = height;
// this.x = y;
}
/**
* Create a new Bitmap that shares the byte array of this bitmap.
* This is useful when rendering a number of animation frames from an APNG file
* and wanting to minimise memory allocation, while still wanting a "new" bitmap
* to work on.
*
* @param width in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @param height in pixels of "new" bitmap (actual pixel array is the exact array
* from the original bitmap).
* @return new bitmap object sharing the same data array.
*/
public Argb8888Bitmap makeView(int width, int height) {//}, int x, int y) {
if ((width * height) > (this.width * this.height)) {
throw new IllegalArgumentException(String.format(
"Requested width and height (%d x %d) exceeds maximum pixels allowed by host bitmap (%d x %d",
width, height, this.width, this.height));
}
return new Argb8888Bitmap(array, width, height);
}
}
@@ -0,0 +1,72 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import java.util.ArrayList;
import java.util.List;
/**
* An Argb8888BitmapSequence object represents all bitmaps in a single PNG file,
* whether it has one and only one default image, any number of frames in an animation
* or a default image and a separate set of animation frames.
* <p>
* Note that instances of this class will hold an individual bitmap for every frame
* and does <em>not</em> do composition of images in any way. Composition is done in
* the japng_android library using an Android Canvas. This class is not used for that,
* as the intermediate Argb8888Bitmap objects are not required, only one bitmap and
* the output buffer is required during composition.
*/
public final class Argb8888BitmapSequence {
public final PngHeader header;
public final Argb8888Bitmap defaultImage;
private boolean defaultImageIsSet = false;
private PngAnimationControl animationControl;
List<Frame> animationFrames;
public Argb8888BitmapSequence(PngHeader header) {
this.header = header;
this.defaultImage = new Argb8888Bitmap(header.width, header.height);
}
public void receiveAnimationControl(PngAnimationControl animationControl) {
this.animationControl = animationControl;
this.animationFrames = new ArrayList<>(animationControl.numFrames);
}
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
defaultImageIsSet = true;
}
public boolean hasDefaultImage() {
return defaultImageIsSet;
}
public boolean isAnimated() {
return null != animationControl && animationControl.numFrames > 0;
}
public PngAnimationControl getAnimationControl() {
return animationControl;
}
public List<Frame> getAnimationFrames() {
return animationFrames;
}
public static final class Frame {
public final PngFrameControl control;
public final Argb8888Bitmap bitmap;
public Frame(PngFrameControl control, Argb8888Bitmap bitmap) {
this.control = control;
this.bitmap = bitmap;
}
}
}
@@ -0,0 +1,82 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888BitmapSequenceDirector instances direct an Argb8888Processor to build all frames
* of an animation into an Argb8888BitmapSequence object.
*/
public class Argb8888BitmapSequenceDirector extends BasicArgb8888Director<Argb8888BitmapSequence> {
Argb8888BitmapSequence bitmapSequence = null;
PngFrameControl currentFrame = null;
private PngHeader header;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
this.header = header;
this.bitmapSequence = new Argb8888BitmapSequence(header);
//this.header = header;
//defaultImage = new Argb8888Bitmap(header.width, header.height);
this.scanlineProcessor = Argb8888Processors.from(header, buffer, this.bitmapSequence.defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return true;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
this.bitmapSequence.receiveDefaultImage(bitmap);
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
this.bitmapSequence.receiveAnimationControl(control);
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
//throw new IllegalStateException("TODO up to here");
//return null;
currentFrame = control;
//System.out.println("Frame: "+control);
return scanlineProcessor.cloneWithNewBitmap(header.adjustFor(control)); // TODO: is this going to be a problem?
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
if (null == currentFrame) {
throw new IllegalStateException("Received a frame image with no frame control in place");
}
if (null == bitmapSequence.animationFrames) {
throw new IllegalStateException("Received a frame image without animation control (or frame list?) in place");
}
bitmapSequence.animationFrames.add(new Argb8888BitmapSequence.Frame(currentFrame, bitmap));
currentFrame = null;
}
@Override
public Argb8888BitmapSequence getResult() {
return bitmapSequence;
}
}
@@ -0,0 +1,47 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* Argb8888Director implementations "direct" a given Argb8888Processor how to
* control the output. This allows the Argb8888Processor to transform pixels into
* ARGB8888 format while allowing for radically different final output objects,
* e.g. a single bitmap, a sequence of bitamps, an Android View or Drawable, etc.
* <p>
* TODO: not sure if this will stay in this form. Needs refinement.
*/
public interface Argb8888Director<ResultT> {
void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException;
void receivePalette(Argb8888Palette palette);
void processTransparentPalette(byte[] bytes, int position, int length) throws PngException;
void processTransparentGreyscale(byte k1, byte k0) throws PngException;
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException;
boolean wantDefaultImage();
boolean wantAnimationFrames();
Argb8888ScanlineProcessor beforeDefaultImage();
void receiveDefaultImage(Argb8888Bitmap bitmap);
void receiveAnimationControl(PngAnimationControl control);
Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control);
void receiveFrameImage(Argb8888Bitmap bitmap);
ResultT getResult();
}
@@ -0,0 +1,117 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A palette implementation representing colours in ARGB8888 format as 32-bit integers.
* <p>
* Note that the transparency of individual entries in the palette is modified in-place
* when the trNS chunk is processed.
*
* @see BasicArgb8888Director
*/
public class Argb8888Palette {
protected static Argb8888Palette monochromePalette = null;
protected static Argb8888Palette greyPalette2 = null;
protected static Argb8888Palette greyPalette4 = null;
protected static Argb8888Palette greyPalette8 = null;
/**
* The colour array is public and mutable by design.
*
* @see this.get()
*/
public final int[] argbArray;
/**
* Construct a new palette using the specific array passed as the argument as-is (not copied).
*
* @param argbArray array of colours to use as-is (it is not copied).
*/
public Argb8888Palette(int[] argbArray) {
this.argbArray = argbArray;
}
/**
* Retrieve a colour using a method. Users can also just use the argbArray directly.
*
* @param index of colour to retrieve
* @return integer representing the colour at that index.
*/
public int get(int index) {
return this.argbArray[index];
}
/**
* @return the number of colours in this palette, which is the same thing as the size of the array.
*/
public int size() {
return argbArray.length;
}
/**
* Build a new palette by reading the byte array (from the offset position and for length bytes)
* as provided in a PLTE chunk.
*
* @param bytes array to read data from
* @param position offset into bytes array to begin reading from
* @param length number of bytes to read from bytes array
* @return new palette formed by reading the bytes array.
* @throws PngException
*/
public static Argb8888Palette fromPaletteBytes(byte[] bytes, int position, int length) throws PngException {
int numColours = length / 3; // guaranteed to be divisible by 3
int[] argbArray = new int[numColours];
int srcIndex = position;
int alpha = 0xff << 24;
for (int destIndex = 0; destIndex < numColours; destIndex++) {
final int r = bytes[srcIndex++] & 0xff;
final int g = bytes[srcIndex++] & 0xff;
final int b = bytes[srcIndex++] & 0xff;
argbArray[destIndex] = alpha | r << 16 | g << 8 | b;
}
return new Argb8888Palette(argbArray);
}
public static Argb8888Palette forGreyscale(int numEntries, int step) {
int[] array = new int[numEntries];
int alpha = 0xff << 24;
int grey = 0;
for (int i = 0; i < numEntries; i++) {
array[i] = alpha | grey << 16 | grey << 8 | grey;
grey = (grey + step) & 0xff;
}
return new Argb8888Palette(array);
}
public static Argb8888Palette forGreyscale(int bitDepth) throws PngException {
switch (bitDepth) {
case 1:
if (null == monochromePalette) {
monochromePalette = forGreyscale(2, 0xff); // Worth it or not really?
}
return monochromePalette;
case 2:
if (null == greyPalette2) {
greyPalette2 = forGreyscale(4, 0x55);
}
return greyPalette2;
case 4:
if (null == greyPalette4) {
greyPalette4 = forGreyscale(16, 0x11);
}
return greyPalette4;
case 8: // TODO: need??
if (null == greyPalette8) {
greyPalette8 = forGreyscale(256, 0x01);
}
return greyPalette8;
default:
throw new PngIntegrityException(String.format("Valid greyscale bit depths are 1, 2, 4, 8, not %d", bitDepth));
}
}
}
@@ -0,0 +1,173 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import org.jackhuang.hmcl.ui.image.apng.reader.PngChunkProcessor;
import java.io.IOException;
import java.io.InputStream;
/**
* Concrete implementation of a chunk processor designed to process pixels into
* 32-bit ARGB8888 format.
*/
public class Argb8888Processor<ResultT> implements PngChunkProcessor<ResultT> { //PngChunkProcessor<Argb8888Bitmap> {
protected PngHeader header = null;
protected PngScanlineBuffer scanlineReader = null;
protected Argb8888Director<ResultT> builder = null;
protected Argb8888ScanlineProcessor scanlineProcessor = null;
public Argb8888Processor(Argb8888Director<ResultT> builder) {
this.builder = builder;
}
@Override
public void processHeader(PngHeader header) throws PngException {
// super.processHeader(header);
//// if (header.bitDepth != 1 || header.colourType != PngColourType.PNG_GREYSCALE) {
//// throw new PngFeatureException("ARGB888 only supports 1-bit greyscale");
//// }
// if (header.isGreyscale()) {
// palette = Argb8888Palette.forGreyscale(header.bitDepth);
// }
// scanlineReader = PngScanlineBuffer.from(header);
this.header = header;
this.scanlineReader = PngScanlineBuffer.from(header);
this.builder.receiveHeader(this.header, this.scanlineReader);
}
@Override
public void processGamma(PngGamma gamma) throws PngException {
// No gamma processing is done at the moment.
}
@Override
public void processPalette(byte[] bytes, int position, int length) throws PngException {
//palette = Argb8888Palette.fromPaletteBytes(bytes, position, length);
//scanlineProcessor = Argb8888Processors.fromPalette(this.header, palette);
builder.receivePalette(Argb8888Palette.fromPaletteBytes(bytes, position, length));
}
@Override
public void processTransparency(byte[] bytes, int position, int length) throws PngException {
switch (header.colourType) {
case PNG_GREYSCALE: // colour type 0
// grey sample value (2 bytes)
if (length != 2) {
throw new PngIntegrityException(String.format("tRNS chunk for greyscale image must be exactly length=2, not %d", length));
}
builder.processTransparentGreyscale(bytes[0], bytes[1]);
break;
case PNG_TRUECOLOUR: // colour type 2
// red, green, blue samples, EACH with two bytes (16-bits)
builder.processTransparentTruecolour(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]);
break;
case PNG_INDEXED_COLOUR: // colour type 3
// This is a sequence of one-byte alpha values to apply to each palette entry starting at zero.
// The number of entries may be less than the size of the palette, but not more.
builder.processTransparentPalette(bytes, position, length);
break;
case PNG_GREYSCALE_WITH_ALPHA:
case PNG_TRUECOLOUR_WITH_ALPHA:
default:
throw new PngIntegrityException("Illegal to have tRNS chunk with image type " + header.colourType.name);
}
}
/**
* The only supported animation type is not "NOT_ANIMATED".
*/
// @Override
// public PngAnimationType chooseApngImageType(PngAnimationType type, PngFrameControl currentFrame) throws PngException {
// scanlineProcessor = Argb8888ScanlineProcessor.from(header, scanlineReader, currentFrame);
// return PngAnimationType.NOT_ANIMATED;
// }
@Override
public void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
if (!builder.wantDefaultImage()) {
inputStream.skip(length); // important!
return;
}
if (null == scanlineProcessor) { // TODO: is that a good enough metric? Or could be numIdat==0?
scanlineProcessor = builder.beforeDefaultImage();
if (null == scanlineProcessor) throw new IllegalStateException("Builder must create scanline processor");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveDefaultImage(scanlineProcessor.getBitmap());
scanlineProcessor = null;
scanlineReader.reset();
}
}
@Override
public void processAnimationControl(PngAnimationControl animationControl) throws PngException {
if (builder.wantAnimationFrames()) {
builder.receiveAnimationControl(animationControl);
}
}
@Override
public void processFrameControl(PngFrameControl frameControl) throws PngException {
if (!builder.wantAnimationFrames()) {
return;
}
if (null == scanlineProcessor) {
// If here, the data in this frame image has not started
scanlineProcessor = builder.receiveFrameControl(frameControl);
if (null == scanlineProcessor)
throw new IllegalStateException("Builder must create scanline processor for frame");
} else {
throw new IllegalStateException("received animation frame control but image data was in progress");
}
}
@Override
public void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException {
//throw new PngFeatureException("PngArgb8888Processor does not support animation frames");
if (!builder.wantAnimationFrames()) {
inputStream.skip(length);
return;
}
if (null == scanlineProcessor) {
throw new IllegalStateException("received animation frame image data before frame control or without processor in place");
}
if (scanlineReader.decompress(inputStream, scanlineProcessor)) {
// If here, the image is fully decompressed.
builder.receiveFrameImage(scanlineProcessor.getBitmap());
scanlineReader.reset();
scanlineProcessor = null;
}
}
@Override
public void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException {
// NOP
}
@Override
public ResultT getResult() {
//return scanlineProcessor.getBitmap();
return builder.getResult();
}
}
@@ -0,0 +1,462 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* A series of scanline processor implementations for different input pixel formats,
* each able to transform the input into ARGB8888 output pixels.
*/
public class Argb8888Processors {
/**
* Determine from the header the concrete Argb8888Processor implementation the
* caller needs to transform the incoming pixels into the ARGB8888 pixel format.
*
* @param header of the image being loaded
* @param scanlineReader the scanline buffer being used
* @param bitmap the destination bitmap
* @return a concrete Argb8888Processor to transform source pixels into the specific bitmap.
* @throws PngException if there is a feature not supported or some specification break with the file.
*/
public static Argb8888ScanlineProcessor from(PngHeader header, PngScanlineBuffer scanlineReader, Argb8888Bitmap bitmap) throws PngException {
int bytesPerScanline = header.bytesPerRow;
switch (header.colourType) {
case PNG_GREYSCALE:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1, Argb8888Palette.forGreyscale(1));
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2, Argb8888Palette.forGreyscale(2));
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4, Argb8888Palette.forGreyscale(4));
case 8:
return new Greyscale8(bytesPerScanline, bitmap);
case 16:
throw new PngFeatureException("Greyscale supports 1, 2, 4, 8 but not 16.");
default:
throw new PngIntegrityException(String.format("Invalid greyscale bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_GREYSCALE_WITH_ALPHA:
switch (header.bitDepth) {
case 4:
return new Greyscale4Alpha(bytesPerScanline, bitmap);
case 8:
return new Greyscale8Alpha(bytesPerScanline, bitmap);
case 16:
return new Greyscale16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid greyscale-with-alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_INDEXED_COLOUR:
switch (header.bitDepth) {
case 1:
return new IndexedColourBits(bytesPerScanline, bitmap, 7, 0x01, PngConstants.SHIFTS_1);
case 2:
return new IndexedColourBits(bytesPerScanline, bitmap, 3, 0x03, PngConstants.SHIFTS_2);
case 4:
return new IndexedColourBits(bytesPerScanline, bitmap, 1, 0x0F, PngConstants.SHIFTS_4);
case 8:
return new IndexedColour8(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid indexed colour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR:
switch (header.bitDepth) {
case 8:
return new Truecolour8(bytesPerScanline, bitmap);
case 16:
return new Truecolour16(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
case PNG_TRUECOLOUR_WITH_ALPHA:
switch (header.bitDepth) {
case 8:
return new Truecolour8Alpha(bytesPerScanline, bitmap);
case 16:
return new Truecolour16Alpha(bytesPerScanline, bitmap);
default:
throw new PngIntegrityException(String.format("Invalid truecolour with alpha bit-depth: %d", header.bitDepth)); // TODO: should be in header parse.
}
default:
throw new PngFeatureException("ARGB8888 doesn't support PNG mode " + header.colourType.name());
}
}
/**
* Transforms 1-, 2-, 4-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColourBits extends Argb8888ScanlineProcessor {
private int highBit;
private int mask;
private byte[] shifts;
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts) {
this(bytesPerScanline, bitmap, highBit, mask, shifts, null);
}
public IndexedColourBits(int bytesPerScanline, Argb8888Bitmap bitmap, int highBit, int mask, byte[] shifts, Argb8888Palette palette) {
super(bytesPerScanline, bitmap);
this.highBit = highBit;
this.mask = mask;
this.shifts = shifts;
this.palette = palette;
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
int lastWritePosition = writePosition + width;
int bit = highBit;
while (writePosition < lastWritePosition) {
//final int index=(srcBytes[srcPosition+x] & masks[bit]) >> shifts[bit];
//final int index = (srcBytes[srcPosition])
//final int v = srcBytes[srcPosition];
final int index = mask & (srcBytes[srcPosition] >> shifts[bit]);
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
if (bit == 0) {
srcPosition++;
bit = highBit;
} else {
bit--;
}
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColourBits(bytesPerRow, bitmap, highBit, mask, shifts, palette);
}
}
/**
* Special case implementation to transform 8-bit indexed colour source pixels to ARGB8888 pixels.
*/
public static class IndexedColour8 extends Argb8888ScanlineProcessor {
public IndexedColour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int index = 0xff & srcBytes[srcPosition++]; // TODO: need to use transparency and background chunks
int dest;
if (palette != null) {
dest = palette.argbArray[index];
} else {
dest = 0;
}
destArray[writePosition++] = dest;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new IndexedColour8(bytesPerRow, bitmap);
}
}
/**
* Transforms 4-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale4Alpha extends Argb8888ScanlineProcessor {
public Greyscale4Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int v = srcBytes[srcPosition++];
final int k = PngConstants.GREY_PALETTE_16[0x0f & (v >> 4)];
final int a = PngConstants.GREY_PALETTE_16[0x0f & v];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale4Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale source pixels to ARGB8888 pixels.
*/
public static class Greyscale8 extends Argb8888ScanlineProcessor {
boolean haveTransparent = false;
int transparentSample = 0;
public Greyscale8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int sample = 0xff & srcBytes[srcPosition++];
final int k = (haveTransparent && sample == transparentSample) ? transparentSample : sample;
final int c = alpha | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) {
// According to below, when image is less than 16-bits per pixel, use least significant byte
// http://www.w3.org/TR/PNG/#11transinfo
haveTransparent = true;
transparentSample = 0xff & k0; // NOT k1 according to http://www.w3.org/TR/PNG/#11transinfo
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8(bytesPerRow, bitmap);
}
}
/**
* Transforms 8-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale8Alpha extends Argb8888ScanlineProcessor {
public Greyscale8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms 16-bit greyscale with alpha source pixels to ARGB8888 pixels.
*/
public static class Greyscale16Alpha extends Argb8888ScanlineProcessor {
public Greyscale16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int k = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the grey
final int a = 0xff & srcBytes[srcPosition++];
srcPosition++; // skip the least-significant byte of the alpha
final int c = a << 24 | k << 16 | k << 8 | k;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Greyscale16Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8 extends Argb8888ScanlineProcessor {
public Truecolour8(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 8-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour8Alpha extends Argb8888ScanlineProcessor {
public Truecolour8Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition++];
final int g = 0xff & srcBytes[srcPosition++];
final int b = 0xff & srcBytes[srcPosition++];
final int a = 0xff & srcBytes[srcPosition++];
final int c = a << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour8Alpha(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour (RGB) 16-bit source pixels to ARGB8888 pixels.
*/
public static class Truecolour16 extends Argb8888ScanlineProcessor {
public Truecolour16(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int c = alpha | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16(bytesPerRow, bitmap);
}
}
/**
* Transforms true-colour with alpha (RGBA) 16-bit source pixels to ARGB8888 pixels.
* <p>
* Note that the simpler method of resampling the colour is done, namely discard the LSB.
*/
public static class Truecolour16Alpha extends Argb8888ScanlineProcessor {
public Truecolour16Alpha(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline, bitmap);
}
@Override
public void processScanline(byte[] srcBytes, int srcPosition) {
final int[] destArray = this.bitmap.array;
final int width = this.bitmap.width;
//final int alpha = 0xff000000; // No alpha in the image means every pixel must be fully opaque
int writePosition = this.y * width;
//srcPosition++; // skip filter byte
for (int x = 0; x < width; x++) {
final int r = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int g = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto
final int b = 0xff & srcBytes[srcPosition];
srcPosition += 2; // ditto again
final int alpha = 0xff & srcBytes[srcPosition];
srcPosition += 2; // skip the byte just read and the least significant byte of the next
final int c = alpha << 24 | r << 16 | g << 8 | b;
destArray[writePosition++] = c;
}
this.y++;
}
@Override
public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
return new Truecolour16Alpha(bytesPerRow, bitmap);
}
}
private Argb8888Processors() {
}
}
@@ -0,0 +1,84 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.reader.BasicScanlineProcessor;
/**
* Base implementation that transforms scanlines of source pixels into scanlines of
* ARGB8888 (32-bit integer) pixels in a destination Argb8888Bitmap object.
* <p>
* Note: I wonder if a better name is ScanlineConverter or PixelTransformer.
*
* @see Argb8888Processors
*/
public abstract class Argb8888ScanlineProcessor extends BasicScanlineProcessor {
protected Argb8888Bitmap bitmap;
protected Argb8888Palette palette;
protected int y = 0;
// public Argb8888ScanlineProcessor(PngHeader header) {
// this(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
// }
public Argb8888ScanlineProcessor(int bytesPerScanline, Argb8888Bitmap bitmap) {
super(bytesPerScanline);
this.bitmap = bitmap;
}
// public Argb8888ScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
// super(header, scanlineReader, currentFrame);
// bitmap = new Argb8888Bitmap(this.header.width, this.header.height);
// }
public Argb8888Bitmap getBitmap() {
return bitmap;
}
public Argb8888Palette getPalette() {
return palette;
}
public void setPalette(Argb8888Palette palette) {
this.palette = palette;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to a new bitmap.
*/
public Argb8888ScanlineProcessor cloneWithNewBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, new Argb8888Bitmap(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
/**
* When processing a sequence of frames a caller may want to write to a completely
* new bitmap for every frame or re-use the bytes in an existing bitmap.
*
* @param header of image to use as basis for calculating image size.
* @return processor that will write to the same bitmap as the current processor.
*/
public Argb8888ScanlineProcessor cloneWithSharedBitmap(PngHeader header) {
Argb8888ScanlineProcessor cloned = clone(header.bytesPerRow, bitmap.makeView(header.width, header.height));
if (this.palette != null) {
cloned.setPalette(new Argb8888Palette(palette.argbArray));
}
return cloned;
}
// public Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap) {
// throw new IllegalStateException("TODO: override");
// }
abstract Argb8888ScanlineProcessor clone(int bytesPerRow, Argb8888Bitmap bitmap);
}
@@ -0,0 +1,45 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
/**
* Common functionality for Argb8888Director implementations.
*/
public abstract class BasicArgb8888Director<ResultT> implements Argb8888Director<ResultT> {
protected Argb8888ScanlineProcessor scanlineProcessor;
@Override
public void receivePalette(Argb8888Palette palette) {
scanlineProcessor.setPalette(palette);
}
@Override
public void processTransparentPalette(byte[] bytes, int position, int length) throws PngException {
Argb8888Palette palette = scanlineProcessor.getPalette();
if (null == palette) {
throw new PngIntegrityException("Received tRNS data but no palette is in place");
}
if (length <= 0 || length > palette.size()) {
throw new PngIntegrityException(String.format("Received tRNS data length is invalid. Should be >1 && < %d but is %d", palette.size(), length));
}
for (int i = 0; i < length; i++) {
final int alpha = 0xff & bytes[position + i];
palette.argbArray[i] = alpha << 24 | palette.argbArray[i] & 0x00FFFFFF;
}
}
@Override
public void processTransparentGreyscale(byte k1, byte k0) throws PngException {
scanlineProcessor.processTransparentGreyscale(k1, k0);
}
@Override
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) throws PngException {
scanlineProcessor.processTransparentTruecolour(r1, r0, g1, g0, b1, b0);
}
}
@@ -0,0 +1,65 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.argb8888;
import org.jackhuang.hmcl.ui.image.apng.PngScanlineBuffer;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
/**
* This will build a single bitmap: the default image within the PNG file.
* Any animation data (or any other data) will not be processed.
*/
public class DefaultImageArgb8888Director extends BasicArgb8888Director<Argb8888Bitmap> {
protected Argb8888Bitmap defaultImage;
@Override
public void receiveHeader(PngHeader header, PngScanlineBuffer buffer) throws PngException {
defaultImage = new Argb8888Bitmap(header.width, header.height);
scanlineProcessor = Argb8888Processors.from(header, buffer, defaultImage);
}
@Override
public boolean wantDefaultImage() {
return true;
}
@Override
public boolean wantAnimationFrames() {
return false;
}
@Override
public Argb8888ScanlineProcessor beforeDefaultImage() {
return scanlineProcessor;
}
@Override
public void receiveDefaultImage(Argb8888Bitmap bitmap) {
}
@Override
public void receiveAnimationControl(PngAnimationControl control) {
}
@Override
public Argb8888ScanlineProcessor receiveFrameControl(PngFrameControl control) {
return null;
}
@Override
public void receiveFrameImage(Argb8888Bitmap bitmap) {
}
@Override
public Argb8888Bitmap getResult() {
return defaultImage;
}
}
@@ -0,0 +1,30 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
/**
* A PngAnimationControl object contains data parsed from the ``acTL``
* animation control chunk of an animated PNG file.
*/
public class PngAnimationControl {
public final int numFrames;
public final int numPlays;
public PngAnimationControl(int numFrames, int numPlays) {
this.numFrames = numFrames;
this.numPlays = numPlays;
}
public boolean loopForever() {
return 0 == numPlays;
}
@Override
public String toString() {
return "PngAnimationControl{" +
"numFrames=" + numFrames +
", numPlays=" + numPlays +
'}';
}
}
@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.util.ArrayList;
import java.util.List;
/**
* A PngFrameControl object contains data parsed from the ``fcTL`` chunk data
* in an animated PNG File.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
* <pre>
* 0 sequence_number (unsigned int) Sequence number of the animation chunk, starting from 0
* 4 width (unsigned int) Width of the following frame
* 8 height (unsigned int) Height of the following frame
* 12 x_offset (unsigned int) X position at which to render the following frame
* 16 y_offset (unsigned int) Y position at which to render the following frame
* 20 delay_num (unsigned short) Frame delay fraction numerator
* 22 delay_den (unsigned short) Frame delay fraction denominator
* 24 dispose_op (byte) Type of frame area disposal to be done after rendering this frame
* 25 blend_op (byte) Type of frame area rendering for this frame
* </pre>
* <p>
* Delay denominator: from spec, "if denominator is zero it should be treated as 100ths of second".
* <pre>
* dispose op:
* value
* 0 APNG_DISPOSE_OP_NONE
* 1 APNG_DISPOSE_OP_BACKGROUND
* 2 APNG_DISPOSE_OP_PREVIOUS
*
* blend op:
* value
* 0 APNG_BLEND_OP_SOURCE
* 1 APNG_BLEND_OP_OVER
* </pre>
*/
public class PngFrameControl {
public final int sequenceNumber;
public final int width;
public final int height;
public final int xOffset;
public final int yOffset;
public final short delayNumerator;
public final short delayDenominator;
public final byte disposeOp;
public final byte blendOp;
List<PngChunkMap> imageChunks = new ArrayList<>(1); // TODO: this may be removed
public PngFrameControl(int sequenceNumber, int width, int height, int xOffset, int yOffset, short delayNumerator, short delayDenominator, byte disposeOp, byte blendOp) {
this.sequenceNumber = sequenceNumber;
this.width = width;
this.height = height;
this.xOffset = xOffset;
this.yOffset = yOffset;
this.delayNumerator = delayNumerator;
this.delayDenominator = delayDenominator == 0 ? 100 : delayDenominator; // APNG spec says zero === 100.
this.disposeOp = disposeOp;
this.blendOp = blendOp;
}
/**
* @return number of milliseconds to show this frame for
*/
public int getDelayMilliseconds() {
if (delayDenominator == 1000) {
return delayNumerator;
} else {
// if denom is 100 then need to multiple by 10
float f = 1000 / delayDenominator; // 1000/100 -> 10
return (int) (delayNumerator * f);
}
}
// TODO: can this be removed?
public void appendImageData(PngChunkMap chunkMap) {
imageChunks.add(chunkMap);
}
// TODO: this may be removed
public List<PngChunkMap> getImageChunks() {
return imageChunks;
}
@Override
public String toString() {
return "PngFrameControl{" +
"sequenceNumber=" + sequenceNumber +
", width=" + width +
", height=" + height +
", xOffset=" + xOffset +
", yOffset=" + yOffset +
", delayNumerator=" + delayNumerator +
", delayDenominator=" + delayDenominator +
", disposeOp=" + disposeOp +
", blendOp=" + blendOp +
'}';
}
// mainly for ease in unit testing
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PngFrameControl that = (PngFrameControl) o;
if (sequenceNumber != that.sequenceNumber) return false;
if (width != that.width) return false;
if (height != that.height) return false;
if (xOffset != that.xOffset) return false;
if (yOffset != that.yOffset) return false;
if (delayNumerator != that.delayNumerator) return false;
if (delayDenominator != that.delayDenominator) return false;
if (disposeOp != that.disposeOp) return false;
return blendOp == that.blendOp;
}
@Override
public int hashCode() {
int result = sequenceNumber;
result = 31 * result + width;
result = 31 * result + height;
result = 31 * result + xOffset;
result = 31 * result + yOffset;
result = 31 * result + (int) delayNumerator;
result = 31 * result + (int) delayDenominator;
result = 31 * result + (int) disposeOp;
result = 31 * result + (int) blendOp;
return result;
}
}
@@ -0,0 +1,22 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import java.io.DataInputStream;
import java.io.IOException;
/**
* A PngGamma object represents data parsed from a ``gAMA`` chunk.
*/
public class PngGamma {
public final int imageGamma;
public PngGamma(int imageGamma) {
this.imageGamma = imageGamma;
}
public static PngGamma from(DataInputStream dis) throws IOException {
return new PngGamma(dis.readInt());
}
}
@@ -0,0 +1,224 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReadHelper;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngFeatureException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import java.io.DataInput;
import java.io.IOException;
/**
* Created by aellerton on 10/05/2015.
*/
public class PngHeader {
/**
* Number of pixels (columns) wide.
*/
public final int width;
/**
* Number of pixels (rows) high.
*/
public final int height;
/**
* The bitDepth is the number of bits for each <em>channel</em> of a given pixel.
* A better name might be "bitsPerPixelChannel" but the name "bitDepth" is used
* throughout the PNG specification.
* <p>
* A truecolour image with a bitDepth of 8 means that the red channel of a pixel
* has 8 bits (so 256 levels of red), green has 8 bits (256 levels of green), and
* blue has 8 bits (so 256 levels of green). That means the total bitsPerPixel
* for that bitmap will be 8+8+8 = 24.
* <p>
* A truecolour <em>with alpha</em> image with bitDepth of 8 will be the same
* except every alpha element of every pixel will have 8 bits (so 256 levels of
* alpha transparency), meaning that the total bitsPerPixel for that bitmap will
* be 8+8+8+8=32.
* <p>
* A truecolour with alpha image with <em>bitDepth of 16</em> means that each of
* red, green blue and alpha have 16-bits respectively, meaning that the total
* bitsPerPixel will be 16+16+16+16 = 64.
* <p>
* A greyscale image (no alpha) with bitDepth of 16 has only a grey channel for
* each pixel, so the bitsPerPixel will also be 16.
* <p>
* But a greyscale image <em>with alpha</em> with a bitDepth of 16 has a grey
* channel and an alpha channel, each with 16 bits so the bitsPerPixel will be
* 16+16=32.
* <p>
* As for palette-based images...
* <ul>
* <li>A monochrome image or image with 2 colour palette has bitDepth=1.</li>
* <li>An image with 4 colour palette has bitDepth=2.</li>
* <li>An image with 8 colour palette has bitDepth=3.</li>
* <li>An image with 16 colour palette has bitDepth=4.</li>
* <li>A greyscale image with 16 levels of gray <em>and an alpha channel</em>
* has bitDepth=4 and bitsPerPixel=8 because the gray and the alpha channel
* each have 4 bits.</li>
* </ul>
*
* @see #bitsPerPixel
*/
public final byte bitDepth;
/**
* Every PNG image must be exactly one of the standard types as defined by the
* PNG specification. Better names might have been "imageType" or "imageFormat"
* but the name "colourType" is used throughout the PNG spec.
*/
public final PngColourType colourType;
/**
* Compression type of the file.
* In practice this is redundant: it may be zip and nothing else.
*/
public final byte compressionMethod;
/**
* Filter method used by the file.
* In practice this is redundant because the filter types are set in the
* specification and have never been (and never will be) extended.
*/
public final byte filterMethod;
/**
* An image is either interlaced or not interlaced.
* At the time of writing only non-interlaced is supported by this library.
*/
public final byte interlaceMethod;
/**
* The number of bits that comprise a single pixel in this bitmap (or every
* frame if animated). This is distinct from bitDepth.
*
* @see #bitDepth
*/
public final int bitsPerPixel;
public final int bytesPerRow;
public final int filterOffset;
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType) {
this(width, height, bitDepth, colourType, (byte) 0, (byte) 0, (byte) 0);
}
public PngHeader(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) {
this.width = width;
this.height = height;
this.bitDepth = bitDepth;
this.colourType = colourType;
this.compressionMethod = compressionMethod;
this.filterMethod = filterMethod;
this.interlaceMethod = interlaceMethod;
this.bitsPerPixel = bitDepth * colourType.componentsPerPixel;
this.bytesPerRow = PngReadHelper.calculateBytesPerRow(width, bitDepth, colourType, interlaceMethod);
//this.filterOffset = this.bitsPerPixel < 8 ? 1 : this.bitsPerPixel>>3; // minimum of 1 byte. RGB888 will be 3 bytes. RGBAFFFF is 8 bytes.
this.filterOffset = (this.bitsPerPixel + 7) >> 3; // libpng
// from pypng
// # Derived values
// # http://www.w3.org/TR/PNG/#6Colour-values
// colormap = bool(self.color_type & 1)
// greyscale = not (self.color_type & 2)
// alpha = bool(self.color_type & 4)
// color_planes = (3,1)[greyscale or colormap]
// planes = color_planes + alpha
}
@Override
public String toString() {
return "PngHeader{" +
"width=" + width +
", height=" + height +
", bitDepth=" + bitDepth +
", colourType=" + colourType +
", compressionMethod=" + compressionMethod +
", filterMethod=" + filterMethod +
", interlaceMethod=" + interlaceMethod +
'}';
}
public boolean isInterlaced() {
return interlaceMethod == 1;
}
public boolean isZipCompression() {
return compressionMethod == 0;
}
public boolean isGreyscale() {
return colourType == PngColourType.PNG_GREYSCALE | colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA;
}
/**
* @return true if the image type indicates there is an alpha value for every pixel.
* Note that this take into account any transparency or background chunk.
*/
public boolean hasAlphaChannel() {
return colourType == PngColourType.PNG_GREYSCALE_WITH_ALPHA | colourType == PngColourType.PNG_TRUECOLOUR_WITH_ALPHA;
}
public static PngHeader makeTruecolour(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR);
}
public static PngHeader makeTruecolourAlpha(int width, int height) {
return new PngHeader(width, height, (byte) 8, PngColourType.PNG_TRUECOLOUR_WITH_ALPHA);
}
public PngHeader adjustFor(PngFrameControl frame) {
if (frame == null) {
return this;
} else {
return new PngHeader(frame.width, frame.height, this.bitDepth, this.colourType, this.compressionMethod, this.filterMethod, this.interlaceMethod);
}
}
public static void checkHeaderParameters(int width, int height, byte bitDepth, PngColourType colourType, byte compressionMethod, byte filterMethod, byte interlaceMethod) throws PngException {
switch (bitDepth) {
case 1:
case 2:
case 4:
case 8:
case 16:
break; // all fine
default:
throw new PngIntegrityException("Invalid bit depth " + bitDepth);
}
// thanks to pypng
if (colourType.isIndexed() && bitDepth > 8) {
throw new PngIntegrityException(String.format(
"Indexed images (colour type %d) cannot have bitdepth > 8 (bit depth %d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (bitDepth < 8 && !colourType.supportsSubByteDepth()) {
throw new PngIntegrityException(String.format(
"Illegal combination of bit depth (%d) and colour type (%d)." +
" See http://www.w3.org/TR/2003/REC-PNG-20031110/#table111 .", colourType.code, bitDepth));
}
if (interlaceMethod != 0) {
throw new PngFeatureException("Interlaced images are not yet supported");
}
}
public static PngHeader from(DataInput dis) throws IOException, PngException {
int width = dis.readInt();
int height = dis.readInt();
byte bitDepth = dis.readByte();
PngColourType colourType = PngColourType.fromByte(dis.readByte());
byte compressionMethod = dis.readByte();
byte filterMethod = dis.readByte();
byte interlaceMethod = dis.readByte();
checkHeaderParameters(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
return new PngHeader(width, height, bitDepth, colourType, compressionMethod, filterMethod, interlaceMethod);
}
}
@@ -0,0 +1,61 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.chunks;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.argb8888.Argb8888Palette;
import java.util.Arrays;
/**
* A PngPalette object represents an ordered array of RGB (888) colour tuples
* derived from a PLTE chunk.
* <p>
* WARNING: this class may not remain in the API.
* When implementing the Argb8888 decoders it seems clear that every output
* format will benefit from a specific palette implementation, so this attempt
* at a generic palette may be removed.
*
* @see Argb8888Palette
*/
public class PngPalette {
// TODO: should include alpha here? Can then store as int32s?
public final byte[] rgb888;
public final int[] rgba8888; // Including this duplicate for now. Not sure if will keep it.
public final int numColours;
public static final int LENGTH_RGB_BYTES = 3;
public static final int BYTE_INITIAL_ALPHA = 0xff;
public PngPalette(byte[] rgb888, int[] rgba8888) {
this.rgb888 = rgb888;
this.rgba8888 = rgba8888;
this.numColours = rgb888.length / 3;
}
public static PngPalette from(byte[] source, int first, int length) throws PngException {
if (length % LENGTH_RGB_BYTES != 0) {
throw new PngIntegrityException(String.format("Invalid palette data length: %d (not a multiple of 3)", length));
}
return new PngPalette(
Arrays.copyOfRange(source, first, first + length),
rgba8888From(source, first, length)
);
}
private static int[] rgba8888From(byte[] source, int first, int length) {
int last = first + length;
int numColours = length / 3;
int[] rgba8888 = new int[numColours];
int j = 0;
for (int i = first; i < last; i += LENGTH_RGB_BYTES) {
rgba8888[j] = source[i] << 24 | source[i + 1] << 16 | source[i + 2] << 8 | BYTE_INITIAL_ALPHA;
j++;
}
return rgba8888;
}
}
@@ -0,0 +1,21 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
/**
* All exceptions in the library are a PngException or subclass of it.
*/
public class PngException extends Exception {
int code;
public PngException(int code, String message) {
super(message);
this.code = code;
}
public PngException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}
@@ -0,0 +1,17 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
/**
* A PngFeatureException is thrown when a feature used in a specific file
* is not supported by the pipeline being used. For example, at the time
* of writing, interlaced image reading is not supported.
*/
public class PngFeatureException extends PngException {
public PngFeatureException(String message) {
super(PngConstants.ERROR_FEATURE_NOT_SUPPORTED, message);
}
}
@@ -0,0 +1,19 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.error;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
/**
* A PngIntegrityException is thrown when some aspect of the PNG file being
* loaded is invalid or unacceptable according to the PNG Specification.
* <p>
* For example, requesting a 16-bit palette image is invalid, or a colour type
* outside of the allowed set.
*/
public class PngIntegrityException extends PngException {
public PngIntegrityException(String message) {
super(PngConstants.ERROR_INTEGRITY, message);
}
}
@@ -0,0 +1,52 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
/**
* A single chunk from a PNG file is represented by a PngChunkMap.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngChunkMap {
/**
* The code like IHDR, IPAL, IDAT. If the chunk code is non-standard,
* the UNKNOWN code will be used and the codeString will be set.
*/
public PngChunkCode code;
/**
* Number of bytes containing the data portion of the chunk. Note that this excludes
* the last 4 bytes that are the CRC checksom.
*/
public int dataLength;
/**
* Integer offset of the first byte of data in this chunk (the byte immediately
* following the chunk length 32-bits and the chunk type 32-bits) from byte zero
* of the source.
*/
public int dataPosition;
public int checksum;
public PngChunkMap(PngChunkCode code, int dataPosition, int dataLength, int checksum) {
this.code = code;
this.dataLength = dataLength;
this.dataPosition = dataPosition;
this.checksum = checksum;
}
@Override
public String toString() {
return "PngChunkMap{" +
"letters=" + code +
", dataLength=" + dataLength +
", dataPosition=" + dataPosition +
", checksum=" + checksum +
'}';
}
}
@@ -0,0 +1,17 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import java.util.List;
/**
* A PngMap represents a map over an entire single PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMap {
public String source;
public List<PngChunkMap> chunks;
}
@@ -0,0 +1,50 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.map;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.reader.PngReader;
import org.jackhuang.hmcl.ui.image.apng.reader.PngSource;
import java.io.IOException;
import java.util.ArrayList;
/**
* Simple processor that skips all chunk content and ignores checksums, with
* sole objective of building a map of the contents of a PNG file.
* <p>
* WARNING: not sure if this API will remain.
* </p>
*/
public class PngMapReader implements PngReader<PngMap> {
PngMap map;
public PngMapReader(String sourceName) {
map = new PngMap();
map.source = sourceName;
map.chunks = new ArrayList<>(4);
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell();
source.skip(dataLength);
int chunkChecksum = source.readInt();
map.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
return code == PngConstants.IEND_VALUE;
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
// NOP
}
@Override
public PngMap getResult() {
return map;
}
}
@@ -0,0 +1,4 @@
/**
* @see <a href="https://github.com/aellerton/japng">japng</a>
*/
package org.jackhuang.hmcl.ui.image.apng;
@@ -0,0 +1,65 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.util.PartialInflaterInputStream;
import java.io.FilterInputStream;
import java.io.InputStream;
import java.util.zip.Inflater;
/**
* A BasicScanlineProcessor manages a re-entrant java.util.zip.Inflater object and
* basic bytesPerLine management.
*/
public abstract class BasicScanlineProcessor implements PngScanlineProcessor {
protected final int bytesPerLine;
protected Inflater inflater = new Inflater();
//protected PngHeader header;
//protected PngScanlineBuffer scanlineReader;
//protected PngFrameControl currentFrame;
//public DefaultScanlineProcessor(PngHeader header, PngScanlineBuffer scanlineReader, PngFrameControl currentFrame) {
public BasicScanlineProcessor(int bytesPerScanline) {
// this.header = header.adjustFor(currentFrame);
// this.currentFrame = currentFrame;
// this.scanlineReader = scanlineReader;
// this.bytesPerLine = this.header.bytesPerRow;
this.bytesPerLine = bytesPerScanline;
}
//@Override
//abstract public void processScanline(byte[] bytes, int position);
@Override
public FilterInputStream makeInflaterInputStream(InputStream inputStream) {
return new PartialInflaterInputStream(inputStream, inflater);
}
@Override
public int getBytesPerLine() {
return bytesPerLine;
}
@Override
public boolean isFinished() {
return inflater.finished();
}
// @Override
// public InflaterInputStream connect(InputStream inputStream) {
// multipartStream.add(inputStream);
// return iis;
// }
@Override
public void processTransparentGreyscale(byte k1, byte k0) {
// NOP by default
}
@Override
public void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0) {
// NOP by default
}
}
@@ -0,0 +1,303 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngAnimationType;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
/**
* The DefaultPngChunkReader is the default PNG chunk-reading workhorse.
* <p>
* Note that any chunk types not recognised can be processed in the readOtherChunk()
* method.
*/
public class DefaultPngChunkReader<ResultT> implements PngChunkReader<ResultT> {
protected PngChunkProcessor<ResultT> processor;
protected boolean seenHeader = false;
protected int idatCount = 0;
protected int apngSequenceExpect = 0;
protected PngAnimationType animationType = PngAnimationType.NOT_ANIMATED;
//private PngMainImageOp mainImageOp = PngMainImageOp.MAIN_IMAGE_KEEP;
public DefaultPngChunkReader(PngChunkProcessor<ResultT> processor) {
this.processor = processor;
}
@Override
public boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException {
int dataPosition = source.tell(); // note the start position before any further reads are done.
if (dataLength < 0) {
throw new PngIntegrityException(String.format("Corrupted read (Data length %d)", dataLength));
}
switch (code) {
case PngConstants.IHDR_VALUE:
readHeaderChunk(source, dataLength);
break;
case PngConstants.IEND_VALUE:
// NOP
break;
case PngConstants.gAMA_VALUE:
readGammaChunk(source, dataLength);
break;
case PngConstants.bKGD_VALUE:
readBackgroundChunk(source, dataLength);
break;
case PngConstants.tRNS_VALUE:
readTransparencyChunk(source, dataLength);
break;
case PngConstants.PLTE_VALUE:
readPaletteChunk(source, dataLength);
break;
case PngConstants.IDAT_VALUE:
readImageDataChunk(source, dataLength);
break;
case PngConstants.acTL_VALUE:
readAnimationControlChunk(source, dataLength);
break;
case PngConstants.fcTL_VALUE:
readFrameControlChunk(source, dataLength);
break;
case PngConstants.fdAT_VALUE:
readFrameImageDataChunk(source, dataLength);
break;
default:
readOtherChunk(code, source, dataPosition, dataLength);
break;
}
int chunkChecksum = source.readInt();
processChunkEnd(code, dataPosition, dataLength, chunkChecksum);
return code == PngConstants.IEND_VALUE;
}
@Override
public void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException {
processor.processChunkMapItem(new PngChunkMap(PngChunkCode.from(code), dataPosition, dataLength, chunkChecksum));
//container.chunks.add(new PngChunkMap(PngChunkCode.from(code), dataLength, dataPosition, chunkChecksum));
}
@Override
public void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException {
PngHeader header = PngHeader.from(source.getDis());
seenHeader = true;
processor.processHeader(header); // Hmm. Prefer header = PngHeader.from(source) ?
}
@Override
public void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException {
processor.processGamma(PngGamma.from(source.getDis()));
}
@Override
public void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException {
// TODO
//processor.processTransparency(PngTransparency.from(source, dataLength));
//throw new PngFeatureException("TODO UP TO HERE");
//source.skip(dataLength);
// if (dataLength % 3 != 0) {
// throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
// }
if (source.supportsByteAccess()) {
processor.processTransparency(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processTransparency(paletteBytes, 0, dataLength);
}
}
@Override
public void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException {
if (!seenHeader) {
throw new PngIntegrityException("bKGD chunk received before IHDR chunk");
}
// TODO
//processor.processBackground(PngBackground.from(source, dataLength);
source.skip(dataLength);
}
@Override
public void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength % 3 != 0) {
throw new PngIntegrityException(String.format("png spec: palette chunk length must be divisible by 3: %d", dataLength));
}
// TODO: can check if colour type matches palette type, or if any palette received before (overkill?)
if (source.supportsByteAccess()) {
processor.processPalette(source.getBytes(), source.tell(), dataLength);
source.skip(dataLength);
} else {
byte[] paletteBytes = new byte[dataLength];
//ByteStreams.readFully(source.getBis(), paletteBytes);
source.getDis().readFully(paletteBytes);
processor.processPalette(paletteBytes, 0, dataLength);
}
}
@Override
public void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException {
if (idatCount == 0 && apngSequenceExpect == 0) {
// Processing a plain PNG (non animated) IDAT chunk
animationType = PngAnimationType.NOT_ANIMATED; // processor.chooseApngImageType(PngAnimationType.NOT_ANIMATED, null);
}
idatCount++;
switch (animationType) {
case ANIMATED_DISCARD_DEFAULT_IMAGE:
// do nothing
source.skip(dataLength);
break;
case ANIMATED_KEEP_DEFAULT_IMAGE:
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
case NOT_ANIMATED:
default:
processor.processDefaultImageData(source.slice(dataLength), PngChunkCode.IDAT, source.tell(), dataLength);
break;
}
// source.skip(dataLength);
}
@Override
public void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_acTL_CHUNK) {
throw new PngIntegrityException(String.format("acTL chunk length must be %d, not %d", PngConstants.LENGTH_acTL_CHUNK, dataLength));
}
processor.processAnimationControl(new PngAnimationControl(source.readInt(), source.readInt()));
}
@Override
public void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException {
if (dataLength != PngConstants.LENGTH_fcTL_CHUNK) {
throw new PngIntegrityException(String.format("fcTL chunk length must be %d, not %d", PngConstants.LENGTH_fcTL_CHUNK, dataLength));
}
int sequence = source.readInt(); // TODO: check sequence # is correct or PngIntegrityException
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fctl chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // ready for next time
PngFrameControl frame = new PngFrameControl(
sequence,
source.readInt(), // width
source.readInt(), // height
source.readInt(), // x offset
source.readInt(), // y offset
source.readUnsignedShort(), // delay numerator
source.readUnsignedShort(), // delay denominator
source.readByte(), // dispose op
source.readByte() // blend op
);
if (sequence == 0) { // We're at the first frame...
if (idatCount == 0) { // Not seen any IDAT chunks yet
// APNG Spec says that when the first fcTL chunk is received *before* the first IDAT chunk
// the main image of the PNG becomes the first frame in the animation.
animationType = PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE; //processor.chooseApngImageType(PngAnimationType.ANIMATED_KEEP_DEFAULT_IMAGE, frame);
//mainImageOp = PngMainImageOp.MAIN_IMAGE_STARTS_ANIMATION;
} else {
//mainImageOp = PngMainImageOp.MAIN_IMAGE_DISCARD;
animationType = PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE; // processor.chooseApngImageType(PngAnimationType.ANIMATED_DISCARD_DEFAULT_IMAGE, frame);
}
} else {
// fall through
}
processor.processFrameControl(frame);
}
//public abstract void setMainImageOp(PngMainImageOp op);
@Override
public void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException {
// Note that once the sequence number is confirmed as being correct that there
// is no need to retain it in subsequent data.
int position = source.tell();
int sequence = source.readInt();
dataLength -= 4; // After reading the sequence number the data is just like IDAT.
if (sequence != apngSequenceExpect) {
throw new PngIntegrityException(String.format("fdAT chunk expected sequence %d but received %d", apngSequenceExpect, sequence));
}
apngSequenceExpect++; // for next time
//processFrameData(sequence, source, dataLength);
//processFrameImageData(source, dataLength);
processor.processFrameImageData(source.slice(dataLength), PngChunkCode.fdAT, source.tell(), dataLength);
// PngFrameControl current = container.getCurrentAnimationFrame();
//
// //imageDecoder
// // TODO: send image bytes to digester
// current.appendImageData(new PngChunkMap(PngChunkCode.fdAT, dataLength, position, 0));
//
// // TODO: skip everything except the frame sequence number
// source.skip(dataLength);
}
@Override
public void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException {
// If we're not processing it, got to skip it.
source.skip(dataLength);
}
@Override
public void finishedChunks(PngSource source) throws PngException, IOException {
}
@Override
public ResultT getResult() {
return processor.getResult();
}
public boolean isSeenHeader() {
return seenHeader;
}
public int getIdatCount() {
return idatCount;
}
public int getApngSequenceExpect() {
return apngSequenceExpect;
}
public PngAnimationType getAnimationType() {
return animationType;
}
}
@@ -0,0 +1,109 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* A simple way to process a PNG file is to read the entire thing into memory.
* <p>
* Very bad for very large images (500Mb?)? Yes.
* But simple for smaller (say <10Mb?) images? Also yes.
* I'd certainly like to make a lean alternative that processes bytes without reading
* the whole thing into memory, but it can come later. Patches welcome.
* <p>
* WARNING: I'm not sure I'll keep this. I may remove it and do everything with
* a BufferedInputStream + DataInputStream.
* </p>
*/
public class PngAtOnceSource implements PngSource {
final byte[] bytes;
//private String sourceDescription;
private final ByteArrayInputStream bis;
private final DataInputStream dis;
public PngAtOnceSource(byte[] bytes) { //}, String sourceDescription) {
this.bytes = bytes;
//this.sourceDescription = sourceDescription;
this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
this.dis = new DataInputStream(this.bis); // never closed because underlying stream doesn't need to be closed.
}
@Override
public int getLength() {
return bytes == null ? 0 : bytes.length;
}
@Override
public boolean supportsByteAccess() {
return true;
}
@Override
public byte[] getBytes() throws IOException {
return bytes;
}
@Override
public byte readByte() throws IOException {
return dis.readByte();
}
@Override
public short readUnsignedShort() throws IOException {
return (short) dis.readUnsignedShort();
}
@Override
public int readInt() throws IOException {
return dis.readInt();
}
@Override
public long skip(int chunkLength) throws IOException {
return dis.skip(chunkLength);
}
@Override
public int tell() {
return bytes.length - bis.available();
}
@Override
public int available() {
return bis.available();
}
// @Override
// public String getSourceDescription() {
// return sourceDescription;
// }
// @Override
// public ByteArrayInputStream getBis() {
// return bis;
// }
@Override
public DataInputStream getDis() {
return dis;
}
@Override
public InputStream slice(int dataLength) throws IOException {
// The below would be fine but in this case we have the full byte stream anyway...
//return ByteStreams.limit(bis, dataLength);
InputStream slice = new ByteArrayInputStream(bytes, tell(), dataLength);
this.skip(dataLength);
return slice;
}
public static PngAtOnceSource from(InputStream is) throws IOException {
return new PngAtOnceSource(is.readAllBytes());
}
}
@@ -0,0 +1,137 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngChunkCode;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngAnimationControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngGamma;
import org.jackhuang.hmcl.ui.image.apng.chunks.PngHeader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import org.jackhuang.hmcl.ui.image.apng.map.PngChunkMap;
import java.io.IOException;
import java.io.InputStream;
/**
* While a PngChunkReader does the low-level reading of a PNG file, it delegates <em>processing</em> of
* the data to a PngChunkProcessor instance. The class is generic about a specific result type because
* the intention of a chunk processor is to yield some result, which might be a bitmap, a sequence of
* bitmaps, a map of chunks, etc.
*/
public interface PngChunkProcessor<ResultT> {
/**
* Subclasses can further process the header immediately after PngHeader has been read.
* A subclass might elect to bail out of subsequent processing, e.g. if the image is too big
* or has an invalid format.
*
* @param header of image
*/
void processHeader(PngHeader header) throws PngException;
/**
* Process the PngGamma object parsed from a ``gaMA`` chunk.
*
* @param gamma parsed gamma data.
* @throws PngException
*/
void processGamma(PngGamma gamma) throws PngException;
/**
* Process the raw trNS data from the PNG file.
* <p>
* Note that this data is passed raw (but in a complete byte array).
* It is not parsed into an object because it allows concrete implementations to define
* their own way to handle the data. And it is not provided as in InputStream because
* the length of the data is well defined based on the PNG colour type.
*
* @param bytes array to read data from.
* @param position offset into the array to begin reading data from.
* @param length number of bytes to read from the array.
* @throws PngException
*/
void processTransparency(byte[] bytes, int position, int length) throws PngException;
/**
* A PLTE chunk has been read and needs to be processed.
* <p>
* A byte array is provided to process. A pre-defined palette class is not loaded because
* different rendering targets may elect to load the palette in different ways.
* For example, rendering to an ARGB palette may load the RGB data and organise as
* an array of 32-bit integer ARGB values.
* </p>
*
* @param bytes representing the loaded palette data. Each colour is represented by three
* bytes, 1 each for red, green and blue.
* @param position that the colour values begin in bytes
* @param length number of bytes that the palette bytes continue. Guaranteed to be a
* multiple of 3.
* @throws PngException
*/
void processPalette(byte[] bytes, int position, int length) throws PngException;
/**
* Process one IDAT chunk the "default image" bitmap in a given file.
* <p>
* The "default", "main" or "primary image" of a file is what you'd generally
* think of as "the" image in file, namely the image constructed of IDAT chunks
* and displayed by any normal viewer or browser.
* <p>
* I theorise (without proof) that most PNG files in the wild have a single
* IDAT chunk representing all pixels in the main image, but the PNG specification
* requires loaders to handle the case where there are multiple IDAT chunks.
* In the case of multiple chunks the data continues precisely where the last
* IDAT chunk left off. The same applies to fdAT chunks.
* <p>
* The data is provided not in any pre-parsed object or even as a byte array because
* the bytes can be arbitrarily long. The InputStream provided is a slice of the
* containing source and will terminate at the end of the IDAT chunk.
*
* @param inputStream data source containing all bytes in the image data chunk.
* @param code of the chunk which should always be IDAT in this case.
* Compare to this.processFrameImageData.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processDefaultImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
void processAnimationControl(PngAnimationControl animationControl) throws PngException;
void processFrameControl(PngFrameControl frameControl) throws PngException;
/**
* The reader has determined that bitmap data needs to be processed for an animation frame.
* The image data may be from an IDAT chunk or an fdAT chunk.
* <p>
* Whether the "default image" in a PNG file is to be part of the animation or discarded
* is determed by the placement of the first fcTL chunk in the file. See the APNG specification.
*
* @param inputStream data source containing all bytes in the frame image data chunk.
* @param code either IDAT or fdAT. Designed to allow an implementation to detect whether the
* frame is from the "main image" or a subsequent image. May not be necessary.
* @param position absolute position within the file. hmmm may be removed
* @param length length of bytes of the hunk. hmmm may be removed
* @throws IOException
* @throws PngException
*/
void processFrameImageData(InputStream inputStream, PngChunkCode code, int position, int length) throws IOException, PngException;
/**
* A subclass can record data about the chunk here.
* <p>
* WARNING: this API may be removed. I'm not sure if it is useful.
*
* @param chunkMapItem represents a "map" of a single chunk in the file.
* @throws PngException
*/
void processChunkMapItem(PngChunkMap chunkMapItem) throws PngException;
/**
* @return the result of processing all chunks. Only ever called after the file is fully read.
*/
ResultT getResult();
}
@@ -0,0 +1,134 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* Created by aellerton on 15/05/2015.
*/
public interface PngChunkReader<ResultT> extends PngReader<ResultT> {
@Override
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void processChunkEnd(int code, int dataPosition, int dataLength, int chunkChecksum) throws PngException;
void readHeaderChunk(PngSource source, int dataLength) throws IOException, PngException;
void readGammaChunk(PngSource source, int dataLength) throws PngException, IOException;
/**
* Process the tRNS chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11tRNS:
* <p>
* The tRNS chunk specifies either alpha values that are associated with palette entries (for indexed-colour
* images) or a single transparent colour (for greyscale and truecolour images). The tRNS chunk contains:
* <p>
* Colour type 0:
* <p>
* Grey sample value 2 bytes
* <p>
* Colour type 2:
* <p>
* Red sample value 2 bytes
* Blue sample value 2 bytes
* Green sample value 2 bytes
* <p>
* Colour type 3:
* <p>
* Alpha for palette index 0 1 byte
* Alpha for palette index 1 1 byte
* ...etc... 1 byte
* <p>
* Note that for palette colour types the number of transparency entries may be less than the number of
* entries in the palette. In that case all missing entries are assumed to be fully opaque.
*
* @param source
* @param dataLength
*/
void readTransparencyChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Process the bKGD chunk.
* <p>
* From http://www.w3.org/TR/PNG/#11bKGD:
* <p>
* The bKGD chunk specifies a default background colour to present the image against.
* If there is any other preferred background, either user-specified or part of a larger
* page (as in a browser), the bKGD chunk should be ignored. The bKGD chunk contains:
* <p>
* Colour types 0 and 4
* Greyscale 2 bytes
* Colour types 2 and 6
* Red 2 bytes
* Green 2 bytes
* Blue 2 bytes
* Colour type 3
* Palette index 1 byte
* <p>
* For colour type 3 (indexed-colour), the value is the palette index of the colour to be used as background.
* <p>
* For colour types 0 and 4 (greyscale, greyscale with alpha), the value is the grey level to be used as
* background in the range 0 to (2bitdepth)-1. For colour types 2 and 6 (truecolour, truecolour with alpha),
* the values are the colour to be used as background, given as RGB samples in the range 0 to (2bitdepth)-1.
* In each case, for consistency, two bytes per sample are used regardless of the image bit depth. If the image
* bit depth is less than 16, the least significant bits are used and the others are 0.
*
* @param source
* @param dataLength
* @throws IOException
*/
void readBackgroundChunk(PngSource source, int dataLength) throws IOException, PngException;
void readPaletteChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the IDAT chunk.
* <p>
* The default implementation skips the data, deferring to the finishedChunks() method
* to process the data. Key reasons to do this:
* <ul>
* <li>There could be multiple IDAT chunks and the streams need to
* be concatenated. This could be done in a single pass but it's more
* complicated and is not the objective of this implementation.</li>
* <li>This might be an APNG file and the IDAT chunk(s) are to be skipped.</li>
* </ul>
*
* @param source to read from
* @param dataLength in bytes of the data
* @throws PngException
* @throws IOException
*/
void readImageDataChunk(PngSource source, int dataLength) throws PngException, IOException;
void readAnimationControlChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Read the fcTL chunk.
* <p>
* See https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk
*
* @param source
* @param dataLength
* @throws IOException
* @throws PngException
*/
void readFrameControlChunk(PngSource source, int dataLength) throws IOException, PngException;
void readFrameImageDataChunk(PngSource source, int dataLength) throws IOException, PngException;
/**
* Give subclasses the opportunity to process a chunk code that was not recognised.
*
* @param code chunk type as integer.
* @param source of PNG data, positioned to read the data bytes.
* @param dataPosition offset from absolute start of bytes that data beings.
* @param dataLength of this chunk
* @throws IOException
*/
void readOtherChunk(int code, PngSource source, int dataPosition, int dataLength) throws IOException;
}
@@ -0,0 +1,112 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.PngColourType;
import org.jackhuang.hmcl.ui.image.apng.PngConstants;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
/**
* PNG reading support functions.
*/
public class PngReadHelper {
/**
* Return true if the next 8 bytes of the InputStream match the
* standard 8 byte PNG Signature, and false if they do not match.
* <p>
* If the stream ends before the signature is read an EOFExceptoin is thrown.
* </p><p>
* Note that no temporary buffer is allocated.
* </p>
*
* @param is Stream to read the 8-byte PNG signature from
* @throws IOException in the case of any IO exception, including EOF.
*/
public static boolean readSignature(InputStream is) throws IOException {
for (int i = 0; i < PngConstants.LENGTH_SIGNATURE; i++) {
int b = is.read();
if (b < 0) {
throw new EOFException();
}
if ((byte) b != PngConstants.BYTES_SIGNATURE[i]) {
return false;
}
}
return true;
}
/**
* Reads a given InputStream using the PngReader to process all chunks until the file is
* finished, then returning the result from the PngReader.
*
* @param is stream to read
* @param reader reads and delegates processing of all chunks
* @param <ResultT> result of the processing
* @return result of the processing of the InputStream.
* @throws PngException
*/
public static <ResultT> ResultT read(InputStream is, PngReader<ResultT> reader) throws PngException {
try {
if (!PngReadHelper.readSignature(is)) {
throw new PngException(PngConstants.ERROR_NOT_PNG, "Failed to read PNG signature");
}
// PngAtOnceSource source = PngAtOnceSource.from(is);//, sourceName);
PngSource source = new PngStreamSource(is);
boolean finished = false;
while (!finished) {
int length = source.readInt();
int code = source.readInt();
finished = reader.readChunk(source, code, length);
}
if (source.available() > 0) { // Should trailing data after IEND always be error or can configure as warning?
throw new PngException(PngConstants.ERROR_EOF_EXPECTED, String.format("Completed IEND but %d byte(s) remain", source.available()));
}
reader.finishedChunks(source);
return reader.getResult();
} catch (EOFException e) {
throw new PngException(PngConstants.ERROR_EOF, "Unexpected EOF", e);
} catch (IOException e) {
throw new PngException(PngConstants.ERROR_UNKNOWN_IO_FAILURE, e.getMessage(), e);
}
}
/**
* Number of bytes per row is key to processing scanlines.
* <p>
* TODO: should this by on the header?
*/
public static int calculateBytesPerRow(int pixelsPerRow, byte bitDepth, PngColourType colourType, byte interlaceMethod) {
if (interlaceMethod != 0) {
throw new IllegalStateException("Interlaced images not yet supported");
} else {
int numComponentsPerPixel = colourType.componentsPerPixel; // or "channels". e.g. "gray and alpha" means two components.
int bitsPerComponent = bitDepth; // e.g. "4" means 4 bits for gray, 4 bits for alpha
int bitsPerPixel = bitsPerComponent * numComponentsPerPixel; // e.g. total of 8 bits per pixel
int bitsPerRow = bitsPerPixel * pixelsPerRow;
//?? use that (bitDepth+7)>>3 ... thing?
// unsigned int bpp = (row_info->pixel_depth + 7) >> 3; // libpng
// If there are less than 8 bits per pixel, then ensure the last byte of the row is padded.
int bytesPerRow = bitsPerRow / 8 + ((0 == (bitsPerRow % 8)) ? 0 : (8 - bitsPerRow % 8));
return 1 + bytesPerRow; // need 1 byte for filter code
}
}
private PngReadHelper() {
}
}
@@ -0,0 +1,20 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
import java.io.IOException;
/**
* All PngReader implementations need to read a specific single chunk and to return
* a result of some form.
*/
public interface PngReader<ResultT> {
boolean readChunk(PngSource source, int code, int dataLength) throws PngException, IOException;
void finishedChunks(PngSource source) throws PngException, IOException;
ResultT getResult();
}
@@ -0,0 +1,69 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.FilterInputStream;
import java.io.InputStream;
/**
* A PngScanlineProcessor receives decompressed, de-filtered scanlines, one by one, in order
* from top to bottom. Interlaced scanlines are not yet supported. The scanline data is not
* parsed or expanded in any form, so if the PNG is 1-bit monochrome or 16 bit RGBA, the bytes
* are provided as-is. The job of the processor is to read and reformat each pixel as
* appropriate for the destination bitmap.
* <p>
* Note that a single instance of a PngScanlineProcessor is intended to process a single
* bitmap and that, when the bitmap is complete, the isFinished() method will return true.
* In the case of an animated PNG, it is expected that a new PngScanlineProcessor will be
* created to service the new bitmap.
* <p>
* Note: I wonder if this would be better named PngScanlineTransformer because its primary
* purpose is to convert pixels from raw file format to destination format.
*/
public interface PngScanlineProcessor {
/**
* A PngScanlineProcessor is responsible for decompressing the raw (compressed) data.
* This is important because there can be more than one IDAT and fdAT chunks for a single
* image, and decompression must occur seamlessly across those chunks, so the decompression
* must be stateful across multiple invocations of makeInflaterInputStream. In practice, it
* seems to be rare that there are multiple image data chunks for a single image.
*
* @param inputStream stream over raw compressed PNG image data.
* @return an InflaterInputStream that decompresses the current stream.
*/
FilterInputStream makeInflaterInputStream(InputStream inputStream);
/**
* The processScanline method is invoked when the raw image data has been first decompressed
* then de-filtered. The PngScanlineProcessor then must interpret each byte according to the
* specific image format and render it to the destination as appropriate.
*
* @param bytes decompressed, de-filtered bytes, ready for processing.
* @param position the position that scanline pixels begin in the array. Note that it is
* the responsibility of the PngScanlineProcessor to know exactly how many
* bytes are in the row. This is because a single processor might process
* different frames in an APNG, and each frame can have a different width
* (up to the maximum set in the PNG header).
*/
void processScanline(byte[] bytes, int position);
int getBytesPerLine();
void processTransparentGreyscale(byte k1, byte k0);
void processTransparentTruecolour(byte r1, byte r0, byte g1, byte g0, byte b1, byte b0);
/**
* A PngScanlineProcessor must be able to decompress more than one consecutive IDAT or fdAT
* chunks. This is because the PNG specification states that a valid PNG file can have more
* than one consecutive IDAT (and by extension, fdAT) chunks and the data therein must be
* treated as if concatenated.
*
* @return true when the data for the current bitmap is complete.
*/
boolean isFinished();
//InflaterInputStream connect(InputStream inputStream);
}
@@ -0,0 +1,43 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* The API for reading any PNG source.
*
* @see PngAtOnceSource
* <p>
* WARNING: this may be removed in favour of direct use of InputStream objects.
*/
public interface PngSource {
int getLength();
boolean supportsByteAccess();
byte[] getBytes() throws IOException;
byte readByte() throws IOException;
short readUnsignedShort() throws IOException;
int readInt() throws IOException;
long skip(int chunkLength) throws IOException;
int tell();
int available();
// String getSourceDescription();
// ByteArrayInputStream getBis();
DataInputStream getDis();
InputStream slice(int dataLength) throws IOException;
}
@@ -0,0 +1,91 @@
// Copy from https://github.com/aellerton/japng
// Licensed under the Apache License, Version 2.0.
package org.jackhuang.hmcl.ui.image.apng.reader;
import org.jackhuang.hmcl.ui.image.apng.util.InputStreamSlice;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* Created by aellerton on 13/06/2015.
*/
public class PngStreamSource implements PngSource {
final InputStream src;
//private final ByteArrayInputStream bis;
private final DataInputStream dis;
public PngStreamSource(InputStream src) {
this.src = src;
//this.bis = new ByteArrayInputStream(this.bytes); // never closed because nothing to do for ByteArrayInputStream
this.dis = new DataInputStream(this.src); // never closed because underlying stream doesn't need to be closed.
}
@Override
public int getLength() {
return 0; // TODO?
}
@Override
public boolean supportsByteAccess() {
return false;
}
@Override
public byte[] getBytes() throws IOException {
return null;
}
@Override
public byte readByte() throws IOException {
return dis.readByte();
}
@Override
public short readUnsignedShort() throws IOException {
return (short) dis.readUnsignedShort();
}
@Override
public int readInt() throws IOException {
return dis.readInt();
}
@Override
public long skip(int chunkLength) throws IOException {
return dis.skip(chunkLength);
}
@Override
public int tell() {
return 0; // TODO
}
@Override
public int available() {
try {
return dis.available(); // TODO: adequate?
} catch (IOException e) {
return 0; // TODO
}
}
// @Override
// public ByteArrayInputStream getBis() {
// return null;
// }
@Override
public DataInputStream getDis() {
return dis;
}
@Override
public InputStream slice(int dataLength) {
return new InputStreamSlice(src, dataLength);
}
}
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff. Show more