Compare commits
521
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5594d8c1 | ||
|
|
13ad50208f | ||
|
|
13910c24b7 | ||
|
|
cc9bdb62ca | ||
|
|
baa7c816b7 | ||
|
|
090f0b9822 | ||
|
|
6d9ae24912 | ||
|
|
d03851b196 | ||
|
|
97cf9dcf16 | ||
|
|
7bacc2bb5a | ||
|
|
c7637079d7 | ||
|
|
8f3282e9a2 | ||
|
|
8e491862b9 | ||
|
|
e0c276075c | ||
|
|
19ea64e14b | ||
|
|
5b26b8f4eb | ||
|
|
ed7ef92ad9 | ||
|
|
cd95891108 | ||
|
|
df52bc6e81 | ||
|
|
9d9fc78384 | ||
|
|
e287f672fd | ||
|
|
24702dc5a0 | ||
|
|
886a25a935 | ||
|
|
450504ceda | ||
|
|
8767cc0e98 | ||
|
|
7331f619cf | ||
|
|
7a8f931641 | ||
|
|
ce51dc4dd7 | ||
|
|
b97e7fbd55 | ||
|
|
7f578be08e | ||
|
|
513f53bc06 | ||
|
|
b2d4d36856 | ||
|
|
9c031c5ac1 | ||
|
|
e2d3ac847d | ||
|
|
0f4152406c | ||
|
|
1a258a2554 | ||
|
|
3742be0aa8 | ||
|
|
0e34554344 | ||
|
|
1acfc55004 | ||
|
|
1428183e36 | ||
|
|
cbc6daf3d5 | ||
|
|
0ad180c082 | ||
|
|
1da8c8d6ac | ||
|
|
ea232b0976 | ||
|
|
b4549b6f68 | ||
|
|
4cf5dbdf66 | ||
|
|
b179c44bec | ||
|
|
7dfc654d5d | ||
|
|
a4b930ded5 | ||
|
|
00cd20a6e9 | ||
|
|
9ebf29d060 | ||
|
|
d7d5e99185 | ||
|
|
5e9ed84605 | ||
|
|
2e01874e95 | ||
|
|
efb475879b | ||
|
|
cb69387151 | ||
|
|
70104cb9e5 | ||
|
|
931fbf5181 | ||
|
|
1d5ecb9236 | ||
|
|
b65104e38b | ||
|
|
8629a2bbe3 | ||
|
|
3142b2f894 | ||
|
|
b1759acb62 | ||
|
|
1363f0e70d | ||
|
|
45c4913b82 | ||
|
|
0d47ff95e1 | ||
|
|
4064c5c188 | ||
|
|
56a2f75c8b | ||
|
|
c4ebdeb77e | ||
|
|
13b87fff89 | ||
|
|
50072799d0 | ||
|
|
16d45890ad | ||
|
|
e0dacd0265 | ||
|
|
72071a41c9 | ||
|
|
c86c19a7f9 | ||
|
|
5475acdc9e | ||
|
|
6daeeb0b04 | ||
|
|
1a4ccd355d | ||
|
|
204a08342d | ||
|
|
ced06cb48c | ||
|
|
80b3883d4f | ||
|
|
60c7ea5a01 | ||
|
|
3323c6a50a | ||
|
|
7530f08ab1 | ||
|
|
15a983de28 | ||
|
|
5c0d4a7c7b | ||
|
|
1532247f2a | ||
|
|
587e92543a | ||
|
|
f0527e12b4 | ||
|
|
b0ac4fb5d0 | ||
|
|
083dbb18ad | ||
|
|
c9585198e1 | ||
|
|
0fd83e5177 | ||
|
|
5a017ecab6 | ||
|
|
da77f52492 | ||
|
|
dccfa7c9a9 | ||
|
|
f7bf2d468d | ||
|
|
a10c501d4f | ||
|
|
116629743e | ||
|
|
cfee31d339 | ||
|
|
3ac08d7f62 | ||
|
|
de643becf8 | ||
|
|
edbcc1f343 | ||
|
|
91fcc4e5ef | ||
|
|
34aeba1b1a | ||
|
|
9a36b86cfb | ||
|
|
71b4c02474 | ||
|
|
45f49d079b | ||
|
|
71d9daed16 | ||
|
|
5ae019ba17 | ||
|
|
770c58c2e7 | ||
|
|
a03c11be42 | ||
|
|
ca3746319f | ||
|
|
56629892f8 | ||
|
|
5238a361cd | ||
|
|
0323a7f321 | ||
|
|
a3c5e8e412 | ||
|
|
5c364f624a | ||
|
|
22275f2c57 | ||
|
|
11e54d199c | ||
|
|
cf21cf4a3c | ||
|
|
a0d1fa8f38 | ||
|
|
78b8d37891 | ||
|
|
0807c04572 | ||
|
|
8eec18450e | ||
|
|
15d4874366 | ||
|
|
c149401cf3 | ||
|
|
ff459a5bc4 | ||
|
|
5796dad18c | ||
|
|
0443ff7cd1 | ||
|
|
4c6fe865f5 | ||
|
|
1e54482f3b | ||
|
|
c329f0d2e8 | ||
|
|
af4d9feea5 | ||
|
|
af71c71663 | ||
|
|
4675442ecd | ||
|
|
c8929c80d9 | ||
|
|
81634b14df | ||
|
|
ccca396d91 | ||
|
|
5a14449b44 | ||
|
|
612998a98f | ||
|
|
a50e327f70 | ||
|
|
7aeb1642b1 | ||
|
|
5fc1536d6a | ||
|
|
463cf2ff0f | ||
|
|
144e822a85 | ||
|
|
87789beeb7 | ||
|
|
bb86100c54 | ||
|
|
2ceca9d8cc | ||
|
|
d5fc365a96 | ||
|
|
d5c86a8d88 | ||
|
|
bd6beb1369 | ||
|
|
b1719e77bb | ||
|
|
77f0a90f10 | ||
|
|
ff6110ba89 | ||
|
|
e7e964b3dd | ||
|
|
4e29c29a7a | ||
|
|
25ad1dbf44 | ||
|
|
d2e7e0d626 | ||
|
|
c68bbf2904 | ||
|
|
d0fc46f930 | ||
|
|
f1b861eb21 | ||
|
|
5cde4ce757 | ||
|
|
d297417909 | ||
|
|
2854eac27d | ||
|
|
c9fd7c6a1c | ||
|
|
0e0517ab3c | ||
|
|
7a4bea10d0 | ||
|
|
b87d40ffcc | ||
|
|
1277bf8b5d | ||
|
|
c2e807055a | ||
|
|
9021ec1960 | ||
|
|
82a47e9e2d | ||
|
|
4841204fa0 | ||
|
|
897f5fe11b | ||
|
|
4657e3273e | ||
|
|
4edea5f5b9 | ||
|
|
36bd7d4491 | ||
|
|
3aa8603bd1 | ||
|
|
ca7a61e20a | ||
|
|
21afc81cf8 | ||
|
|
94a0e26be5 | ||
|
|
14e8d2d0b5 | ||
|
|
49c408cb23 | ||
|
|
b81c3398ac | ||
|
|
2855df3693 | ||
|
|
8184dc6cee | ||
|
|
df21559c0e | ||
|
|
73ff3081f8 | ||
|
|
fe05bb58a4 | ||
|
|
4c9cd950e4 | ||
|
|
646d9a09a5 | ||
|
|
255fe75640 | ||
|
|
de15dd8e98 | ||
|
|
886319a6c7 | ||
|
|
3320f6c341 | ||
|
|
b85be78166 | ||
|
|
a34a7439d7 | ||
|
|
591fd51600 | ||
|
|
c1407a9620 | ||
|
|
e0a91579f2 | ||
|
|
b68f404c3c | ||
|
|
c1f50f9ba3 | ||
|
|
d8bb2131f2 | ||
|
|
aca761385b | ||
|
|
15faccf376 | ||
|
|
241c9c419c | ||
|
|
4b36f8ce03 | ||
|
|
5c0baad782 | ||
|
|
850a56405c | ||
|
|
e8487c467b | ||
|
|
9866d88a16 | ||
|
|
af2d46ae89 | ||
|
|
6f7c561ecb | ||
|
|
93abe31acf | ||
|
|
a6f8659349 | ||
|
|
8728c89093 | ||
|
|
2dccd8099d | ||
|
|
d8a03cfed8 | ||
|
|
26a874a030 | ||
|
|
7c5c3e2fb4 | ||
|
|
62d7b36248 | ||
|
|
dec6837528 | ||
|
|
3484792e81 | ||
|
|
2b310e035d | ||
|
|
6e960612dd | ||
|
|
73a776b021 | ||
|
|
31a2f49ed2 | ||
|
|
ab268cfe48 | ||
|
|
721aeccfc0 | ||
|
|
e4348f727a | ||
|
|
dfc96f10da | ||
|
|
a5c7fe08d8 | ||
|
|
a6e0acca45 | ||
|
|
3d312f9247 | ||
|
|
18dc5b69c0 | ||
|
|
98af2c85b6 | ||
|
|
c4c0b1a1ce | ||
|
|
5e621ce340 | ||
|
|
1d1c8dfdc9 | ||
|
|
9b68b8825d | ||
|
|
5c17783223 | ||
|
|
a921be45f1 | ||
|
|
2dd1c0f85e | ||
|
|
7ba229b3e5 | ||
|
|
59a299630b | ||
|
|
b41b89e233 | ||
|
|
af137e5ff1 | ||
|
|
74ddbf5623 | ||
|
|
accef4a93f | ||
|
|
9c3a955fad | ||
|
|
1c0e506d34 | ||
|
|
6374ec0de7 | ||
|
|
a6b3872af4 | ||
|
|
ad98a5a6d4 | ||
|
|
05393af537 | ||
|
|
3438da2ab9 | ||
|
|
3163b465ec | ||
|
|
573dac471c | ||
|
|
12f54f353a | ||
|
|
246699d571 | ||
|
|
3480e6974b | ||
|
|
6d14351833 | ||
|
|
fd0d83257d | ||
|
|
0eb1d3cf4b | ||
|
|
f9b8dad3d2 | ||
|
|
57fbd0910e | ||
|
|
7767a57f50 | ||
|
|
f024dba940 | ||
|
|
52736d3c86 | ||
|
|
f75b472068 | ||
|
|
d8e0c9e9c7 | ||
|
|
9601c1c1ea | ||
|
|
d5c7f9bef3 | ||
|
|
1714ffd260 | ||
|
|
fa06be865c | ||
|
|
9dc69dbc2b | ||
|
|
43b6fdb5ee | ||
|
|
e6598f23de | ||
|
|
0f51934e1c | ||
|
|
122d474eeb | ||
|
|
6223ceedff | ||
|
|
823b74b7e5 | ||
|
|
31642f9185 | ||
|
|
c8f66a457d | ||
|
|
65c76c53d5 | ||
|
|
7580c6da82 | ||
|
|
d8938e1fcb | ||
|
|
c0f699b90d | ||
|
|
3bc8d441a1 | ||
|
|
6c7681d015 | ||
|
|
24c36c8f20 | ||
|
|
c023242afc | ||
|
|
194ed4c255 | ||
|
|
28022461f7 | ||
|
|
55316c18bc | ||
|
|
beb6882921 | ||
|
|
2ba56a1137 | ||
|
|
a127254341 | ||
|
|
6086e6ba6e | ||
|
|
4baf983514 | ||
|
|
4dc8e2a6ff | ||
|
|
cf9dcf04c3 | ||
|
|
677c29e232 | ||
|
|
1d35f49af8 | ||
|
|
9e2f1d3151 | ||
|
|
8f9f800c51 | ||
|
|
1f8ba73bb1 | ||
|
|
415fcc1ac7 | ||
|
|
da3cf3056b | ||
|
|
d81d88d3f9 | ||
|
|
d5dee7146c | ||
|
|
436f664a1c | ||
|
|
0e04566b6c | ||
|
|
f9e9939b1f | ||
|
|
5d1e6fdc25 | ||
|
|
8011eee586 | ||
|
|
90eb622903 | ||
|
|
f0fcc4ac5e | ||
|
|
d35d1fc81e | ||
|
|
e9a9efd0cc | ||
|
|
30acc53ee8 | ||
|
|
f7b1c7cd8c | ||
|
|
506f1c1d60 | ||
|
|
ca2f8a5cda | ||
|
|
1f48b10eb9 | ||
|
|
40d21bbe24 | ||
|
|
d0be7efca3 | ||
|
|
3bed03c535 | ||
|
|
d295e606a9 | ||
|
|
36afe32146 | ||
|
|
87dbbdd96f | ||
|
|
9b8e67b1b3 | ||
|
|
f5a5e6a6da | ||
|
|
f0466c1d8e | ||
|
|
903ecbb127 | ||
|
|
4c6f3aeb99 | ||
|
|
d6a4b3480f | ||
|
|
5e84e5bcd6 | ||
|
|
6ce7af05b2 | ||
|
|
0d62a12353 | ||
|
|
880aab0ba5 | ||
|
|
0b64e89aaa | ||
|
|
7b6379674b | ||
|
|
55f4b0d8fc | ||
|
|
863c3c9c7f | ||
|
|
1f5fb983b0 | ||
|
|
856d5ec5af | ||
|
|
20a5670876 | ||
|
|
bdf6a58be8 | ||
|
|
7c74dd438f | ||
|
|
5803235407 | ||
|
|
afbf567fef | ||
|
|
72b3c1981a | ||
|
|
72ee82543b | ||
|
|
996c9f4c86 | ||
|
|
f8c1e97376 | ||
|
|
f146040ff3 | ||
|
|
0da28173ab | ||
|
|
be822666a7 | ||
|
|
8c99ee3b0d | ||
|
|
0e3b4a7853 | ||
|
|
615b1dbaae | ||
|
|
0fa51f830d | ||
|
|
007bb345d0 | ||
|
|
46536f11d3 | ||
|
|
b1232b5dc8 | ||
|
|
f8f047d67e | ||
|
|
86529c6f15 | ||
|
|
d63829c344 | ||
|
|
34359a7e86 | ||
|
|
aa335d8365 | ||
|
|
a565d3b7ce | ||
|
|
3bff3c7f23 | ||
|
|
4e6e832e9c | ||
|
|
d6648a840e | ||
|
|
f8c2af716f | ||
|
|
bd812ccb67 | ||
|
|
dad190f7e6 | ||
|
|
1b1ba64a38 | ||
|
|
e31268c39d | ||
|
|
cb9d32dc91 | ||
|
|
36568df5b1 | ||
|
|
b4ac42d789 | ||
|
|
32aafb11a3 | ||
|
|
36dab70b7a | ||
|
|
ebf253b1a7 | ||
|
|
539dcca5ca | ||
|
|
659b6c2a70 | ||
|
|
3dcc7ab311 | ||
|
|
e0ccb58272 | ||
|
|
d4ec8ebec8 | ||
|
|
f3b21e5099 | ||
|
|
202fd38180 | ||
|
|
f5768f2f95 | ||
|
|
2d377d06de | ||
|
|
42d1581ba0 | ||
|
|
995a23c2bc | ||
|
|
8969f55a1f | ||
|
|
312a285792 | ||
|
|
d85b052195 | ||
|
|
cd0d3d1b97 | ||
|
|
6ccc7df998 | ||
|
|
73e5909a69 | ||
|
|
ade487277d | ||
|
|
e755d17977 | ||
|
|
292288de9e | ||
|
|
57f6dec462 | ||
|
|
8e1a549c9d | ||
|
|
1d18eae377 | ||
|
|
6ef04d2fa5 | ||
|
|
1778e9d916 | ||
|
|
dbb63e9faa | ||
|
|
6b1c3182eb | ||
|
|
82460ebbcb | ||
|
|
3e8a9bf1c4 | ||
|
|
a2db925e86 | ||
|
|
fe70cc3daf | ||
|
|
5991112cd8 | ||
|
|
084d91c62a | ||
|
|
4963f7a5f0 | ||
|
|
f42d5219c2 | ||
|
|
101a9b5d5e | ||
|
|
7cd79a4e2a | ||
|
|
30c3acfb23 | ||
|
|
31f04db555 | ||
|
|
7d41f7bdd3 | ||
|
|
8e6ac947ed | ||
|
|
20e321f502 | ||
|
|
00b323e507 | ||
|
|
333b593c8a | ||
|
|
665f6df142 | ||
|
|
9e16130537 | ||
|
|
5c98d6734d | ||
|
|
c3801a3445 | ||
|
|
1d137a3a88 | ||
|
|
2e8b9ae3ca | ||
|
|
fb750e8717 | ||
|
|
f9ae0902d8 | ||
|
|
8d69cbc02f | ||
|
|
90c2372c92 | ||
|
|
84c4943de5 | ||
|
|
057943b6c0 | ||
|
|
e943cb0aec | ||
|
|
70290e205b | ||
|
|
a73b8c7172 | ||
|
|
a5942c8e91 | ||
|
|
dc6a97520b | ||
|
|
8be3f04cc8 | ||
|
|
8d85b1537d | ||
|
|
b16f2a6ce6 | ||
|
|
e19f175e29 | ||
|
|
f3a5b2a99d | ||
|
|
eac2740ea8 | ||
|
|
a06da53ec9 | ||
|
|
765ea24d22 | ||
|
|
bd24ab37f5 | ||
|
|
c689272c42 | ||
|
|
952cc5dd37 | ||
|
|
356df5d630 | ||
|
|
0e433c5f4b | ||
|
|
a28509de85 | ||
|
|
e45601ecda | ||
|
|
5bbf5cbfc8 | ||
|
|
afb98c20ce | ||
|
|
6b2bc4606b | ||
|
|
6b57cec576 | ||
|
|
83111b83f2 | ||
|
|
420e337c8c | ||
|
|
028e43c959 | ||
|
|
ef01acca6d | ||
|
|
110d5f5630 | ||
|
|
94497a073d | ||
|
|
ff540a95ee | ||
|
|
ff6a32cdc3 | ||
|
|
969644635a | ||
|
|
a053736e95 | ||
|
|
879f82c462 | ||
|
|
e2d8d9f93f | ||
|
|
da4ed04697 | ||
|
|
5d44df6b40 | ||
|
|
114503e0bf | ||
|
|
8bd88aa8d8 | ||
|
|
cc7d1e1aae | ||
|
|
56d4effda0 | ||
|
|
7c2dbe3dae | ||
|
|
99469077d2 | ||
|
|
ec930843da | ||
|
|
969d314802 | ||
|
|
3b11541b0a | ||
|
|
c58d5a3a81 | ||
|
|
cf9b4aacb2 | ||
|
|
e430db0cd6 | ||
|
|
1b31e96629 | ||
|
|
5c19239ddd | ||
|
|
c181c7825b | ||
|
|
80d2fefa1f | ||
|
|
8701030934 | ||
|
|
ae4c152a8b | ||
|
|
e436163be7 | ||
|
|
9b8e65449b | ||
|
|
fbb52f4e8a | ||
|
|
704eb32048 | ||
|
|
730d9b4ce8 | ||
|
|
c7b316ecba | ||
|
|
6df5a02c3f | ||
|
|
1b9fde3d60 | ||
|
|
f435cda6a3 | ||
|
|
4a69fde6e6 | ||
|
|
294a0cd7da | ||
|
|
e057e20b3c | ||
|
|
8c0ca7c731 | ||
|
|
0d591a5429 | ||
|
|
900d7595d5 | ||
|
|
5e18c0aad4 | ||
|
|
f51f982f80 | ||
|
|
7c5430424f | ||
|
|
5909c73c48 | ||
|
|
3166c1d405 | ||
|
|
27c73b4434 | ||
|
|
705f978c43 |
No files matched your search
@@ -0,0 +1,66 @@
|
||||
# Default: normalize all text files to LF in the repository and on checkout.
|
||||
# Git auto-detects binary files and leaves them untouched.
|
||||
* text=auto eol=lf
|
||||
|
||||
# ─── Explicitly text – source & build ────────────────────────────────────────
|
||||
*.java text eol=lf
|
||||
*.js text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.groovy text eol=lf
|
||||
*.gradle text eol=lf
|
||||
|
||||
# ─── Configuration & data ─────────────────────────────────────────────────────
|
||||
*.properties text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.MF text eol=lf
|
||||
|
||||
# Gradle dependency-lock files
|
||||
*.lockfile text eol=lf
|
||||
|
||||
# ─── Scripts ──────────────────────────────────────────────────────────────────
|
||||
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# CI pipeline scripts
|
||||
Jenkinsfile text eol=lf
|
||||
|
||||
# Windows batch files must keep CRLF so cmd.exe can parse them.
|
||||
*.bat text eol=crlf
|
||||
|
||||
# ─── Binary files ─────────────────────────────────────────────────────────────
|
||||
# Raster / animated images
|
||||
*.png binary
|
||||
*.apng binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
|
||||
# Archives & compiled artifacts
|
||||
*.jar binary
|
||||
*.zip binary
|
||||
*.class binary
|
||||
|
||||
# Cryptographic / certificate files
|
||||
*.der binary
|
||||
|
||||
# Minecraft schematic
|
||||
*.litematic binary
|
||||
|
||||
# Compiled / intermediate build artefacts
|
||||
*.bin binary
|
||||
*.probe binary
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: QQ 群
|
||||
url: https://docs.hmcl.net/groups.html
|
||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||
@@ -0,0 +1,120 @@
|
||||
const fs = require("fs");
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
|
||||
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
|
||||
core.setFailed("Pull request number file not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reportsPath = process.env.REPORTS_PATH;
|
||||
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
|
||||
core.setFailed("Reports file not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
|
||||
if (isNaN(PULL_REQUEST_NUMBER)) {
|
||||
core.setFailed("Failed to parse the pull request number.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let pr;
|
||||
try {
|
||||
const response = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: PULL_REQUEST_NUMBER,
|
||||
});
|
||||
pr = response.data;
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Current PR state: ${pr.state}`);
|
||||
if (pr.state !== "open") {
|
||||
core.setFailed("The pull request is not open. Skipping comment creation.");
|
||||
return;
|
||||
}
|
||||
|
||||
const run = context.payload.workflow_run;
|
||||
if (!run) {
|
||||
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.sha !== run.head_sha) {
|
||||
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.ref !== run.head_branch) {
|
||||
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.repo.full_name !== run.head_repository.full_name) {
|
||||
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = [];
|
||||
const maxCommentCount = 10;
|
||||
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
|
||||
for (const report of reports) {
|
||||
if (comments.length >= maxCommentCount) break;
|
||||
try {
|
||||
const data = JSON.parse(report);
|
||||
if (typeof data !== "object" || Array.isArray(data)) continue;
|
||||
|
||||
const { location, message, severity, code } = data;
|
||||
if (typeof location !== "object" || Array.isArray(location)) continue;
|
||||
if (typeof message !== "string") continue;
|
||||
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
|
||||
if (!severities.includes(severity)) continue;
|
||||
const severityText = typeof severity === "string" ? severity : severities[severity];
|
||||
if (typeof code !== "object" || Array.isArray(code)) continue;
|
||||
|
||||
const path = location.path;
|
||||
const line = location.range?.start?.line;
|
||||
const codeText = code.value ? `[${code.value}] ` : "";
|
||||
|
||||
if (path && line) {
|
||||
comments.push({
|
||||
path,
|
||||
line,
|
||||
side: "RIGHT",
|
||||
body: `**[${severityText}]** ${codeText}\n\n${message}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to parse report: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
core.info("No diagnostics found to report as comments.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
|
||||
|
||||
try {
|
||||
await github.rest.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: PULL_REQUEST_NUMBER,
|
||||
commit_id: run.head_sha,
|
||||
body: "🤖 Static analysis found the following issues (max count 10):",
|
||||
event: "COMMENT",
|
||||
comments: comments
|
||||
});
|
||||
core.info("Review comments successfully posted to the pull request.");
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const ignoredFilePatterns = [
|
||||
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.github\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.idea\/.+\.xml$/i,
|
||||
/(?:^|\/)\.editorconfig$/i,
|
||||
/(?:^|\/)\.gitattributes$/i,
|
||||
/(?:^|\/)\.gitignore$/i,
|
||||
/(?:^|\/).+\.kts$/i,
|
||||
/(?:^|\/).+\.md$/i,
|
||||
/(?:^|\/)LICENSE(?:_.*)?$/i,
|
||||
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
|
||||
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
|
||||
/(?:^|\/)lang\/.+\.properties$/i,
|
||||
/(?:^|\/)l10n\/.+\.properties$/i,
|
||||
/(?:^|\/)checkstyle\.xml$/i,
|
||||
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
|
||||
];
|
||||
|
||||
async function processPR(issueNumber) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: issueNumber, per_page: 100,
|
||||
});
|
||||
|
||||
const countedFiles = [];
|
||||
const ignoredFiles = [];
|
||||
let totalChanges = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
|
||||
if (ignored) {
|
||||
ignoredFiles.push(file.filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
|
||||
totalChanges += fileChanges;
|
||||
countedFiles.push(`${file.filename} (${fileChanges})`);
|
||||
}
|
||||
|
||||
let targetLabel;
|
||||
if (totalChanges <= 9) targetLabel = "1+";
|
||||
else if (totalChanges <= 39) targetLabel = "10+";
|
||||
else if (totalChanges <= 99) targetLabel = "40+";
|
||||
else if (totalChanges <= 499) targetLabel = "100+";
|
||||
else if (totalChanges <= 999) targetLabel = "500+";
|
||||
else if (totalChanges <= 1999) targetLabel = "1000+";
|
||||
else if (totalChanges <= 4999) targetLabel = "2000+";
|
||||
else targetLabel = "5000+";
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number: issueNumber, per_page: 100,
|
||||
});
|
||||
|
||||
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
|
||||
|
||||
for (const name of sizeLabels) {
|
||||
if (name !== targetLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: issueNumber, name,
|
||||
}).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLabels.some(label => label.name === targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: issueNumber, labels: [targetLabel],
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
|
||||
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
|
||||
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
|
||||
}
|
||||
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
core.info("Manual trigger detected. Fetching all open pull requests...");
|
||||
|
||||
const openPRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: "open", per_page: 100,
|
||||
});
|
||||
|
||||
const unlabelledPRs = openPRs.filter(pr => {
|
||||
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
|
||||
});
|
||||
|
||||
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
|
||||
|
||||
for (const pr of unlabelledPRs) {
|
||||
core.info(`----------------------------------------`);
|
||||
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
|
||||
await processPR(pr.number);
|
||||
}
|
||||
} else {
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) {
|
||||
core.info("No pull request found in payload, skipping.");
|
||||
return;
|
||||
}
|
||||
await processPR(pr.number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Check Codes Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Check Codes"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: Checkout Script
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/check-codes-comment.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: checkstyle-artifact
|
||||
path: ${{ runner.temp }}/checkstyle-artifact
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate Review Comment
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REPORTS_PATH: ${{ runner.temp }}/checkstyle-artifact/ALL_REPORTS.jsonl
|
||||
PULL_REQUEST_NUMBER_PATH: ${{ runner.temp }}/checkstyle-artifact/PULL_REQUEST_NUMBER
|
||||
with:
|
||||
script: |
|
||||
const script = require("./.github/scripts/check-codes-comment.js");
|
||||
await script({ github, context, core });
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -27,3 +27,29 @@ jobs:
|
||||
cache-cleanup: never
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Setup reviewdog
|
||||
uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0
|
||||
with:
|
||||
reviewdog_version: v0.21.0
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Generate Artifact
|
||||
run: |
|
||||
echo "${{ github.event.number }}" > PULL_REQUEST_NUMBER
|
||||
> ALL_REPORTS.jsonl
|
||||
find . -path "*/build/reports/checkstyle/*.xml" | while read -r xml_file; do
|
||||
if [ -s "$xml_file" ]; then
|
||||
echo "Processing $xml_file..."
|
||||
reviewdog -f=checkstyle -name="checkstyle" -reporter=rdjsonl < "$xml_file" >> ALL_REPORTS.jsonl
|
||||
else
|
||||
echo "Skipping empty or missing report: $xml_file"
|
||||
fi
|
||||
done
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: checkstyle-artifact
|
||||
path: |
|
||||
ALL_REPORTS.jsonl
|
||||
PULL_REQUEST_NUMBER
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -24,30 +24,34 @@ jobs:
|
||||
with:
|
||||
cache-cleanup: never
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build --no-daemon --parallel
|
||||
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- name: Upload JAR
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-jar
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.jar
|
||||
HMCL/build/libs/HMCL-*.jar.sha256
|
||||
path: HMCL/build/libs/HMCL-*.jar
|
||||
archive: false
|
||||
- name: Upload EXE
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-exe
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.exe
|
||||
HMCL/build/libs/HMCL-*.exe.sha256
|
||||
path: HMCL/build/libs/HMCL-*.exe
|
||||
archive: false
|
||||
- name: Upload SH
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-sh
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.sh
|
||||
HMCL/build/libs/HMCL-*.sh.sha256
|
||||
path: HMCL/build/libs/HMCL-*.sh
|
||||
archive: false
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-deb
|
||||
path: HMCL/build/libs/HMCL-*.deb
|
||||
archive: false
|
||||
@@ -0,0 +1,31 @@
|
||||
name: PR Size Label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/pr-size-label.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label PR by filtered changed lines
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const script = require("./.github/scripts/pr-size-label.js");
|
||||
await script({ github, context, core });
|
||||
@@ -46,16 +46,20 @@ jobs:
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
|
||||
- name: Verify artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
|
||||
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
|
||||
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
|
||||
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
|
||||
|
||||
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
|
||||
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
|
||||
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
|
||||
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -67,6 +71,7 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
|
||||
|
||||
# CNB Release Note
|
||||
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
|
||||
@@ -76,6 +81,8 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
|
||||
env:
|
||||
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
|
||||
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
|
||||
@@ -86,6 +93,7 @@ jobs:
|
||||
"HMCL-${{ env.HMCL_VERSION }}.exe" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.jar" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.sh" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.deb" \
|
||||
--target "${{ env.HMCL_COMMIT_SHA }}" \
|
||||
--title "${{ env.HMCL_TAG_NAME }}" \
|
||||
--notes-file RELEASE_NOTE \
|
||||
@@ -120,6 +128,9 @@ jobs:
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.sh"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.deb"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
|
||||
CNB_REPO: HMCL-dev/HMCL
|
||||
+13
-1
@@ -4,7 +4,10 @@
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
*.jfr
|
||||
|
||||
.gradle
|
||||
gradle.properties
|
||||
|
||||
*.lck
|
||||
*.1
|
||||
@@ -16,6 +19,8 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
hmcl-mod-update-list-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
@@ -43,6 +48,7 @@ language-subtag-registry
|
||||
# eclipse
|
||||
/bin/
|
||||
/HMCL/bin/
|
||||
/HMCLBoot/bin/
|
||||
/HMCLCore/bin/
|
||||
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
||||
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
||||
@@ -64,4 +70,10 @@ language-subtag-registry
|
||||
# test
|
||||
/hmcl.json
|
||||
/.hmcl.json
|
||||
/.hmcl/
|
||||
/.hmcl/
|
||||
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Hello Minecraft! Launcher Copyright (C) &#36;today.year 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/>." />
|
||||
<option name="myName" value="HMCL" />
|
||||
</copyright>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="HMCL">
|
||||
<LanguageOptions name="JAVA">
|
||||
<option name="fileTypeOverride" value="3" />
|
||||
<option name="addBlankAfter" value="false" />
|
||||
</LanguageOptions>
|
||||
</settings>
|
||||
</component>
|
||||
@@ -0,0 +1,25 @@
|
||||
# Java Code Style Requirements
|
||||
|
||||
These rules apply to all Java code written or modified in this repository.
|
||||
|
||||
## Nullability
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
|
||||
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
||||
- Nullability in code being written or modified must never be implicit.
|
||||
|
||||
## Immutability
|
||||
|
||||
- Immutable arrays and collections must be explicitly annotated with JetBrains Annotations `@Unmodifiable` or `@UnmodifiableView` as appropriate.
|
||||
- For arrays, use type-use syntax such as `String @Unmodifiable []`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class, field, and method must have documentation.
|
||||
- Documentation must use `///` Markdown-style Javadoc comments.
|
||||
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
|
||||
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
|
||||
+64
-18
@@ -4,8 +4,11 @@ import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
@@ -50,22 +53,27 @@ if (buildNumber != null) {
|
||||
}
|
||||
}
|
||||
|
||||
val embedResources by configurations.registering
|
||||
val embedResources = configurations.register("embedResources")
|
||||
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.fxsvgimage)
|
||||
implementation(libs.java.info)
|
||||
implementation(libs.monet.fx)
|
||||
implementation(libs.nayuki.qrcodegen)
|
||||
implementation(libs.uuid.tools)
|
||||
|
||||
testImplementation(libs.jimfs)
|
||||
|
||||
if (launcherExe.isBlank()) {
|
||||
implementation(libs.hmclauncher)
|
||||
}
|
||||
|
||||
embedResources(libs.authlib.injector)
|
||||
embedResources(libs.lwjgl.unsafe.agent)
|
||||
}
|
||||
|
||||
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
|
||||
@@ -155,10 +163,11 @@ val hmclProperties = buildList {
|
||||
add("hmcl.microsoft.auth.id" to microsoftAuthId)
|
||||
add("hmcl.curseforge.apikey" to curseForgeApiKey)
|
||||
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
|
||||
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
|
||||
}
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
val createPropertiesFile by tasks.registering {
|
||||
val createPropertiesFile = tasks.register("createPropertiesFile") {
|
||||
outputs.file(hmclPropertiesFile)
|
||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
||||
|
||||
@@ -192,7 +201,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-*",
|
||||
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
|
||||
).forEach { exclude("com/sun/jna/$it/**") }
|
||||
|
||||
@@ -248,20 +257,22 @@ tasks.processResources {
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
val makeExecutables = tasks.register("makeExecutables") {
|
||||
val extensions = listOf("exe", "sh")
|
||||
|
||||
dependsOn(tasks.jar)
|
||||
|
||||
inputs.file(jarPath)
|
||||
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
|
||||
outputs.files(extensions.map { artifactFile(it) })
|
||||
|
||||
doLast {
|
||||
val jarContent = jarPath.readBytes()
|
||||
|
||||
ZipFile(jarPath).use { zipFile ->
|
||||
for (extension in extensions) {
|
||||
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
|
||||
val output = artifactFile(extension)
|
||||
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
|
||||
?: throw GradleException("HMCLauncher.$extension not found")
|
||||
|
||||
@@ -276,8 +287,32 @@ val makeExecutables by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
|
||||
dependsOn(makeExecutables)
|
||||
|
||||
val debFile = layout.file(provider { artifactFile("deb") })
|
||||
|
||||
val debChannel = when (versionType) {
|
||||
"stable" -> ReleaseType.STABLE
|
||||
"dev" -> ReleaseType.DEVELOPMENT
|
||||
else -> ReleaseType.NIGHTLY
|
||||
}
|
||||
|
||||
version.set(project.version.toString())
|
||||
releaseType.set(debChannel)
|
||||
launcherClassName.set("org.jackhuang.hmcl.Launcher")
|
||||
appShFile.set(layout.file(provider { artifactFile("sh") }))
|
||||
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
|
||||
outputFile.set(debFile)
|
||||
|
||||
doLast {
|
||||
createChecksum(debFile.get().asFile)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn(makeExecutables)
|
||||
dependsOn(makeDeb)
|
||||
}
|
||||
|
||||
fun parseToolOptions(options: String?): MutableList<String> {
|
||||
@@ -374,12 +409,14 @@ val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgra
|
||||
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
|
||||
val source = layout.projectDirectory.file("terracotta-template.json");
|
||||
|
||||
classifiers.set(listOf(
|
||||
"windows-x86_64", "windows-arm64",
|
||||
"macos-x86_64", "macos-arm64",
|
||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
||||
"freebsd-x86_64"
|
||||
))
|
||||
classifiers.set(
|
||||
listOf(
|
||||
"windows-x86_64", "windows-arm64",
|
||||
"macos-x86_64", "macos-arm64",
|
||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
||||
"freebsd-x86_64"
|
||||
)
|
||||
)
|
||||
|
||||
version.set(libs.versions.terracotta)
|
||||
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
|
||||
@@ -394,28 +431,37 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
|
||||
englishFile.set(dir.file("I18N.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
|
||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||
}
|
||||
|
||||
// l10n
|
||||
|
||||
tasks.register<SyncTranslations>("syncTranslations") {
|
||||
group = "localization"
|
||||
description = "Synchronizes translation keys and blank lines with I18N.properties."
|
||||
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
sourceFile.set(dir.file("I18N.properties"))
|
||||
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
|
||||
}
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
|
||||
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
|
||||
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
|
||||
}
|
||||
|
||||
val createLanguageList by tasks.registering(CreateLanguageList::class) {
|
||||
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
|
||||
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||
resourceBundleBaseName.set("I18N")
|
||||
additionalLanguages.set(listOf("en-Qabs"))
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXCheckBoxSkin;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.CheckBoxTreeItem;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
|
||||
///
|
||||
/// The checkbox's selected and indeterminate states are bidirectionally bound to
|
||||
/// the current tree item. Reusing or clearing the cell removes the old bindings.
|
||||
/// Installing a checkbox skin or switching tree items applies the selection state
|
||||
/// immediately, without playing a selection animation.
|
||||
/// Ordinary tree items retain the display provided by [JFXTreeCell].
|
||||
/// Empty cells and `null` values display neither content nor a checkbox.
|
||||
///
|
||||
/// @param <T> the type of value stored in each tree item
|
||||
@NotNullByDefault
|
||||
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
|
||||
|
||||
/// The checkbox bound to the currently displayed checkable tree item.
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
|
||||
/// Places the checkbox before the graphic supplied by the superclass.
|
||||
private final HBox graphic = new HBox();
|
||||
|
||||
/// The tree item whose selection properties are currently bound, if any.
|
||||
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
|
||||
|
||||
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
|
||||
public JFXCheckTreeCell() {
|
||||
checkBox.setAllowIndeterminate(false);
|
||||
checkBox.setFocusTraversable(false);
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
graphic.setPickOnBounds(false);
|
||||
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
|
||||
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
|
||||
}
|
||||
|
||||
/// Finishes animations from the previous item and immediately displays the current state.
|
||||
private void finishSelectionAnimation() {
|
||||
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
|
||||
skin.finishSelectionAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the checkbox to the inherited display and updates its selection bindings.
|
||||
@Override
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
// Release graphics before the superclass reuses or reparents them.
|
||||
graphic.getChildren().clear();
|
||||
super.updateDisplay(item, empty);
|
||||
|
||||
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
|
||||
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
|
||||
if (boundTreeItem != treeItem) {
|
||||
if (boundTreeItem != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
|
||||
}
|
||||
boundTreeItem = treeItem;
|
||||
if (treeItem != null) {
|
||||
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
|
||||
} else {
|
||||
checkBox.setSelected(false);
|
||||
checkBox.setIndeterminate(false);
|
||||
}
|
||||
finishSelectionAnimation();
|
||||
}
|
||||
|
||||
if (treeItem != null) {
|
||||
@Nullable Node itemGraphic = getGraphic();
|
||||
graphic.getChildren().add(checkBox);
|
||||
if (itemGraphic != null) {
|
||||
graphic.getChildren().add(itemGraphic);
|
||||
}
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
private void initialize() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
this.setCellFactory(listView -> new JFXListCell<T>() {
|
||||
this.setCellFactory(listView -> new JFXListCell<>() {
|
||||
@Override
|
||||
public void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
// had to refactor the code out of the skin class to allow
|
||||
// customization of the button cell
|
||||
this.setButtonCell(new ListCell<T>() {
|
||||
this.setButtonCell(new ListCell<>() {
|
||||
{
|
||||
// fixed clearing the combo box value is causing
|
||||
// java prompt text to be shown because the button cell is not updated
|
||||
|
||||
@@ -23,21 +23,13 @@ import com.jfoenix.controls.events.JFXDialogEvent;
|
||||
import com.jfoenix.converters.DialogTransitionConverter;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ObjectPropertyBase;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.SimpleStyleableObjectProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableObjectProperty;
|
||||
import javafx.css.StyleableProperty;
|
||||
import javafx.css.*;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -49,7 +41,6 @@ import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
@@ -155,7 +146,6 @@ public class JFXDialog extends StackPane {
|
||||
});
|
||||
|
||||
contentHolder = new StackPane();
|
||||
contentHolder.setBackground(new Background(new BackgroundFill(Color.WHITE, new CornerRadii(2), null)));
|
||||
JFXDepthManager.setDepth(contentHolder, 4);
|
||||
contentHolder.setPickOnBounds(false);
|
||||
// ensure stackpane is never resized beyond it's preferred size
|
||||
@@ -381,6 +371,14 @@ public class JFXDialog extends StackPane {
|
||||
setCycleDuration(Duration.seconds(0.4));
|
||||
setDelay(Duration.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void starting() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void stopping() {
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
|
||||
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
|
||||
*/
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
T oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
|
||||
super.updateItem(item, empty);
|
||||
if (empty) {
|
||||
setText(null);
|
||||
|
||||
@@ -38,7 +38,6 @@ import javafx.scene.paint.Paint;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
@@ -68,14 +67,18 @@ public class JFXPasswordField extends PasswordField {
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
|
||||
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
|
||||
}
|
||||
|
||||
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
|
||||
// https://github.com/HMCL-dev/HMCL/issues/5822
|
||||
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-password-field'.
|
||||
* <p>
|
||||
@@ -294,4 +297,3 @@ public class JFXPasswordField extends PasswordField {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXProgressBarSkin;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.scene.control.ProgressBar;
|
||||
import javafx.scene.control.Skin;
|
||||
|
||||
@@ -51,26 +49,18 @@ public class JFXProgressBar extends ProgressBar {
|
||||
return new JFXProgressBarSkin(this);
|
||||
}
|
||||
|
||||
private boolean smoothProgress = true;
|
||||
|
||||
public boolean isSmoothProgress() {
|
||||
return smoothProgress;
|
||||
}
|
||||
|
||||
public void setSmoothProgress(boolean smoothProgress) {
|
||||
this.smoothProgress = smoothProgress;
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
setPrefWidth(200);
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
|
||||
private DoubleProperty secondaryProgress;
|
||||
|
||||
public DoubleProperty secondaryProgressProperty() {
|
||||
if (secondaryProgress == null) {
|
||||
secondaryProgress = new SimpleDoubleProperty(this, "secondaryProgress", INDETERMINATE_PROGRESS);
|
||||
}
|
||||
return secondaryProgress;
|
||||
}
|
||||
|
||||
public double getSecondaryProgress() {
|
||||
return secondaryProgress == null ? INDETERMINATE_PROGRESS : secondaryProgress.get();
|
||||
}
|
||||
|
||||
public void setSecondaryProgress(double secondaryProgress) {
|
||||
secondaryProgressProperty().set(secondaryProgress);
|
||||
}
|
||||
}
|
||||
@@ -231,10 +231,11 @@ public class JFXRippler extends StackPane {
|
||||
*
|
||||
* @return the ripple radius size
|
||||
*/
|
||||
protected double computeRippleRadius() {
|
||||
double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
|
||||
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
|
||||
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
|
||||
protected double computeRippleRadius(double centerX, double centerY) {
|
||||
double dx = Math.max(centerX, control.getLayoutBounds().getWidth() - centerX);
|
||||
double dy = Math.max(centerY, control.getLayoutBounds().getHeight() - centerY);
|
||||
|
||||
return (Math.sqrt(dx * dx + dy * dy) / 0.9) + 2;
|
||||
}
|
||||
|
||||
protected void setOverLayBounds(Rectangle overlay) {
|
||||
@@ -277,6 +278,10 @@ public class JFXRippler extends StackPane {
|
||||
rippler.releaseRipple();
|
||||
}
|
||||
|
||||
public void releaseRippleImmediately() {
|
||||
rippler.releaseRippleImmediately();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates Ripple effect in the center of the control
|
||||
*
|
||||
@@ -404,6 +409,21 @@ public class JFXRippler extends StackPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseRippleImmediately() {
|
||||
Ripple ripple = ripplesQueue.poll();
|
||||
if (ripple != null) {
|
||||
getChildren().remove(ripple);
|
||||
if (generating.getAndSet(false)) {
|
||||
if (overlayRect != null) {
|
||||
overlayRect.inAnimation.stop();
|
||||
if (!forceOverlay) {
|
||||
overlayRect.setOpacity(0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cacheRippleClip(boolean cached) {
|
||||
cacheRipplerClip = cached;
|
||||
}
|
||||
@@ -473,7 +493,7 @@ public class JFXRippler extends StackPane {
|
||||
super(centerX,
|
||||
centerY,
|
||||
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
|
||||
computeRippleRadius() : getRipplerRadius(), null);
|
||||
computeRippleRadius(centerX, centerY) : getRipplerRadius(), null);
|
||||
setCache(true);
|
||||
setCacheHint(CacheHint.SPEED);
|
||||
setCacheShape(true);
|
||||
@@ -518,7 +538,7 @@ public class JFXRippler extends StackPane {
|
||||
new KeyValue(opacityProperty(),
|
||||
1,
|
||||
RIPPLE_INTERPOLATOR)
|
||||
), new KeyFrame(Duration.millis(900), inKeyValues));
|
||||
), new KeyFrame(Duration.millis(600), inKeyValues));
|
||||
|
||||
setScaleX(0);
|
||||
setScaleY(0);
|
||||
|
||||
@@ -54,7 +54,8 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
StackPane toastContainer = new StackPane(toast);
|
||||
toastContainer.setPadding(new Insets(20));
|
||||
actionContainer = new StackPane();
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
actionContainer.setVisible(false);
|
||||
actionContainer.setManaged(false);
|
||||
|
||||
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
||||
if (getPrefWidth() == -1) {
|
||||
@@ -78,6 +79,7 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
actionContainer.getChildren().add(action);
|
||||
|
||||
if (!actionText.isEmpty()) {
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
action.setVisible(true);
|
||||
actionContainer.setVisible(true);
|
||||
actionContainer.setManaged(true);
|
||||
|
||||
@@ -31,6 +31,7 @@ import javafx.scene.control.Skin;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/// JFXSpinner is the material design implementation of a loading spinner.
|
||||
///
|
||||
@@ -71,10 +72,10 @@ public class JFXSpinner extends ProgressIndicator {
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-spinner";
|
||||
|
||||
private static final double DEFAULT_RADIUS = 12.0;
|
||||
private static final double DEFAULT_RADIUS = 16.0;
|
||||
|
||||
/**
|
||||
* specifies the radius of the spinner node, by default it's set to `12.0`
|
||||
* specifies the radius of the spinner node, by default it's set to `16.0`
|
||||
*/
|
||||
private StyleableDoubleProperty radius;
|
||||
|
||||
@@ -96,25 +97,8 @@ public class JFXSpinner extends ProgressIndicator {
|
||||
this.radiusProperty().set(radius);
|
||||
}
|
||||
|
||||
/// specifies from which angle the spinner should start spinning
|
||||
private StyleableDoubleProperty startingAngle;
|
||||
|
||||
public final StyleableDoubleProperty startingAngleProperty() {
|
||||
if (this.startingAngle == null) {
|
||||
startingAngle = new SimpleStyleableDoubleProperty(StyleableProperties.STARTING_ANGLE,
|
||||
JFXSpinner.this,
|
||||
"startingAngle",
|
||||
0.0);
|
||||
}
|
||||
return this.startingAngle;
|
||||
}
|
||||
|
||||
public final double getStartingAngle() {
|
||||
return startingAngle != null ? startingAngle.get() : 0.0;
|
||||
}
|
||||
|
||||
public final void setStartingAngle(final double startingAngle) {
|
||||
this.startingAngleProperty().set(startingAngle);
|
||||
public double getStartingAngle() {
|
||||
return 360 - ThreadLocalRandom.current().nextDouble() * 720;
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
@@ -132,26 +116,12 @@ public class JFXSpinner extends ProgressIndicator {
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXSpinner, Number> STARTING_ANGLE =
|
||||
new CssMetaData<>("-jfx-starting-angle",
|
||||
SizeConverter.getInstance(), 0.0) {
|
||||
@Override
|
||||
public boolean isSettable(JFXSpinner control) {
|
||||
return control.startingAngle == null || !control.startingAngle.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) {
|
||||
return control.startingAngleProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(ProgressIndicator.getClassCssMetaData());
|
||||
Collections.addAll(styleables, RADIUS, STARTING_ANGLE);
|
||||
Collections.addAll(styleables, RADIUS);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@ public class JFXTextField extends TextField {
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/5822
|
||||
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Properties *
|
||||
|
||||
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
treeItemRef = new WeakReference<>(newTreeItem);
|
||||
}
|
||||
};
|
||||
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
|
||||
treeItemProperty().addListener(weakTreeItemListener);
|
||||
treeItemProperty().addListener(treeItemInvalidationListener);
|
||||
if (getTreeItem() != null) {
|
||||
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||
}
|
||||
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
selectedPane.setVisible(isSelected());
|
||||
}
|
||||
|
||||
private void updateDisplay(T item, boolean empty) {
|
||||
/// Updates the text and graphic for the current value and tree item.
|
||||
///
|
||||
/// @implSpec Called when the cell value or the current tree item's graphic changes.
|
||||
/// Subclasses may override this method to decorate the default display.
|
||||
///
|
||||
/// @param item the cell value, or `null` to clear the display
|
||||
/// @param empty whether the cell has no item
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
if (item == null || empty) {
|
||||
hbox = null;
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
TreeItem<T> treeItem = getTreeItem();
|
||||
@Nullable TreeItem<T> treeItem = getTreeItem();
|
||||
if (treeItem != null && treeItem.getGraphic() != null) {
|
||||
if (item instanceof Node) {
|
||||
setText(null);
|
||||
|
||||
@@ -42,11 +42,11 @@ public final class JFXDepthManager {
|
||||
|
||||
private static final DropShadow[] depth = new DropShadow[] {
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 1),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 6, 0, 0, 2),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 10, 0, 0, 3),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 14, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 6)
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 0),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 8, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 12, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 16, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 8)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,31 +12,20 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import com.jfoenix.transitions.JFXFillTransition;
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.skin.CheckBoxSkin;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.Border;
|
||||
import javafx.scene.layout.BorderStroke;
|
||||
import javafx.scene.layout.BorderStrokeStyle;
|
||||
import javafx.scene.layout.BorderWidths;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
@@ -180,21 +169,41 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
};
|
||||
}
|
||||
|
||||
private void playSelectAnimation(Boolean selection) {
|
||||
if (selection == null) {
|
||||
selection = false;
|
||||
}
|
||||
|
||||
/// Animates a selection change, or applies it immediately when animations are disabled.
|
||||
private void playSelectAnimation(boolean selection) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
|
||||
this.box.setBorder(new Border(new BorderStroke(
|
||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
BorderStrokeStyle.SOLID,
|
||||
new CornerRadii(2.0),
|
||||
new BorderWidths(this.lineThick))));
|
||||
|
||||
if (!AnimationUtils.isAnimationEnabled()) {
|
||||
finishSelectionAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
}
|
||||
|
||||
/// Stops selection animations and immediately displays the checkbox's current selected state.
|
||||
///
|
||||
/// The check mark, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses the initial selection animation on the first layout.
|
||||
/// The skin must not have been disposed.
|
||||
public void finishSelectionAnimation() {
|
||||
transition.stop();
|
||||
select.stop();
|
||||
invalid = false;
|
||||
boolean selected = getSkinnable().isSelected();
|
||||
mark.setVisible(selected);
|
||||
mark.setScaleX(selected ? 1.0 : 0.0);
|
||||
mark.setScaleY(selected ? 1.0 : 0.0);
|
||||
updateColors();
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
|
||||
import com.jfoenix.concurrency.JFXUtilities;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.EventTarget;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
protected final ObjectProperty<Paint> promptTextFill;
|
||||
private boolean invalid = true;
|
||||
private final StackPane customPane;
|
||||
private final StackPane line = new StackPane();
|
||||
private final StackPane focusedLine = new StackPane();
|
||||
private final Text promptText = new Text();
|
||||
private final double initScale = 0.05;
|
||||
private final Scale scale;
|
||||
private final Timeline linesAnimation;
|
||||
private ParallelTransition transition;
|
||||
private CachedTransition promptTextUpTransition;
|
||||
private CachedTransition promptTextDownTransition;
|
||||
private CachedTransition promptTextColorTransition;
|
||||
private final Scale promptTextScale;
|
||||
private Paint oldPromptTextFill;
|
||||
private final BooleanBinding usePromptText;
|
||||
|
||||
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
|
||||
super(comboBox);
|
||||
this.scale = new Scale(this.initScale, 1.0F);
|
||||
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
|
||||
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
|
||||
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
|
||||
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.promptText.textProperty().bind(comboBox.promptTextProperty());
|
||||
this.promptText.fillProperty().bind(this.promptTextFill);
|
||||
this.promptText.getStyleClass().addAll("text", "prompt-text");
|
||||
this.promptText.getTransforms().add(this.promptTextScale);
|
||||
if (!comboBox.isLabelFloat()) {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.customPane = new StackPane();
|
||||
this.customPane.setMouseTransparent(true);
|
||||
this.customPane.getStyleClass().add("combo-box-button-container");
|
||||
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
|
||||
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.customPane.getChildren().add(this.promptText);
|
||||
this.getChildren().add(0, this.customPane);
|
||||
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
|
||||
this.line.getStyleClass().add("input-line");
|
||||
this.focusedLine.getStyleClass().add("input-focused-line");
|
||||
this.getChildren().add(this.line);
|
||||
this.getChildren().add(this.focusedLine);
|
||||
this.line.setPrefHeight(1.0F);
|
||||
this.line.setTranslateY(1.0F);
|
||||
this.line.setManaged(false);
|
||||
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (this.getSkinnable().isDisabled()) {
|
||||
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
|
||||
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
this.focusedLine.setPrefHeight(2.0F);
|
||||
this.focusedLine.setTranslateY(0.0F);
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
this.focusedLine.getTransforms().add(this.scale);
|
||||
this.focusedLine.setManaged(false);
|
||||
if (comboBox.isEditable()) {
|
||||
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
|
||||
comboBox.getEditor().promptTextProperty().unbind();
|
||||
comboBox.getEditor().setPromptText(null);
|
||||
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.usePromptText.invalidate();
|
||||
comboBox.setValue(this.getConverter().fromString(newVal));
|
||||
});
|
||||
}
|
||||
|
||||
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.promptText.visibleProperty().unbind();
|
||||
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
|
||||
} else {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.createFocusTransition();
|
||||
});
|
||||
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
});
|
||||
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.unFocus();
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for (Node child : getChildren()) {
|
||||
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
|
||||
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
|
||||
// We implement hideOnClick ourselves to prevent the popup from
|
||||
// hiding when releasing the mouse on the scrollbar.
|
||||
|
||||
this.setHideOnClick(false);
|
||||
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
|
||||
EventTarget target = t.getTarget();
|
||||
if (target instanceof Node targetNode) {
|
||||
List<String> s = targetNode.getStyleClass();
|
||||
if (s.contains("thumb")
|
||||
|| s.contains("track")
|
||||
|| s.contains("decrement-arrow")
|
||||
|| s.contains("increment-arrow")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
this.customPane.resizeRelocate(x, y, w, h);
|
||||
if (this.invalid) {
|
||||
this.invalid = false;
|
||||
if (!this.getSkinnable().isEditable()) {
|
||||
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
|
||||
if (javaPromptText != null) {
|
||||
this.promptTextFill.set(javaPromptText.getFill());
|
||||
}
|
||||
}
|
||||
|
||||
this.createFloatingAnimation();
|
||||
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
|
||||
this.animateFloatingLabel(true);
|
||||
}
|
||||
}
|
||||
|
||||
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
|
||||
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
|
||||
this.scale.setPivotX(w / (double) 2.0F);
|
||||
}
|
||||
|
||||
private void createFloatingAnimation() {
|
||||
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition.setOnFinished((finish) -> {
|
||||
this.promptText.setTranslateY(0.0F);
|
||||
this.promptTextScale.setX(1.0F);
|
||||
this.promptTextScale.setY(1.0F);
|
||||
});
|
||||
}
|
||||
|
||||
private void focus() {
|
||||
if (this.transition == null) {
|
||||
this.createFocusTransition();
|
||||
}
|
||||
|
||||
this.transition.play();
|
||||
}
|
||||
|
||||
private void animateFloatingLabel(boolean up) {
|
||||
if (this.promptText == null) {
|
||||
Platform.runLater(() -> this.animateFloatingLabel(up));
|
||||
} else {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
this.transition.getChildren().remove(this.promptTextUpTransition);
|
||||
this.transition.getChildren().remove(this.promptTextColorTransition);
|
||||
this.transition = null;
|
||||
}
|
||||
|
||||
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
|
||||
this.promptTextDownTransition.stop();
|
||||
this.promptTextUpTransition.play();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextColorTransition.play();
|
||||
}
|
||||
} else if (!up) {
|
||||
this.promptTextUpTransition.stop();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
}
|
||||
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void createFocusTransition() {
|
||||
this.transition = new ParallelTransition();
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.transition.getChildren().add(this.promptTextUpTransition);
|
||||
this.transition.getChildren().add(this.promptTextColorTransition);
|
||||
}
|
||||
|
||||
this.transition.getChildren().add(this.linesAnimation);
|
||||
}
|
||||
|
||||
private void unFocus() {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
}
|
||||
|
||||
this.scale.setX(this.initScale);
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
if (this.usePromptText()) {
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean usePromptText() {
|
||||
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
|
||||
String promptTxt = this.getSkinnable().getPromptText();
|
||||
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,16 @@ import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/**
|
||||
* @author Shadi Shaheen
|
||||
*/
|
||||
@@ -78,7 +82,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
|
||||
// create JFX Decorator
|
||||
pickerDecorator = new JFXDecorator(dialog, this, false, false, false);
|
||||
pickerDecorator.setOnCloseButtonAction(this::updateColor);
|
||||
pickerDecorator.setOnCloseButtonAction(this::close);
|
||||
pickerDecorator.setPickOnBounds(false);
|
||||
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
|
||||
StyleSheets.init(customScene);
|
||||
@@ -153,6 +157,22 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
paraTransition.play();
|
||||
});
|
||||
|
||||
container.getChildren().add(tabs);
|
||||
|
||||
HBox actionsHBox = new HBox();
|
||||
actionsHBox.getStyleClass().add("jfx-color-dialog-actions");
|
||||
|
||||
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
|
||||
acceptButton.setOnAction(event -> updateColor());
|
||||
acceptButton.getStyleClass().add("jfx-color-dialog-accept");
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(event -> close());
|
||||
cancelButton.getStyleClass().add("jfx-color-dialog-cancel");
|
||||
|
||||
actionsHBox.getChildren().addAll(acceptButton, cancelButton);
|
||||
container.getChildren().add(actionsHBox);
|
||||
|
||||
initRun = () -> {
|
||||
// change tabs labels font color according to the selected color
|
||||
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
|
||||
@@ -179,7 +199,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
(int) (newColor.getRed() * 255),
|
||||
(int) (newColor.getGreen() * 255),
|
||||
(int) (newColor.getBlue() * 255));
|
||||
String rgb = String.format("rgba(%d, %d, %d, 1)",
|
||||
String rgb = String.format("rgb(%d, %d, %d)",
|
||||
(int) (newColor.getRed() * 255),
|
||||
(int) (newColor.getGreen() * 255),
|
||||
(int) (newColor.getBlue() * 255));
|
||||
@@ -232,7 +252,13 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
return pane.getBackground().getFills().get(0).getFill();
|
||||
}, pane.backgroundProperty()));
|
||||
|
||||
acceptButton.textFillProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
Color fill = (Color) pane.getBackground().getFills().get(0).getFill();
|
||||
return ColorScheme.newBuilder(Themes.getColorScheme())
|
||||
.setPrimaryColorSeed(fill)
|
||||
.build()
|
||||
.getPrimary();
|
||||
}, pane.backgroundProperty()));
|
||||
|
||||
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
|
||||
.bind(Bindings.createObjectBinding(() -> {
|
||||
@@ -262,9 +288,6 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
container.getChildren().add(tabs);
|
||||
|
||||
this.getChildren().add(container);
|
||||
this.setPadding(new Insets(0));
|
||||
|
||||
@@ -294,7 +317,11 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
if (!systemChange) {
|
||||
userChange = true;
|
||||
try {
|
||||
curvedColorPicker.setColor(Color.valueOf(colorWebString));
|
||||
Color color = Color.valueOf(colorWebString);
|
||||
if (color.getOpacity() != 1.0) {
|
||||
color = Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||
}
|
||||
curvedColorPicker.setColor(color);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// if color is not valid then do nothing
|
||||
}
|
||||
@@ -309,6 +336,9 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
|
||||
public void setCurrentColor(Color currentColor) {
|
||||
this.currentColorProperty.set(currentColor);
|
||||
if (curvedColorPicker != null && currentColor != null) {
|
||||
curvedColorPicker.setColor(currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
Color getCurrentColor() {
|
||||
|
||||
@@ -20,61 +20,38 @@
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXProgressBar;
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import com.jfoenix.utils.TreeShowingProperty;
|
||||
import javafx.animation.*;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.skin.ProgressIndicatorSkin;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
/// # Material Design ProgressBar Skin
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 2.0
|
||||
/// @since 2017-10-06
|
||||
public class JFXProgressBarSkin extends ProgressIndicatorSkin {
|
||||
public class JFXProgressBarSkin extends SkinBase<JFXProgressBar> {
|
||||
|
||||
private StackPane track;
|
||||
private StackPane secondaryBar;
|
||||
private StackPane bar;
|
||||
private double barWidth = 0;
|
||||
private double secondaryBarWidth = 0;
|
||||
private Timeline indeterminateTransition;
|
||||
private Region clip;
|
||||
private static final double DEFAULT_HEIGHT = 4;
|
||||
|
||||
private final StackPane track;
|
||||
private final StackPane bar;
|
||||
private final Rectangle clip;
|
||||
private Animation transition;
|
||||
private final TreeShowingProperty treeShowingProperty;
|
||||
private double fullWidth;
|
||||
|
||||
public JFXProgressBarSkin(JFXProgressBar bar) {
|
||||
super(bar);
|
||||
public JFXProgressBarSkin(JFXProgressBar control) {
|
||||
super(control);
|
||||
|
||||
this.treeShowingProperty = new TreeShowingProperty(bar);
|
||||
this.treeShowingProperty = new TreeShowingProperty(control);
|
||||
|
||||
bar.widthProperty().addListener(observable -> {
|
||||
updateProgress();
|
||||
updateSecondaryProgress();
|
||||
});
|
||||
|
||||
registerChangeListener(bar.progressProperty(), (obs) -> updateProgress());
|
||||
registerChangeListener(bar.secondaryProgressProperty(), obs -> updateSecondaryProgress());
|
||||
registerChangeListener(bar.visibleProperty(), obs -> updateAnimation());
|
||||
registerChangeListener(bar.parentProperty(), obs -> updateAnimation());
|
||||
registerChangeListener(bar.sceneProperty(), obs -> updateAnimation());
|
||||
|
||||
unregisterChangeListeners(treeShowingProperty);
|
||||
unregisterChangeListeners(bar.indeterminateProperty());
|
||||
|
||||
registerChangeListener(treeShowingProperty, obs -> this.updateAnimation());
|
||||
registerChangeListener(bar.indeterminateProperty(), obs -> initialize());
|
||||
|
||||
initialize();
|
||||
|
||||
getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
protected void initialize() {
|
||||
registerChangeListener(treeShowingProperty, obs -> updateProgress(false));
|
||||
registerChangeListener(control.progressProperty(), obs -> updateProgress(true));
|
||||
|
||||
track = new StackPane();
|
||||
track.getStyleClass().setAll("track");
|
||||
@@ -82,14 +59,14 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
|
||||
bar = new StackPane();
|
||||
bar.getStyleClass().setAll("bar");
|
||||
|
||||
secondaryBar = new StackPane();
|
||||
secondaryBar.getStyleClass().setAll("secondary-bar");
|
||||
clip = new Rectangle();
|
||||
clip.setArcWidth(DEFAULT_HEIGHT);
|
||||
clip.setArcHeight(DEFAULT_HEIGHT);
|
||||
bar.setClip(clip);
|
||||
|
||||
clip = new Region();
|
||||
clip.setBackground(new Background(new BackgroundFill(Color.BLACK, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
bar.backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(bar.getBackground(), clip));
|
||||
getChildren().setAll(track, bar);
|
||||
|
||||
getChildren().setAll(track, secondaryBar, bar);
|
||||
getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -104,7 +81,7 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return topInset + bar.prefHeight(width) + bottomInset;
|
||||
return topInset + DEFAULT_HEIGHT + bottomInset;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -120,105 +97,129 @@ public class JFXProgressBarSkin extends ProgressIndicatorSkin {
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
track.resizeRelocate(x, y, w, h);
|
||||
secondaryBar.resizeRelocate(x, y, secondaryBarWidth, h);
|
||||
bar.resizeRelocate(x, y, getSkinnable().isIndeterminate() ? w : barWidth, h);
|
||||
clip.resizeRelocate(0, 0, w, h);
|
||||
bar.resizeRelocate(x, y, w, h);
|
||||
|
||||
if (getSkinnable().isIndeterminate()) {
|
||||
createIndeterminateTimeline();
|
||||
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
|
||||
indeterminateTransition.play();
|
||||
}
|
||||
// apply clip
|
||||
bar.setClip(clip);
|
||||
} else if (indeterminateTransition != null) {
|
||||
clip.relocate(0, 0);
|
||||
clip.setWidth(0);
|
||||
clip.setHeight(h);
|
||||
clip.setTranslateX(0);
|
||||
|
||||
fullWidth = w;
|
||||
|
||||
clearAnimation();
|
||||
updateProgress(false);
|
||||
}
|
||||
|
||||
private boolean wasIndeterminate = false;
|
||||
|
||||
private void updateProgress(boolean playProgressAnimation) {
|
||||
double progress = Math.min(getSkinnable().getProgress(), 1.0);
|
||||
boolean isIndeterminate = progress < 0.0;
|
||||
boolean isTreeShowing = treeShowingProperty.get();
|
||||
|
||||
if (isIndeterminate != wasIndeterminate) {
|
||||
wasIndeterminate = isIndeterminate;
|
||||
clearAnimation();
|
||||
// remove clip
|
||||
bar.setClip(null);
|
||||
clip.setTranslateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateSecondaryProgress() {
|
||||
final JFXProgressBar control = (JFXProgressBar) getSkinnable();
|
||||
secondaryBarWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
|
||||
* Math.min(1, Math.max(0, control.getSecondaryProgress()))) / 2.0F;
|
||||
control.requestLayout();
|
||||
}
|
||||
|
||||
boolean wasIndeterminate = false;
|
||||
|
||||
protected void pauseTimeline(boolean pause) {
|
||||
if (getSkinnable().isIndeterminate()) {
|
||||
if (indeterminateTransition == null) {
|
||||
createIndeterminateTimeline();
|
||||
if (isIndeterminate) { // indeterminate
|
||||
if (isTreeShowing) {
|
||||
if (transition == null) {
|
||||
transition = createIndeterminateTransition();
|
||||
transition.playFromStart();
|
||||
} else {
|
||||
transition.play();
|
||||
}
|
||||
} else if (transition != null) {
|
||||
transition.pause();
|
||||
}
|
||||
if (pause) {
|
||||
indeterminateTransition.pause();
|
||||
} else { // determinate
|
||||
clearAnimation();
|
||||
if (isTreeShowing && playProgressAnimation
|
||||
&& AnimationUtils.isAnimationEnabled()
|
||||
&& getSkinnable().isSmoothProgress()) {
|
||||
transition = createDeterminateTransition(progress);
|
||||
transition.playFromStart();
|
||||
} else {
|
||||
indeterminateTransition.play();
|
||||
clip.setWidth(computeBarWidth(progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAnimation() {
|
||||
final boolean isTreeShowing = treeShowingProperty.get();
|
||||
if (indeterminateTransition != null) {
|
||||
pauseTimeline(!isTreeShowing);
|
||||
} else if (isTreeShowing) {
|
||||
createIndeterminateTimeline();
|
||||
}
|
||||
private static final Duration INDETERMINATE_DURATION = Duration.seconds(1);
|
||||
|
||||
private Transition createIndeterminateTransition() {
|
||||
double minWidth = 0;
|
||||
double maxWidth = fullWidth * 0.4;
|
||||
Transition transition = new Transition() {
|
||||
{
|
||||
setInterpolator(Interpolator.LINEAR);
|
||||
setCycleDuration(INDETERMINATE_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void interpolate(double frac) {
|
||||
double currentWidth;
|
||||
|
||||
if (frac <= 0.5) {
|
||||
currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5);
|
||||
} else {
|
||||
currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5);
|
||||
}
|
||||
|
||||
double targetCenter;
|
||||
if (frac <= 0.1) {
|
||||
targetCenter = 0.0;
|
||||
} else if (frac >= 0.9) {
|
||||
targetCenter = fullWidth;
|
||||
} else {
|
||||
targetCenter = ((frac - 0.1) / 0.8) * fullWidth;
|
||||
}
|
||||
|
||||
clip.setWidth(currentWidth);
|
||||
clip.setTranslateX(targetCenter - currentWidth / 2.0);
|
||||
}
|
||||
};
|
||||
|
||||
transition.setCycleCount(Timeline.INDEFINITE);
|
||||
return transition;
|
||||
}
|
||||
|
||||
private void updateProgress() {
|
||||
final ProgressIndicator control = getSkinnable();
|
||||
final boolean isIndeterminate = control.isIndeterminate();
|
||||
if (!(isIndeterminate && wasIndeterminate)) {
|
||||
barWidth = ((int) (control.getWidth() - snappedLeftInset() - snappedRightInset()) * 2
|
||||
* Math.min(1, Math.max(0, control.getProgress()))) / 2.0F;
|
||||
control.requestLayout();
|
||||
}
|
||||
wasIndeterminate = isIndeterminate;
|
||||
}
|
||||
private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2);
|
||||
|
||||
private void createIndeterminateTimeline() {
|
||||
if (indeterminateTransition != null) {
|
||||
clearAnimation();
|
||||
}
|
||||
double dur = 1;
|
||||
ProgressIndicator control = getSkinnable();
|
||||
final double w = control.getWidth() - (snappedLeftInset() + snappedRightInset());
|
||||
indeterminateTransition = new Timeline(new KeyFrame(
|
||||
Duration.ZERO,
|
||||
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_IN),
|
||||
new KeyValue(clip.translateXProperty(), -w / 2, Interpolator.LINEAR)
|
||||
),
|
||||
new KeyFrame(
|
||||
Duration.seconds(0.5 * dur),
|
||||
new KeyValue(clip.scaleXProperty(), 0.4, Interpolator.LINEAR)
|
||||
),
|
||||
new KeyFrame(
|
||||
Duration.seconds(0.9 * dur),
|
||||
new KeyValue(clip.translateXProperty(), w / 2, Interpolator.LINEAR)
|
||||
),
|
||||
new KeyFrame(
|
||||
Duration.seconds(1 * dur),
|
||||
new KeyValue(clip.scaleXProperty(), 0.0, Interpolator.EASE_OUT)
|
||||
));
|
||||
indeterminateTransition.setCycleCount(Timeline.INDEFINITE);
|
||||
private Timeline createDeterminateTransition(double targetProgress) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)),
|
||||
new KeyFrame(DETERMINATE_DURATION,
|
||||
new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT))
|
||||
);
|
||||
timeline.setOnFinished(e -> {
|
||||
if (transition == timeline) {
|
||||
transition = null;
|
||||
}
|
||||
});
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private void clearAnimation() {
|
||||
indeterminateTransition.stop();
|
||||
indeterminateTransition.getKeyFrames().clear();
|
||||
indeterminateTransition = null;
|
||||
if (transition != null) {
|
||||
transition.stop();
|
||||
transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
treeShowingProperty.dispose();
|
||||
if (indeterminateTransition != null) {
|
||||
clearAnimation();
|
||||
}
|
||||
clearAnimation();
|
||||
}
|
||||
|
||||
private double computeBarWidth(double progress) {
|
||||
assert progress >= 0 && progress <= 1;
|
||||
double barWidth = ((int) fullWidth * 2 * progress) / 2.0;
|
||||
return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth;
|
||||
}
|
||||
}
|
||||
@@ -20,25 +20,24 @@
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXSpinner;
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import com.jfoenix.utils.TreeShowingProperty;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.shape.Arc;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.StrokeLineCap;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/// JFXSpinner material design skin
|
||||
///
|
||||
@@ -47,10 +46,7 @@ import javafx.util.Duration;
|
||||
/// @since 2017-09-25
|
||||
public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
private static final Color GREEN_COLOR = Color.valueOf("#0F9D58");
|
||||
private static final Color RED_COLOR = Color.valueOf("#db4437");
|
||||
private static final Color YELLOW_COLOR = Color.valueOf("#f4b400");
|
||||
private static final Color BLUE_COLOR = Color.valueOf("#4285f4");
|
||||
private static final double DEFAULT_STROKE_WIDTH = 4;
|
||||
|
||||
private JFXSpinner control;
|
||||
private final TreeShowingProperty treeShowingProperty;
|
||||
@@ -61,110 +57,62 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
private Arc track;
|
||||
private final StackPane arcPane;
|
||||
private final Rectangle fillRect;
|
||||
private double arcLength = -1;
|
||||
private final Text text;
|
||||
|
||||
private final double startingAngle;
|
||||
|
||||
public JFXSpinnerSkin(JFXSpinner control) {
|
||||
super(control);
|
||||
|
||||
this.control = control;
|
||||
this.treeShowingProperty = new TreeShowingProperty(control);
|
||||
this.startingAngle = control.getStartingAngle();
|
||||
|
||||
arc = new Arc();
|
||||
arc.setManaged(false);
|
||||
arc.setStartAngle(0);
|
||||
arc.setLength(180);
|
||||
arc.getStyleClass().setAll("arc");
|
||||
arc.setFill(Color.TRANSPARENT);
|
||||
arc.setStrokeWidth(3);
|
||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
arc.setStrokeLineCap(StrokeLineCap.ROUND);
|
||||
|
||||
track = new Arc();
|
||||
track.setManaged(false);
|
||||
track.setStartAngle(0);
|
||||
track.setLength(360);
|
||||
track.setStrokeWidth(3);
|
||||
track.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
track.getStyleClass().setAll("track");
|
||||
track.setFill(Color.TRANSPARENT);
|
||||
|
||||
fillRect = new Rectangle();
|
||||
fillRect.setFill(Color.TRANSPARENT);
|
||||
text = new Text();
|
||||
text.setStyle("-fx-font-size:null");
|
||||
text.getStyleClass().setAll("text", "percentage");
|
||||
final Group group = new Group(fillRect, track, arc, text);
|
||||
final Group group = new Group(fillRect, track, arc);
|
||||
group.setManaged(false);
|
||||
arcPane = new StackPane(group);
|
||||
arcPane.setPrefSize(50, 50);
|
||||
getChildren().setAll(arcPane);
|
||||
|
||||
// register listeners
|
||||
registerChangeListener(control.indeterminateProperty(), obs -> initialize());
|
||||
registerChangeListener(control.progressProperty(), obs -> updateProgress());
|
||||
registerChangeListener(treeShowingProperty, obs -> updateAnimation());
|
||||
registerChangeListener(control.sceneProperty(), obs -> updateAnimation());
|
||||
registerChangeListener(treeShowingProperty, obs -> updateProgress());
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (getSkinnable().isIndeterminate()) {
|
||||
if (timeline == null) {
|
||||
createTransition();
|
||||
if (JFXNodeUtils.isTreeShowing(getSkinnable())) {
|
||||
private void updateProgress() {
|
||||
double progress = Double.min(getSkinnable().getProgress(), 1.0);
|
||||
if (progress < 0) { // indeterminate
|
||||
boolean treeShowing = treeShowingProperty.get();
|
||||
if (treeShowing) {
|
||||
if (timeline == null) {
|
||||
timeline = createTransition();
|
||||
timeline.playFromStart();
|
||||
} else {
|
||||
timeline.play();
|
||||
}
|
||||
} else if (timeline != null) {
|
||||
timeline.pause();
|
||||
}
|
||||
} else {
|
||||
} else { // determinate
|
||||
clearAnimation();
|
||||
arc.setStartAngle(90);
|
||||
updateProgress();
|
||||
}
|
||||
}
|
||||
|
||||
private KeyFrame[] getKeyFrames(double angle, double duration, Paint color) {
|
||||
KeyFrame[] frames = new KeyFrame[4];
|
||||
frames[0] = new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 45 + control.getStartingAngle(),
|
||||
Interpolator.LINEAR));
|
||||
frames[1] = new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + control.getStartingAngle(),
|
||||
Interpolator.LINEAR));
|
||||
frames[2] = new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + control.getStartingAngle(),
|
||||
Interpolator.LINEAR));
|
||||
frames[3] = new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + control.getStartingAngle(),
|
||||
Interpolator.LINEAR),
|
||||
new KeyValue(arc.strokeProperty(), color, Interpolator.EASE_BOTH));
|
||||
return frames;
|
||||
}
|
||||
|
||||
private void pauseTimeline(boolean pause) {
|
||||
if (getSkinnable().isIndeterminate()) {
|
||||
if (timeline == null) {
|
||||
createTransition();
|
||||
}
|
||||
if (pause) {
|
||||
timeline.pause();
|
||||
} else {
|
||||
timeline.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAnimation() {
|
||||
final boolean isTreeShowing = treeShowingProperty.get();
|
||||
if (timeline != null) {
|
||||
pauseTimeline(!isTreeShowing);
|
||||
} else if (isTreeShowing) {
|
||||
createTransition();
|
||||
arc.setLength(-360 * progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,21 +172,9 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
fillRect.setHeight(arcSize);
|
||||
|
||||
if (!isValid) {
|
||||
initialize();
|
||||
updateProgress();
|
||||
isValid = true;
|
||||
}
|
||||
|
||||
if (!getSkinnable().isIndeterminate()) {
|
||||
arc.setLength(arcLength);
|
||||
if (text.isVisible()) {
|
||||
final double progress = control.getProgress();
|
||||
int intProgress = (int) Math.round(progress * 100.0);
|
||||
Font font = text.getFont();
|
||||
text.setFont(Font.font(font.getFamily(), radius / 1.7));
|
||||
text.setText((progress > 1 ? 100 : intProgress) + "%");
|
||||
text.relocate((arcSize - text.getLayoutBounds().getWidth()) / 2, (arcSize - text.getLayoutBounds().getHeight()) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateArcLayout(double radius, double arcSize) {
|
||||
@@ -254,66 +190,64 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
track.setStrokeWidth(arc.getStrokeWidth());
|
||||
}
|
||||
|
||||
boolean wasIndeterminate = false;
|
||||
|
||||
protected void updateProgress() {
|
||||
final ProgressIndicator control = getSkinnable();
|
||||
final boolean isIndeterminate = control.isIndeterminate();
|
||||
if (!(isIndeterminate && wasIndeterminate)) {
|
||||
arcLength = -360 * control.getProgress();
|
||||
control.requestLayout();
|
||||
}
|
||||
wasIndeterminate = isIndeterminate;
|
||||
}
|
||||
|
||||
private void createTransition() {
|
||||
if (!getSkinnable().isIndeterminate()) return;
|
||||
final Paint initialColor = arc.getStroke();
|
||||
if (initialColor == null) {
|
||||
arc.setStroke(BLUE_COLOR);
|
||||
}
|
||||
|
||||
KeyFrame[] blueFrame = getKeyFrames(0, 0, initialColor == null ? BLUE_COLOR : initialColor);
|
||||
KeyFrame[] redFrame = getKeyFrames(450, 1.4, initialColor == null ? RED_COLOR : initialColor);
|
||||
KeyFrame[] yellowFrame = getKeyFrames(900, 2.8, initialColor == null ? YELLOW_COLOR : initialColor);
|
||||
KeyFrame[] greenFrame = getKeyFrames(1350, 4.2, initialColor == null ? GREEN_COLOR : initialColor);
|
||||
|
||||
KeyFrame endingFrame = new KeyFrame(Duration.seconds(5.6),
|
||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + control.getStartingAngle(),
|
||||
Interpolator.LINEAR));
|
||||
angle + 45 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
}
|
||||
|
||||
if (timeline != null) {
|
||||
timeline.stop();
|
||||
timeline.getKeyFrames().clear();
|
||||
private Timeline createTransition() {
|
||||
Timeline timeline;
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
var keyFrames = new ArrayList<KeyFrame>(17);
|
||||
addKeyFrames(keyFrames, 0, 0);
|
||||
addKeyFrames(keyFrames, 450, 1.4);
|
||||
addKeyFrames(keyFrames, 900, 2.8);
|
||||
addKeyFrames(keyFrames, 1350, 4.2);
|
||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
|
||||
timeline = new Timeline();
|
||||
timeline.getKeyFrames().setAll(keyFrames);
|
||||
} else {
|
||||
final double arcLength = 250;
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||
new KeyFrame(Duration.seconds(1.2),
|
||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||
);
|
||||
}
|
||||
timeline = new Timeline(blueFrame[0],
|
||||
blueFrame[1],
|
||||
blueFrame[2],
|
||||
blueFrame[3],
|
||||
redFrame[0],
|
||||
redFrame[1],
|
||||
redFrame[2],
|
||||
redFrame[3],
|
||||
yellowFrame[0],
|
||||
yellowFrame[1],
|
||||
yellowFrame[2],
|
||||
yellowFrame[3],
|
||||
greenFrame[0],
|
||||
greenFrame[1],
|
||||
greenFrame[2],
|
||||
greenFrame[3],
|
||||
endingFrame);
|
||||
|
||||
timeline.setCycleCount(Timeline.INDEFINITE);
|
||||
timeline.setDelay(Duration.ZERO);
|
||||
timeline.playFromStart();
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private void clearAnimation() {
|
||||
if (timeline != null) {
|
||||
timeline.stop();
|
||||
timeline.getKeyFrames().clear();
|
||||
timeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
import java.io.IOException;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
@@ -47,9 +48,9 @@ public final class EntryPoint {
|
||||
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
|
||||
|
||||
createHMCLDirectories();
|
||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
||||
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
checkWine();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||
@@ -71,95 +72,30 @@ public final class EntryPoint {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
if (uiScale != null) {
|
||||
uiScale = uiScale.trim();
|
||||
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
float scaleValue;
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
|
||||
float lowerBound;
|
||||
float upperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
lowerBound = 0.25f;
|
||||
upperBound = 4f;
|
||||
} else {
|
||||
lowerBound = 0.01f;
|
||||
upperBound = 10f;
|
||||
}
|
||||
|
||||
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void createHMCLDirectories() {
|
||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
try {
|
||||
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
|
||||
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME);
|
||||
e.printStackTrace(System.err);
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME));
|
||||
}
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_USER_HOME);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
|
||||
LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +156,13 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkWine() {
|
||||
if (OperatingSystem.isRunningUnderWine()) {
|
||||
LOG.warning("HMCL is running under Wine or its distributions!");
|
||||
showWarning(i18n("fatal.wine_warning"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnableNativeAccess() {
|
||||
if (JavaRuntime.CURRENT_VERSION > 21) {
|
||||
try {
|
||||
@@ -256,10 +199,21 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
static void showWarning(String message) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
||||
*/
|
||||
private static void showErrorAndExit(String message) {
|
||||
static void showErrorAndExit(String message) {
|
||||
SwingUtils.showErrorDialog(message);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -17,38 +17,50 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.WString;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SambaException;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.CrashReporter;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.CommandBuilder;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryPoolMXBean;
|
||||
import java.net.CookieHandler;
|
||||
@@ -58,16 +70,19 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// The main JavaFX application entry point.
|
||||
public final class Launcher extends Application {
|
||||
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
||||
|
||||
@@ -95,50 +110,44 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
ConfigHolder.init();
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
initializeSettingsRuntime();
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& ConfigHolder.isNewlyCreated()
|
||||
&& SettingsManager.isNewlyCreated()
|
||||
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
||||
return;
|
||||
} else {
|
||||
checkConfigInTempDir();
|
||||
}
|
||||
|
||||
if (ConfigHolder.isOwnerChanged()) {
|
||||
if (SettingsManager.isOwnerChanged()) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfigHolder.isUnsupportedVersion()) {
|
||||
if (SettingsManager.hasReadOnlyCoreSettings()) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
|
||||
}
|
||||
|
||||
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
|
||||
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
// runLater to ensure SettingsManager.init() finished initialization
|
||||
Platform.runLater(() -> {
|
||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
||||
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
Themes.applyNativeDarkMode(primaryStage);
|
||||
|
||||
UpdateChecker.init();
|
||||
|
||||
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
|
||||
primaryStage.show();
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
@@ -146,6 +155,26 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes modules and runtime services that depend on loaded settings.
|
||||
private static void initializeSettingsRuntime() {
|
||||
DownloadProviders.init();
|
||||
ProxyManager.init();
|
||||
Accounts.init();
|
||||
GameDirectoryManager.init();
|
||||
AuthlibInjectorServers.init();
|
||||
AnimationUtils.init();
|
||||
|
||||
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
|
||||
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
|
||||
String commonDirectory = settings().getResolvedCommonDirectory();
|
||||
if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) {
|
||||
return commonDirectory;
|
||||
} else {
|
||||
return LauncherSettings.getDefaultCommonDirectory();
|
||||
}
|
||||
}, settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
|
||||
}
|
||||
|
||||
private static void appendScreen(StringBuilder builder, Screen screen) {
|
||||
Rectangle2D bounds = screen.getBounds();
|
||||
double scale = screen.getOutputScaleX();
|
||||
@@ -182,8 +211,29 @@ public final class Launcher extends Application {
|
||||
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
|
||||
}
|
||||
|
||||
private static boolean confirmWithCountdown(Alert.AlertType alertType, String contentText, int seconds) {
|
||||
Alert alert = new Alert(alertType, contentText, ButtonType.YES, ButtonType.NO);
|
||||
Button okButton = (Button) alert.getDialogPane().lookupButton(ButtonType.YES);
|
||||
|
||||
okButton.setDisable(true);
|
||||
|
||||
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
|
||||
for (int i = 0; i < seconds; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(okButton.textProperty(), i18n("button.ok.countdown", seconds - i)));
|
||||
}
|
||||
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
|
||||
new KeyValue(okButton.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(okButton.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
alert.setOnShown(e -> timeline.play());
|
||||
alert.setOnCloseRequest(e -> timeline.stop());
|
||||
return alert.showAndWait().orElse(null) == ButtonType.YES;
|
||||
}
|
||||
|
||||
private static boolean isConfigInTempDir() {
|
||||
String configPath = ConfigHolder.configLocation().toString();
|
||||
String configPath = SettingsManager.localConfigDirectory().toString();
|
||||
|
||||
String tmpdir = System.getProperty("java.io.tmpdir");
|
||||
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
||||
@@ -220,55 +270,16 @@ 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) {
|
||||
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(ConfigHolder.configLocation().toString());
|
||||
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
|
||||
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
|
||||
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
|
||||
files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
|
||||
|
||||
if (showAlert(AlertType.ERROR,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
|
||||
Clipboard.getSystemClipboard()
|
||||
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
Controllers.onApplicationStop();
|
||||
FileSaver.shutdown();
|
||||
SettingsManager.shutdown();
|
||||
LOG.shutdown();
|
||||
}
|
||||
|
||||
@@ -300,10 +311,10 @@ public final class Launcher extends Application {
|
||||
LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor"));
|
||||
LOG.info("Java Home: " + System.getProperty("java.home"));
|
||||
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
|
||||
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
||||
try {
|
||||
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
|
||||
@@ -321,8 +332,24 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
try {
|
||||
SettingsManager.init();
|
||||
} catch (SambaException e) {
|
||||
EntryPoint.showWarning(i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigOwner();
|
||||
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
setupWindowsAppUserModelID();
|
||||
|
||||
launch(Launcher.class, args);
|
||||
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
||||
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
||||
@@ -355,5 +382,217 @@ public final class Launcher extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
|
||||
private static void setupWindowsAppUserModelID() {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
|
||||
return;
|
||||
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||
return;
|
||||
|
||||
try {
|
||||
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||
if (hr < 0) {
|
||||
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
|
||||
} else {
|
||||
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to set AppUserModelID", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets JavaFX VM options before the toolkit starts.
|
||||
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
setUpAnimationFrameRate:
|
||||
{
|
||||
if (System.getProperty("javafx.animation.pulse") != null) {
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
int value = Integer.parseInt(animationFrameRate);
|
||||
if (value <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
if (value != 60)
|
||||
System.setProperty("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
|
||||
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
|
||||
break setUpAnimationFrameRate;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
|
||||
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
|
||||
if (pointer != null) {
|
||||
try {
|
||||
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
|
||||
|
||||
if (refreshRate > 0) {
|
||||
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
|
||||
|
||||
if (refreshRate >= 90) {
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float uiScaleLowerBound;
|
||||
float uiScaleUpperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
uiScaleLowerBound = 0.25f;
|
||||
uiScaleUpperBound = 4f;
|
||||
} else {
|
||||
uiScaleLowerBound = 0.01f;
|
||||
uiScaleUpperBound = 10f;
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
|
||||
float scaleValue = Float.NaN;
|
||||
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
|
||||
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
|
||||
|
||||
if ("wayland".equals(xdgSessionType)
|
||||
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
|
||||
try {
|
||||
@Nullable Path xrdb = SystemUtils.which("xrdb");
|
||||
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
|
||||
@Nullable String dpiLine;
|
||||
|
||||
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
|
||||
BufferedReader bufferedReader = new BufferedReader(reader);
|
||||
Stream<String> lines = bufferedReader.lines()) {
|
||||
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
return dpiLine != null
|
||||
? dpiLine.substring("Xft.dpi:".length()).trim()
|
||||
: null;
|
||||
}, java.time.Duration.ofSeconds(1)) : null;
|
||||
|
||||
if (xftDpi != null) {
|
||||
float dpiValue = Float.parseFloat(xftDpi);
|
||||
float scale = dpiValue / 96f;
|
||||
|
||||
if (scale > 1 && scale <= 10) {
|
||||
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
|
||||
scaleValue = scale;
|
||||
uiScale = Float.toString(scale);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read Xft.dpi from xrdb", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Float.isFinite(scaleValue)) {
|
||||
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(configDirectory.toString());
|
||||
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
|
||||
int result = JOptionPane.showOptionDialog(null,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
i18n("message.error"),
|
||||
JOptionPane.DEFAULT_OPTION,
|
||||
JOptionPane.ERROR_MESSAGE,
|
||||
null,
|
||||
options,
|
||||
options[0]);
|
||||
|
||||
if (result == 0) {
|
||||
try {
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to copy command to clipboard", e);
|
||||
}
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -23,8 +23,8 @@ import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,9 @@ public final class Metadata {
|
||||
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
|
||||
|
||||
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
|
||||
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
|
||||
|
||||
public static final String TITLE = NAME + " " + VERSION;
|
||||
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
|
||||
|
||||
@@ -46,9 +49,9 @@ public final class Metadata {
|
||||
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";
|
||||
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
|
||||
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
|
||||
|
||||
public static final String DOCS_URL = "https://docs.hmcl.net";
|
||||
public static final String CONTACT_URL = DOCS_URL + "/help.html";
|
||||
@@ -59,34 +62,38 @@ public final class Metadata {
|
||||
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 CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
public static final Path HMCL_GLOBAL_DIRECTORY;
|
||||
public static final Path HMCL_CURRENT_DIRECTORY;
|
||||
public static final Path HMCL_USER_HOME;
|
||||
public static final Path HMCL_LOCAL_HOME;
|
||||
public static final Path DEPENDENCIES_DIRECTORY;
|
||||
|
||||
static {
|
||||
String hmclHome = System.getProperty("hmcl.home");
|
||||
if (hmclHome == null) {
|
||||
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
|
||||
if (StringUtils.isBlank(hmclHome)) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||
if (StringUtils.isNotBlank(xdgData)) {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir");
|
||||
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
|
||||
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
|
||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
|
||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||
: HMCL_LOCAL_HOME.resolve("dependencies");
|
||||
}
|
||||
|
||||
public static boolean isStable() {
|
||||
@@ -127,4 +134,32 @@ public final class Metadata {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
|
||||
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
|
||||
|
||||
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
|
||||
public static Path getBundledModpackDirectory() {
|
||||
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
|
||||
///
|
||||
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
|
||||
/// signal to offer automatic install; the file is removed after a successful install.
|
||||
///
|
||||
/// @return the modpack path, or `null` when no package is present
|
||||
public static @Nullable Path findBundledModpackFile() {
|
||||
Path directory = getBundledModpackDirectory();
|
||||
Path zipModpack = directory.resolve("modpack.zip");
|
||||
if (Files.isRegularFile(zipModpack)) {
|
||||
return zipModpack;
|
||||
}
|
||||
Path mrpackModpack = directory.resolve("modpack.mrpack");
|
||||
if (Files.isRegularFile(mrpackModpack)) {
|
||||
return mrpackModpack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.countly;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultCacheRepository;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Provides HMCL-specific game builders for an HMCL game repository.
|
||||
@NotNullByDefault
|
||||
public class HMCLDependencyManager extends DefaultDependencyManager {
|
||||
/// Creates a dependency manager for a repository and download context.
|
||||
///
|
||||
/// @param repository the associated game repository
|
||||
/// @param downloadProvider the remote download provider
|
||||
/// @param cacheRepository the artifact cache
|
||||
public HMCLDependencyManager(
|
||||
HMCLGameRepository repository,
|
||||
DownloadProvider downloadProvider,
|
||||
DefaultCacheRepository cacheRepository) {
|
||||
super(repository, downloadProvider, cacheRepository);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameRepository getGameRepository() {
|
||||
return (HMCLGameRepository) super.getGameRepository();
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
|
||||
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
|
||||
validateGameInstance(instance);
|
||||
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
|
||||
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
|
||||
return new HMCLGameBuilder(
|
||||
this, updateTarget.getId(), updateTarget, draft, initialManifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultGameBuilder;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Builds game instances and applies HMCL-specific post-installation settings.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameBuilder extends DefaultGameBuilder {
|
||||
/// Creates a builder around an HMCL target already reserved by its dependency manager.
|
||||
///
|
||||
/// @param dependencyManager the dependency manager for the target repository
|
||||
/// @param instanceId the id of the reserved instance
|
||||
/// @param updateTarget the exact update target retained by the draft, or `null` for install
|
||||
/// @param draft the open draft containing `initialManifest`
|
||||
/// @param initialManifest the empty target manifest retained by `draft`
|
||||
HMCLGameBuilder(
|
||||
HMCLDependencyManager dependencyManager,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable HMCLGameInstance updateTarget,
|
||||
DefaultGameRepositoryDraft draft,
|
||||
GameInstanceManifest initialManifest) {
|
||||
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
///
|
||||
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
|
||||
/// launches use the instance root. A read-only instance retains its existing setting.
|
||||
@Override
|
||||
protected void onInstanceCommitted(DefaultGameInstance instance) {
|
||||
if (isolationEnabled) {
|
||||
((HMCLGameInstance) instance).enableIsolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectPropertyBase;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameInstance extends DefaultGameInstance {
|
||||
|
||||
/// Whether the instance-local game settings file has already been inspected.
|
||||
private boolean gameSettingsLoaded;
|
||||
|
||||
/// Whether the instance-local game settings file cannot be overwritten safely.
|
||||
private boolean gameSettingsReadOnly;
|
||||
|
||||
/// Cached instance-local game settings, or `null` when none exist after loading.
|
||||
private GameSettings.@Nullable Instance gameSettings;
|
||||
|
||||
/// Creates a registered instance bound to the given repository snapshot.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
|
||||
this(snapshot, id, manifest, (Path) null);
|
||||
}
|
||||
|
||||
/// Creates a registered instance with an optional non-conventional manifest path.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
|
||||
protected HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
@Nullable Path manifestFile) {
|
||||
super(snapshot, id, manifest, manifestFile);
|
||||
}
|
||||
|
||||
/// Creates an instance that shares mutable instance-local state with another instance.
|
||||
///
|
||||
/// Used when the repository clones a snapshot so that settings and the icon property remain
|
||||
/// available on the new wrapper.
|
||||
private HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
HMCLGameInstance shareState) {
|
||||
super(snapshot, id, manifest, shareState);
|
||||
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
|
||||
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
|
||||
this.gameSettings = shareState.gameSettings;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
/// Returns the HMCL modpack configuration file for this instance.
|
||||
///
|
||||
/// @return the `modpack.cfg` path in the instance root
|
||||
@Override
|
||||
public Path getModpackConfigurationFile() {
|
||||
return getLayout().getModpackConfigurationFile(getId());
|
||||
}
|
||||
|
||||
/// Returns whether this instance has an HMCL modpack configuration file.
|
||||
///
|
||||
/// @return whether [#getModpackConfigurationFile()] exists
|
||||
public boolean isModpack() {
|
||||
return Files.exists(getModpackConfigurationFile());
|
||||
}
|
||||
|
||||
/// Reads this instance's HMCL modpack configuration.
|
||||
///
|
||||
/// @return the parsed configuration, or `null` when the file does not exist
|
||||
/// @throws IOException if the configuration cannot be read
|
||||
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
|
||||
Path file = getModpackConfigurationFile();
|
||||
if (Files.notExists(file)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed modpack configuration: " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory() {
|
||||
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
|
||||
}
|
||||
|
||||
/// Returns the loaded instance-local game settings, loading them while this instance is current.
|
||||
///
|
||||
/// @return the settings, or `null` when no local settings exist after loading
|
||||
public @Nullable GameSettings.Instance getSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettings;
|
||||
}
|
||||
|
||||
/// Returns the instance-local game settings, creating an empty settings object when absent.
|
||||
///
|
||||
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
|
||||
public @Nullable GameSettings.Instance getSettingsOrCreate() {
|
||||
GameSettings.Instance setting = getSettings();
|
||||
if (setting == null) {
|
||||
setting = createSettings();
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Resolves this instance's effective settings against its selected parent preset.
|
||||
///
|
||||
/// @return the effective settings
|
||||
public GameSettings.Effective getEffectiveSettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
|
||||
}
|
||||
|
||||
/// Creates empty instance-local game settings when none are loaded.
|
||||
///
|
||||
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
|
||||
public @Nullable GameSettings.Instance createSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (gameSettingsReadOnly) {
|
||||
return null;
|
||||
}
|
||||
if (gameSettings != null) {
|
||||
return gameSettings;
|
||||
}
|
||||
return initSettings(new GameSettings.Instance(), true);
|
||||
}
|
||||
|
||||
/// Returns whether the instance-local game settings file cannot be overwritten safely.
|
||||
///
|
||||
/// @return whether the settings are loaded in read-only mode
|
||||
public boolean isSettingsReadOnly() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettingsReadOnly;
|
||||
}
|
||||
|
||||
/// Enables instance-local running-directory selection for this instance.
|
||||
///
|
||||
/// A blank local running directory resolves to the instance root. This operation is idempotent
|
||||
/// and schedules a settings save only when it adds the override. It leaves the instance
|
||||
/// unchanged when its local settings cannot be written safely.
|
||||
public void enableIsolation() {
|
||||
if (isSettingsReadOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
|
||||
if (setting != null
|
||||
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the settings file when this instance still owns its settings.
|
||||
public void forceOverwriteSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (!ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
if (setting == null) {
|
||||
setting = new GameSettings.Instance();
|
||||
gameSettings = setting;
|
||||
gameSettingsLoaded = true;
|
||||
}
|
||||
|
||||
boolean installAutoSave = !setting.isSavable();
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
setting.setSavable(true);
|
||||
setting.setBackupOnNextSave(false);
|
||||
gameSettingsReadOnly = false;
|
||||
saveSettings();
|
||||
if (installAutoSave) {
|
||||
setting.addListener(a -> saveSettings());
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the settings asynchronously when writable and still owned by the current instance.
|
||||
public void saveSettings() {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Saves the settings synchronously when writable and still owned by the current instance.
|
||||
///
|
||||
/// @throws IOException if saving the file fails
|
||||
public void saveSettingsSync() throws IOException {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Initializes this instance with the given settings object.
|
||||
///
|
||||
/// @param setting the settings to install
|
||||
/// @param allowSave whether the settings may be written back to disk
|
||||
/// @return the installed settings
|
||||
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
|
||||
normalizeRunningDirectoryOverride(setting);
|
||||
setting.setSavable(allowSave);
|
||||
gameSettingsLoaded = true;
|
||||
gameSettings = setting;
|
||||
setting.iconProperty().addListener(observable -> invalidateIconImage());
|
||||
if (allowSave) {
|
||||
gameSettingsReadOnly = false;
|
||||
setting.addListener(a -> saveSettings());
|
||||
} else {
|
||||
gameSettingsReadOnly = true;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
|
||||
/// the effective parent preset when no local settings exist.
|
||||
///
|
||||
/// @return a detached copy suitable for installing into another instance
|
||||
public GameSettings.Instance copySettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
if (setting != null) {
|
||||
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
|
||||
}
|
||||
|
||||
GameSettings.Instance copied = new GameSettings.Instance();
|
||||
copied.parentProperty().setValue(
|
||||
getEffectiveSettings().getPreset().idProperty().getValue());
|
||||
return copied;
|
||||
}
|
||||
|
||||
/// Returns the first custom icon file found in this instance's root directory.
|
||||
///
|
||||
/// @return the icon file, or empty when no supported icon file exists
|
||||
public @Nullable Path getIconFile() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
if (Files.exists(file)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Replaces this instance's custom icon file.
|
||||
///
|
||||
/// Existing supported icon files are removed before `iconFile` is copied.
|
||||
///
|
||||
/// @param iconFile the source icon file
|
||||
/// @throws IOException if the icon cannot be copied
|
||||
/// @throws IllegalArgumentException if the file extension is unsupported
|
||||
public void setIconFile(Path iconFile) throws IOException {
|
||||
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new IllegalArgumentException("Unsupported icon file: " + extension);
|
||||
}
|
||||
|
||||
clearIconFiles();
|
||||
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
/// Deletes all supported custom icon files for this instance.
|
||||
///
|
||||
/// Individual deletion failures are logged and do not stop later files from being attempted.
|
||||
public void deleteIconFile() {
|
||||
clearIconFiles();
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
private void clearIconFiles() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete instance icon file: " + file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft-cached icon image for this instance id.
|
||||
///
|
||||
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
|
||||
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
|
||||
private @Nullable WeakCachedIconImageProperty iconImage;
|
||||
|
||||
/// Returns the observable icon image for this instance.
|
||||
///
|
||||
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
|
||||
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
|
||||
/// pressure. The next [#getIconImage] reloads it.
|
||||
///
|
||||
/// @return the icon image property
|
||||
public ReadOnlyObjectProperty<Image> iconImageProperty() {
|
||||
if (iconImage == null) {
|
||||
iconImage = new WeakCachedIconImageProperty();
|
||||
}
|
||||
return iconImage;
|
||||
}
|
||||
|
||||
/// Returns the icon image selected for this instance.
|
||||
///
|
||||
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
public Image getIconImage() {
|
||||
return iconImageProperty().get();
|
||||
}
|
||||
|
||||
/// Drops the soft-cached icon image and notifies observers.
|
||||
public void invalidateIconImage() {
|
||||
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
|
||||
}
|
||||
|
||||
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
|
||||
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
|
||||
private @Nullable WeakReference<Image> cache;
|
||||
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return HMCLGameInstance.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "iconImage";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Image get() {
|
||||
WeakReference<Image> current = cache;
|
||||
Image image = current != null ? current.get() : null;
|
||||
if (image != null) {
|
||||
return image;
|
||||
}
|
||||
|
||||
image = computeIconImage();
|
||||
cache = new WeakReference<>(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
/// Computes the icon image from settings, custom files, and the launch manifest.
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
private Image computeIconImage() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
GameInstanceIconType iconType = setting != null
|
||||
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
|
||||
: GameInstanceIconType.DEFAULT;
|
||||
if (iconType != GameInstanceIconType.DEFAULT) {
|
||||
return iconType.getIcon();
|
||||
}
|
||||
|
||||
@Nullable Path iconFile = getIconFile();
|
||||
if (iconFile != null) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile, 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load instance icon for " + getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
for (ModLoaderType modLoader : getModLoaders()) {
|
||||
return GameInstanceIconType.getIconType(modLoader).getIcon();
|
||||
}
|
||||
|
||||
if (hasComponent(GameComponentType.OPTIFINE))
|
||||
return GameInstanceIconType.OPTIFINE.getIcon();
|
||||
|
||||
GameVersionNumber version = getVersion();
|
||||
if (version.isAprilFools())
|
||||
return GameInstanceIconType.APRIL_FOOLS.getIcon();
|
||||
else if (version instanceof GameVersionNumber.LegacySnapshot || version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
|
||||
return GameInstanceIconType.COMMAND.getIcon();
|
||||
else if (version instanceof GameVersionNumber.Old)
|
||||
return GameInstanceIconType.CRAFT_TABLE.getIcon();
|
||||
else
|
||||
return GameInstanceIconType.GRASS.getIcon();
|
||||
}
|
||||
|
||||
/// Clears the weak cache and notifies listeners.
|
||||
void invalidate() {
|
||||
cache = null;
|
||||
fireValueChangedEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the marker indicating that the most recent launch ended abnormally.
|
||||
public void markLaunchedAbnormally() {
|
||||
try {
|
||||
Files.createFile(getInstanceRoot().resolve(".abnormal"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the abnormal-launch marker when present.
|
||||
///
|
||||
/// @return whether a regular marker file was present
|
||||
public boolean unmarkLaunchedAbnormally() {
|
||||
Path file = getInstanceRoot().resolve(".abnormal");
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Loads settings once while this object is the repository's current instance.
|
||||
private void ensureGameSettingsLoaded() {
|
||||
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
|
||||
loadGameSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this instance still owns the settings object used by the current snapshot.
|
||||
private boolean ownsGameSettings() {
|
||||
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
|
||||
boolean owns = current == this
|
||||
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
|
||||
if (!owns && gameSettings != null) {
|
||||
gameSettings.setSavable(false);
|
||||
}
|
||||
return owns;
|
||||
}
|
||||
|
||||
private void loadGameSettings() {
|
||||
gameSettingsLoaded = true;
|
||||
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
|
||||
if (result.setting() != null) {
|
||||
initSettings(result.setting(), result.allowSave());
|
||||
return;
|
||||
}
|
||||
if (!result.allowSave()) {
|
||||
gameSettingsReadOnly = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
|
||||
getRepository(), id, legacyParent);
|
||||
if (migrationResult != null) {
|
||||
initSettings(migrationResult.setting(), true);
|
||||
try {
|
||||
saveSettingsSync();
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path getGameSettingsFile() {
|
||||
return getLayout().getInstanceGameSettingsFile(id);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
GameSettings.Effective vs = getEffectiveSettings();
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
GameVersionNumber gameVersionNumber = getVersion();
|
||||
|
||||
@Nullable Integer maxMemory;
|
||||
if (autoMemory) {
|
||||
maxMemory = noJVMOptions
|
||||
? null
|
||||
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||
SystemInfo.getPhysicalMemoryStatus().available(),
|
||||
javaVersion.getPlatform()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setInstanceId(getId())
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(getId().id())
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
|
||||
.setMaxMemory(maxMemory)
|
||||
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
|
||||
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
|
||||
.setProxyOption(getProxyOption())
|
||||
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
|
||||
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
|
||||
.setNoGeneratedJVMArgs(noJVMOptions)
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
|
||||
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
|
||||
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
|
||||
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
|
||||
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
|
||||
.setRenderer(vs.getRenderer(gameVersionNumber))
|
||||
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
|
||||
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
|
||||
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
|
||||
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
|
||||
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
|
||||
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
|
||||
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
|
||||
if (quickPlayOption != null) {
|
||||
builder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
Path json = getModpackConfigurationFile();
|
||||
if (Files.exists(json)) {
|
||||
try {
|
||||
String jsonText = Files.readString(json);
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static ProxyOption getProxyOption() {
|
||||
return switch (settings().proxyTypeProperty().get()) {
|
||||
case SYSTEM -> ProxyOption.Default.INSTANCE;
|
||||
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||
case HTTP, SOCKS -> {
|
||||
String proxyHost = settings().proxyHostProperty().get();
|
||||
int proxyPort = settings().proxyPortProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||
yield ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
String proxyUser = settings().proxyUserProperty().get();
|
||||
String proxyPass = settings().proxyPasswordProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyUser)) {
|
||||
proxyUser = null;
|
||||
proxyPass = null;
|
||||
} else if (proxyPass == null) {
|
||||
proxyPass = "";
|
||||
}
|
||||
|
||||
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
|
||||
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
} else {
|
||||
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Loads a new-format instance game settings file.
|
||||
private static LoadResult loadGameSettingsFile(Path file) {
|
||||
if (!Files.exists(file)) {
|
||||
return new LoadResult(null, true);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning("Instance game settings are empty: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
|
||||
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setSavable(false);
|
||||
return new LoadResult(fallback, false);
|
||||
}
|
||||
|
||||
GameSettings.@Nullable Instance setting =
|
||||
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
|
||||
if (setting == null) {
|
||||
LOG.warning("Instance game settings deserialized to null: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
}
|
||||
return new LoadResult(setting, schemaResult.allowSave());
|
||||
} catch (JsonParseException ex) {
|
||||
LOG.warning("Failed to parse game setting " + file, ex);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load game setting " + file, ex);
|
||||
return new LoadResult(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps old local custom running directories effective under the new source-selection model.
|
||||
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
|
||||
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of loading an instance-specific game settings file.
|
||||
///
|
||||
/// @param setting the loaded instance settings, or `null` when unavailable
|
||||
/// @param allowSave whether the file may be overwritten
|
||||
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
|
||||
}
|
||||
|
||||
/// Optional reference to an HMCL game instance bound to a repository.
|
||||
///
|
||||
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
|
||||
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
|
||||
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
|
||||
/// from the current snapshot while preserving repository context.
|
||||
@NotNullByDefault
|
||||
public static final class Optional {
|
||||
private final HMCLGameRepository repository;
|
||||
private final @Nullable HMCLGameInstance instance;
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
public Optional(HMCLGameRepository repository) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.instance = null;
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
public Optional(HMCLGameInstance instance) {
|
||||
this.repository = instance.getRepository();
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @param instanceId the instance id, or `null` for an empty optional
|
||||
/// @return an optional that is empty when `instanceId` is null or not registered
|
||||
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
if (instanceId == null) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
HMCLGameInstance instance = repository.findInstance(instanceId);
|
||||
return instance != null ? new Optional(instance) : new Optional(repository);
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
/// @return the optional
|
||||
public static Optional of(HMCLGameInstance instance) {
|
||||
return new Optional(instance);
|
||||
}
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @return the empty optional
|
||||
public static Optional empty(HMCLGameRepository repository) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
|
||||
/// Returns the repository associated with this optional.
|
||||
///
|
||||
/// @return the repository
|
||||
public HMCLGameRepository repository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Returns the held instance, if any.
|
||||
///
|
||||
/// @return the instance, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable HMCLGameInstance instance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// Returns the held instance id, if any.
|
||||
///
|
||||
/// @return the instance id, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable GameInstanceID instanceId() {
|
||||
return instance != null ? instance.getId() : null;
|
||||
}
|
||||
|
||||
/// Returns whether an instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is non-null
|
||||
public boolean isPresent() {
|
||||
return instance != null;
|
||||
}
|
||||
|
||||
/// Returns whether no instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is null
|
||||
public boolean isEmpty() {
|
||||
return instance == null;
|
||||
}
|
||||
|
||||
/// Re-resolves the held instance id from the repository's current snapshot.
|
||||
///
|
||||
/// @return this optional when empty; otherwise a fresh optional for the same id
|
||||
public Optional refreshed() {
|
||||
if (instance == null) {
|
||||
return this;
|
||||
}
|
||||
return of(repository, instance.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,20 @@ 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.NativePatcher;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.CommandBuilder;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -39,16 +42,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
|
||||
this(repository, version, authInfo, options, null);
|
||||
/// Creates a launcher with daemon process monitors.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(instance, manifest, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(repository, version, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(repository, version, authInfo, options, listener, daemon);
|
||||
/// Creates a launcher for the given instance and launch plan.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
/// @param daemon whether monitors should be daemon threads
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(instance, manifest, authInfo, options, listener, daemon);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -60,10 +74,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
if (config().isDisableAutoGameOptions())
|
||||
if (options.isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(version.getId());
|
||||
Path runDir = instance.getRunDirectory();
|
||||
Path optionsFile = runDir.resolve("options.txt");
|
||||
Path configFolder = runDir.resolve("config");
|
||||
|
||||
@@ -88,8 +102,8 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
|
||||
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
|
||||
*/
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
|
||||
if (gameVersion.compareTo("1.1") < 0)
|
||||
GameVersionNumber gameVersion = instance.getVersion();
|
||||
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
|
||||
return;
|
||||
|
||||
String lang = normalizedLanguageTag(locale, gameVersion);
|
||||
@@ -150,4 +164,60 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
generateOptionsTxt();
|
||||
super.makeLaunchScript(scriptFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void appendJvmArgs(CommandBuilder result) {
|
||||
super.appendJvmArgs(result);
|
||||
|
||||
if (options.isAllowAutoAgent()
|
||||
&& !options.isNoGeneratedJVMArgs()
|
||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
|
||||
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
||||
try {
|
||||
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to extract lwjgl-unsafe-agent", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path extractLwjglUnsafeAgent() throws IOException {
|
||||
String agentVersion = JarUtils.getAttribute("hmcl.lwjgl-unsafe-agent.version", null);
|
||||
if (agentVersion == null) {
|
||||
throw new IOException("Missing hmcl.lwjgl-unsafe-agent.version attribute");
|
||||
}
|
||||
|
||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||
String fileName = library.artifact().getFileName();
|
||||
|
||||
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
|
||||
if (agentPath.toString().contains("=")) {
|
||||
throw new IOException("Invalid library path: " + agentPath);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/" + fileName)) {
|
||||
if (input == null) {
|
||||
throw new IOException("/assets/" + fileName + " not found");
|
||||
}
|
||||
|
||||
bytes = input.readAllBytes();
|
||||
}
|
||||
|
||||
if (Files.isRegularFile(agentPath)) {
|
||||
try {
|
||||
if (Files.size(agentPath) == bytes.length) {
|
||||
return agentPath;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to check size of " + agentPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
Files.createDirectories(agentPath.getParent());
|
||||
FileUtils.saveSafely(agentPath, output -> output.write(bytes));
|
||||
return agentPath;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,573 +17,517 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParseException;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.mod.ModAdviser;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.ObjectBinding;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModAdviser;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.setting.DefaultIsolationType;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator;
|
||||
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Bits;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL game repository implementation backed by a GameDirectory and per-instance game settings.
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
private final Profile profile;
|
||||
/// The persistent game directory for this repository.
|
||||
private final GameDirectory gameDirectory;
|
||||
|
||||
// local version settings
|
||||
private final Map<String, VersionSetting> localVersionSettings = new HashMap<>();
|
||||
private final Set<String> beingModpackVersions = new HashSet<>();
|
||||
/// The selected instance ID persisted for this repository's game directory.
|
||||
private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId;
|
||||
|
||||
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
|
||||
/// The selected instance resolved from the current repository snapshot.
|
||||
private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance;
|
||||
|
||||
public HMCLGameRepository(Profile profile, Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
this.profile = profile;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
/// Creates a repository backed by the given game directory.
|
||||
///
|
||||
/// @param gameDirectory the persistent game directory represented by this repository
|
||||
public HMCLGameRepository(GameDirectory gameDirectory) {
|
||||
super(gameDirectory.getPath().toPath());
|
||||
this.gameDirectory = gameDirectory;
|
||||
this.selectedInstanceId = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId());
|
||||
this.selectedInstance = new ReadOnlyObjectWrapper<>(this, "selectedInstance");
|
||||
this.selectedInstance.bind(Bindings.createObjectBinding(
|
||||
this::resolveSelectedInstance,
|
||||
selectedInstanceId,
|
||||
snapshotProperty()));
|
||||
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameDirectoryType getGameDirectoryType(String id) {
|
||||
if (beingModpackVersions.contains(id) || isModpack(id)) {
|
||||
return GameDirectoryType.VERSION_FOLDER;
|
||||
} else {
|
||||
return getVersionSetting(id).getGameDirType();
|
||||
protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) {
|
||||
return new HMCLGameRepositoryLayout(baseDirectory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) {
|
||||
return new HMCLGameRepositorySnapshot(this, (HMCLGameRepositoryLayout) layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance createInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
@Nullable Path manifestFile) {
|
||||
return new HMCLGameInstance(snapshot, id, manifest, manifestFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositorySnapshot getSnapshot() {
|
||||
return (HMCLGameRepositorySnapshot) super.getSnapshot();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public ReadOnlyObjectProperty<HMCLGameRepositorySnapshot> snapshotProperty() {
|
||||
return (ReadOnlyObjectProperty<HMCLGameRepositorySnapshot>) super.snapshotProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException {
|
||||
return (HMCLGameInstance) super.getInstance(id);
|
||||
}
|
||||
|
||||
/// Returns the indexed instance for the given id, or `null` when it is not loaded.
|
||||
///
|
||||
/// @param id the instance id
|
||||
/// @return the instance, or `null` when absent
|
||||
public @Nullable HMCLGameInstance findInstance(GameInstanceID id) {
|
||||
return (HMCLGameInstance) getSnapshot().findInstance(id);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
protected void flushPendingInstanceWrites() throws IOException {
|
||||
try {
|
||||
FileSaver.waitForAllSaves();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for pending instance writes", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory(String id) {
|
||||
switch (getGameDirectoryType(id)) {
|
||||
case VERSION_FOLDER:
|
||||
return getVersionRoot(id);
|
||||
case ROOT_FOLDER:
|
||||
return super.getRunDirectory(id);
|
||||
case CUSTOM:
|
||||
try {
|
||||
return Path.of(getVersionSetting(id).getGameDir());
|
||||
} catch (InvalidPathException ignored) {
|
||||
return getVersionRoot(id);
|
||||
}
|
||||
default:
|
||||
throw new AssertionError("Unreachable");
|
||||
/// Returns the persistent game directory for this repository.
|
||||
public GameDirectory getGameDirectory() {
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected instance resolved from the current repository snapshot.
|
||||
///
|
||||
/// The property is `null` when the persisted selection is absent or is not registered in the
|
||||
/// current snapshot. Publishing a new snapshot replaces the value with that snapshot's member,
|
||||
/// even when the selected ID is unchanged.
|
||||
///
|
||||
/// @return the read-only selected-instance property
|
||||
public ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
|
||||
return selectedInstance.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the selected instance from the current repository snapshot.
|
||||
///
|
||||
/// @return the selected instance, or `null` when no registered instance is selected
|
||||
public @Nullable HMCLGameInstance getSelectedInstance() {
|
||||
return selectedInstance.get();
|
||||
}
|
||||
|
||||
/// Persists an instance as this repository's current selection.
|
||||
///
|
||||
/// A stale snapshot member from this repository is accepted; the observable property resolves
|
||||
/// its ID against the current snapshot.
|
||||
///
|
||||
/// @param instance the instance to select, or `null` to clear the selection
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository
|
||||
public void setSelectedInstance(@Nullable HMCLGameInstance instance) {
|
||||
if (instance != null && instance.getRepository() != this) {
|
||||
throw new IllegalArgumentException("Selected instance belongs to another repository");
|
||||
}
|
||||
settings().setSelectedInstance(gameDirectory.getId(), instance != null ? instance.getId() : null);
|
||||
}
|
||||
|
||||
/// Restores a valid selected instance after repository instances are loaded.
|
||||
///
|
||||
/// If the persisted ID is not registered, the first indexed instance is selected. If the
|
||||
/// repository is empty, the persisted selection is cleared.
|
||||
public void refreshSelectedInstance() {
|
||||
@Nullable GameInstanceID persistedId = selectedInstanceId.get();
|
||||
@Nullable HMCLGameInstance refreshedInstance = persistedId != null ? findInstance(persistedId) : null;
|
||||
if (refreshedInstance == null) {
|
||||
refreshedInstance = getSnapshot().getInstances().stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Nullable GameInstanceID refreshedId = refreshedInstance != null ? refreshedInstance.getId() : null;
|
||||
if (!Objects.equals(persistedId, refreshedId)) {
|
||||
setSelectedInstance(refreshedInstance);
|
||||
}
|
||||
}
|
||||
|
||||
public Stream<Version> getDisplayVersions() {
|
||||
return getVersions().stream()
|
||||
.filter(v -> !v.isHidden())
|
||||
.sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH))
|
||||
.thenComparing(v -> VersionNumber.asVersion(v.getId())));
|
||||
/// Resolves the persisted selected ID from the current repository snapshot.
|
||||
///
|
||||
/// @return the current snapshot member, or `null` when the selected ID is absent or unregistered
|
||||
private @Nullable HMCLGameInstance resolveSelectedInstance() {
|
||||
@Nullable GameInstanceID instanceId = selectedInstanceId.get();
|
||||
return instanceId != null ? findInstance(instanceId) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void refreshVersionsImpl() {
|
||||
localVersionSettings.clear();
|
||||
super.refreshVersionsImpl();
|
||||
versions.keySet().forEach(this::loadLocalVersionSetting);
|
||||
versions.keySet().forEach(version -> {
|
||||
if (isModpack(version)) {
|
||||
specializeVersionSetting(version);
|
||||
}
|
||||
});
|
||||
/// Returns a dependency manager using the currently selected download provider.
|
||||
///
|
||||
/// @return a new dependency manager for this repository
|
||||
public HMCLDependencyManager getDependency() {
|
||||
return getDependency(DownloadProviders.getDownloadProvider());
|
||||
}
|
||||
|
||||
/// Returns a dependency manager using the given download provider.
|
||||
///
|
||||
/// @param downloadProvider the remote download provider
|
||||
/// @return a new dependency manager for this repository
|
||||
public HMCLDependencyManager getDependency(DownloadProvider downloadProvider) {
|
||||
return new HMCLDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY);
|
||||
}
|
||||
|
||||
/// Resolves the run directory from modpack state and local settings.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present)
|
||||
/// @param localSetting the instance-local settings, or `null` when absent
|
||||
/// @return the run directory
|
||||
Path computeRunDirectory(
|
||||
GameInstanceID instanceId,
|
||||
boolean modpack,
|
||||
GameSettings.@Nullable Instance localSetting) {
|
||||
Path instanceRoot = getLayout().getInstanceRoot(instanceId);
|
||||
if (modpack) {
|
||||
return instanceRoot;
|
||||
}
|
||||
|
||||
boolean useInstanceRunningDirectory =
|
||||
localSetting != null
|
||||
&& localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory);
|
||||
if (StringUtils.isBlank(runningDirectory)) {
|
||||
return useInstanceRunningDirectory ? instanceRoot : getBaseDirectory();
|
||||
}
|
||||
|
||||
try {
|
||||
Path file = getBaseDirectory().resolve("launcher_profiles.json");
|
||||
if (!Files.exists(file) && !versions.isEmpty()) {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, PROFILE);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex);
|
||||
return Path.of(runningDirectory);
|
||||
} catch (Exception ignored) {
|
||||
return instanceRoot;
|
||||
}
|
||||
}
|
||||
|
||||
private String selectedRunningDirectory(
|
||||
GameSettings.@Nullable Instance localSetting,
|
||||
boolean useInstanceRunningDirectory) {
|
||||
if (useInstanceRunningDirectory) {
|
||||
if (localSetting == null) {
|
||||
return "";
|
||||
}
|
||||
return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
GameSettings.Preset parent = getParentGameSettings(localSetting);
|
||||
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
/// Reads instance-local settings from disk without requiring a registered snapshot member.
|
||||
///
|
||||
/// Used while loading or migrating settings before the instance is indexed. Does not publish a
|
||||
/// snapshot entry.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @return the loaded settings, or `null` when none can be loaded
|
||||
private GameSettings.@Nullable Instance peekInstanceGameSettings(GameInstanceID instanceId) {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return null;
|
||||
}
|
||||
try (var reader = Files.newBufferedReader(file)) {
|
||||
return LauncherSettings.SETTINGS_GSON.fromJson(reader, GameSettings.Instance.class);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to peek instance game settings: " + file, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes instance-local settings to disk for an id that may not yet be registered.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @param setting the settings to write
|
||||
/// @throws IOException if the file cannot be written
|
||||
private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting)
|
||||
throws IOException {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
public Stream<HMCLGameInstance> getDisplayInstances() {
|
||||
return getSnapshot().getInstances().stream()
|
||||
.filter(it -> !it.getManifest().isHidden())
|
||||
.sorted(Comparator.comparing(DefaultGameInstance::getVersion)
|
||||
.thenComparing(instance -> Lang.requireNonNullElse(instance.getResolvedManifest().releaseTime(), Instant.EPOCH))
|
||||
.thenComparing(instance -> VersionNumber.asVersion(instance.getId().id())));
|
||||
}
|
||||
|
||||
public void changeDirectory(Path newDirectory) {
|
||||
setBaseDirectory(newDirectory);
|
||||
refreshVersionsAsync().start();
|
||||
refreshAsync().start();
|
||||
}
|
||||
|
||||
private void clean(Path directory) throws IOException {
|
||||
FileUtils.deleteDirectory(directory.resolve("crash-reports"));
|
||||
FileUtils.deleteDirectory(directory.resolve("logs"));
|
||||
for (Path logFile : FileUtils.listFilesByExtension(directory, "log"))
|
||||
Files.deleteIfExists(logFile);
|
||||
}
|
||||
|
||||
public void clean(String id) throws IOException {
|
||||
public void clean(GameInstanceID instanceId) throws IOException {
|
||||
clean(getBaseDirectory());
|
||||
clean(getRunDirectory(id));
|
||||
clean(getInstance(instanceId).getRunDirectory());
|
||||
}
|
||||
|
||||
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
|
||||
Path srcDir = getVersionRoot(srcId);
|
||||
Path dstDir = getVersionRoot(dstId);
|
||||
/// Duplicates an instance and publishes the copy through one exclusive repository draft.
|
||||
///
|
||||
/// The destination remains unpublished until all selected instance and run-directory files have
|
||||
/// been copied. Failure aborts the draft and removes the destination instance root.
|
||||
///
|
||||
/// @param srcId the source instance id
|
||||
/// @param dstId the destination instance id
|
||||
/// @param copySaves whether saved worlds should be copied
|
||||
/// @throws IOException if the destination exists or any file cannot be copied or committed
|
||||
public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException {
|
||||
Path srcDir = getLayout().getInstanceRoot(srcId);
|
||||
Path dstDir = getLayout().getInstanceRoot(dstId);
|
||||
|
||||
Version fromVersion = getVersion(srcId);
|
||||
GameInstanceManifest fromManifest = getInstanceManifest(srcId);
|
||||
GameInstanceManifest destinationManifest = fromManifest.withId(dstId).withJar(dstId);
|
||||
|
||||
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
|
||||
blackList.add(srcId + ".jar");
|
||||
blackList.add(srcId + ".json");
|
||||
blackList.add(srcId.id() + ".jar");
|
||||
blackList.add(srcId.id() + ".json");
|
||||
if (!copySaves)
|
||||
blackList.add("saves");
|
||||
|
||||
if (Files.exists(dstDir)) throw new IOException("Version exists");
|
||||
try (DefaultGameRepositoryDraft draft = openDraft()) {
|
||||
draft.put(destinationManifest);
|
||||
|
||||
Files.createDirectories(dstDir);
|
||||
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
Files.createDirectories(dstDir);
|
||||
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
|
||||
Path fromJson = srcDir.resolve(srcId + ".json");
|
||||
Path fromJar = srcDir.resolve(srcId + ".jar");
|
||||
Path toJson = dstDir.resolve(dstId + ".json");
|
||||
Path toJar = dstDir.resolve(dstId + ".jar");
|
||||
|
||||
if (Files.exists(fromJar)) {
|
||||
Files.copy(fromJar, toJar);
|
||||
}
|
||||
Files.copy(fromJson, toJson);
|
||||
|
||||
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
|
||||
|
||||
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
|
||||
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
|
||||
oldVersionSetting.setUsesGlobal(false);
|
||||
oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
|
||||
saveVersionSetting(dstId);
|
||||
|
||||
Path srcGameDir = getRunDirectory(srcId);
|
||||
Path dstGameDir = getRunDirectory(dstId);
|
||||
|
||||
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
|
||||
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
}
|
||||
|
||||
private Path getLocalVersionSettingFile(String id) {
|
||||
return getVersionRoot(id).resolve("hmclversion.cfg");
|
||||
}
|
||||
|
||||
private void loadLocalVersionSetting(String id) {
|
||||
Path file = getLocalVersionSettingFile(id);
|
||||
if (Files.exists(file))
|
||||
try {
|
||||
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
|
||||
initLocalVersionSetting(id, versionSetting);
|
||||
} catch (Exception ex) {
|
||||
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
|
||||
initLocalVersionSetting(id, new VersionSetting());
|
||||
Path fromJar = srcDir.resolve(srcId.id() + ".jar");
|
||||
Path toJar = dstDir.resolve(dstId.id() + ".jar");
|
||||
if (Files.exists(fromJar)) {
|
||||
Files.copy(fromJar, toJar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new version setting if version id has no version setting.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return new version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting createLocalVersionSetting(String id) {
|
||||
if (!hasVersion(id))
|
||||
return null;
|
||||
if (localVersionSettings.containsKey(id))
|
||||
return getLocalVersionSetting(id);
|
||||
else
|
||||
return initLocalVersionSetting(id, new VersionSetting());
|
||||
}
|
||||
|
||||
private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) {
|
||||
localVersionSettings.put(id, vs);
|
||||
vs.addListener(a -> saveVersionSetting(id));
|
||||
return vs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version setting for version id.
|
||||
*
|
||||
* @param id version id
|
||||
* @return corresponding version setting, null if the version has no its own version setting.
|
||||
*/
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
loadLocalVersionSetting(id);
|
||||
VersionSetting setting = localVersionSettings.get(id);
|
||||
if (setting != null && isModpack(id))
|
||||
setting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSettingOrCreate(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null) {
|
||||
vs = createLocalVersionSetting(id);
|
||||
}
|
||||
return vs;
|
||||
}
|
||||
|
||||
public VersionSetting getVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null || vs.isUsesGlobal()) {
|
||||
profile.getGlobal().setUsesGlobal(true);
|
||||
return profile.getGlobal();
|
||||
} else
|
||||
return vs;
|
||||
}
|
||||
|
||||
public Optional<Path> getVersionIconFile(String id) {
|
||||
Path root = getVersionRoot(id);
|
||||
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = root.resolve("icon." + extension);
|
||||
if (Files.exists(file)) {
|
||||
return Optional.of(file);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void setVersionIconFile(String id, Path iconFile) throws IOException {
|
||||
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
|
||||
throw new IllegalArgumentException("Unsupported icon file: " + ext);
|
||||
}
|
||||
|
||||
deleteIconFile(id);
|
||||
|
||||
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
|
||||
}
|
||||
|
||||
public void deleteIconFile(String id) {
|
||||
Path root = getVersionRoot(id);
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = root.resolve("icon." + extension);
|
||||
Path srcGameDir = getInstance(srcId).getRunDirectory();
|
||||
boolean copyOriginalGameDir;
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
copyOriginalGameDir = !Files.isSameFile(srcGameDir, srcDir);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete icon file: " + file, e);
|
||||
copyOriginalGameDir = true;
|
||||
}
|
||||
|
||||
GameSettings.Instance newGameSettings = getInstance(srcId).copySettings();
|
||||
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
newGameSettings.runningDirectoryProperty().setValue("");
|
||||
writeInstanceGameSettings(dstId, newGameSettings);
|
||||
|
||||
Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings);
|
||||
if (copyOriginalGameDir) {
|
||||
FileUtils.copyDirectory(
|
||||
srcGameDir,
|
||||
dstGameDir,
|
||||
path -> Modpack.acceptFile(path, blackList, null));
|
||||
}
|
||||
|
||||
draft.commit();
|
||||
}
|
||||
}
|
||||
|
||||
public Image getVersionIconImage(String id) {
|
||||
if (id == null || !isLoaded())
|
||||
return VersionIconType.DEFAULT.getIcon();
|
||||
|
||||
VersionSetting vs = getLocalVersionSettingOrCreate(id);
|
||||
VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
|
||||
|
||||
if (iconType == VersionIconType.DEFAULT) {
|
||||
Version version = getVersion(id).resolve(this);
|
||||
Optional<Path> iconFile = getVersionIconFile(id);
|
||||
if (iconFile.isPresent()) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile.get());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load version icon of " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (LibraryAnalyzer.isModded(this, version)) {
|
||||
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null);
|
||||
if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC))
|
||||
return VersionIconType.FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
|
||||
return VersionIconType.QUILT.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
|
||||
return VersionIconType.LEGACY_FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
|
||||
return VersionIconType.NEO_FORGE.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
|
||||
return VersionIconType.FORGE.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
|
||||
return VersionIconType.CLEANROOM.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER))
|
||||
return VersionIconType.CHICKEN.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE))
|
||||
return VersionIconType.OPTIFINE.getIcon();
|
||||
}
|
||||
|
||||
String gameVersion = getGameVersion(version).orElse(null);
|
||||
if (gameVersion != null) {
|
||||
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
|
||||
if (versionNumber.isAprilFools()) {
|
||||
return VersionIconType.APRIL_FOOLS.getIcon();
|
||||
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
|
||||
return VersionIconType.COMMAND.getIcon();
|
||||
} else if (versionNumber instanceof GameVersionNumber.Old) {
|
||||
return VersionIconType.CRAFT_TABLE.getIcon();
|
||||
}
|
||||
}
|
||||
return VersionIconType.GRASS.getIcon();
|
||||
} else {
|
||||
return iconType.getIcon();
|
||||
}
|
||||
}
|
||||
|
||||
public void saveVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
return;
|
||||
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Make version use self version settings instead of the global one.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return specialized version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting specializeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
vs = createLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
/// Returns instance-local settings for a registered instance ID, creating empty settings when
|
||||
/// the settings file is absent and writable.
|
||||
///
|
||||
/// Code that already has an [HMCLGameInstance] should use
|
||||
/// [HMCLGameInstance#getSettingsOrCreate()] instead.
|
||||
///
|
||||
/// @param instanceId the registered instance ID
|
||||
/// @return the settings, or `null` when the instance is not registered or settings are unavailable
|
||||
public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) {
|
||||
HMCLGameInstance instance = findInstance(instanceId);
|
||||
if (instance == null) {
|
||||
return null;
|
||||
if (vs.isUsesGlobal()) {
|
||||
vs.setUsesGlobal(false);
|
||||
}
|
||||
return vs;
|
||||
return instance.getSettingsOrCreate();
|
||||
}
|
||||
|
||||
public void globalizeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs != null)
|
||||
vs.setUsesGlobal(true);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
VersionSetting vs = getVersionSetting(version);
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(version)
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
|
||||
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
|
||||
vs.getMaxMemory() * 1024L * 1024L,
|
||||
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
|
||||
vs.isAutoMemory()
|
||||
) / 1024 / 1024))
|
||||
.setMinMemory(vs.getMinMemory())
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables())
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.isFullscreen())
|
||||
.setWrapper(vs.getWrapper())
|
||||
.setProxyOption(getProxyOption())
|
||||
.setPreLaunchCommand(vs.getPreLaunchCommand())
|
||||
.setPostExitCommand(vs.getPostExitCommand())
|
||||
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
|
||||
.setNativesDirType(vs.getNativesDirType())
|
||||
.setNativesDir(vs.getNativesDir())
|
||||
.setProcessPriority(vs.getProcessPriority())
|
||||
.setRenderer(vs.getRenderer())
|
||||
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
|
||||
.setUseNativeGLFW(vs.isUseNativeGLFW())
|
||||
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
|
||||
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
if (StringUtils.isNotBlank(vs.getServerIp())) {
|
||||
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
|
||||
/// Returns instance-local settings for a registered instance ID.
|
||||
///
|
||||
/// When the instance is not yet indexed, settings are loaded from disk (including lazy legacy
|
||||
/// migration) without publishing a snapshot entry. Callers that already have an
|
||||
/// [HMCLGameInstance] should use [HMCLGameInstance#getSettings()] instead.
|
||||
///
|
||||
/// @param instanceId the instance ID
|
||||
/// @return the settings, or `null` when no local settings exist
|
||||
public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) {
|
||||
HMCLGameInstance instance = findInstance(instanceId);
|
||||
if (instance != null) {
|
||||
return instance.getSettings();
|
||||
}
|
||||
|
||||
Path json = getModpackConfiguration(version);
|
||||
if (Files.exists(json)) {
|
||||
try {
|
||||
String jsonText = Files.readString(json);
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
return loadOrMigrateInstanceGameSettings(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getModpackConfiguration(String version) {
|
||||
return getVersionRoot(version).resolve("modpack.cfg");
|
||||
/// Returns the explicit parent preset of the instance, falling back to the default preset.
|
||||
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
|
||||
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
|
||||
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
|
||||
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
|
||||
}
|
||||
|
||||
public void markVersionAsModpack(String id) {
|
||||
beingModpackVersions.add(id);
|
||||
/// Resolves effective settings for a registered instance ID.
|
||||
///
|
||||
/// Instance-oriented callers should use [HMCLGameInstance#getEffectiveSettings()] instead.
|
||||
///
|
||||
/// @param instanceId the registered instance ID
|
||||
/// @return the effective settings
|
||||
/// @throws NoSuchGameInstanceException if the instance is not registered
|
||||
public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) {
|
||||
return getInstance(instanceId).getEffectiveSettings();
|
||||
}
|
||||
|
||||
public void undoMark(String id) {
|
||||
beingModpackVersions.remove(id);
|
||||
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
|
||||
///
|
||||
/// @param modded whether the new instance contains a mod loader
|
||||
/// @return whether installation-time run-directory content should be placed under the instance root
|
||||
public boolean shouldIsolateNewInstance(boolean modded) {
|
||||
GameSettings.Preset preset = getParentGameSettings(null);
|
||||
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
|
||||
return switch (type) {
|
||||
case NEVER -> false;
|
||||
case ALWAYS -> true;
|
||||
case MODDED -> modded;
|
||||
};
|
||||
}
|
||||
|
||||
public void markVersionLaunchedAbnormally(String id) {
|
||||
try {
|
||||
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unmarkVersionLaunchedAbnormally(String id) {
|
||||
Path file = getVersionRoot(id).resolve(".abnormal");
|
||||
/// Loads settings from disk for an unregistered id, running legacy migration when needed.
|
||||
private GameSettings.@Nullable Instance loadOrMigrateInstanceGameSettings(GameInstanceID instanceId) {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
|
||||
if (Files.isRegularFile(file)) {
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete abnormal mark file: " + file, e);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
return peekInstanceGameSettings(instanceId);
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = getGameDirectory().getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(this, instanceId, legacyParent);
|
||||
if (migrationResult == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeInstanceGameSettings(instanceId, migrationResult.setting());
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + instanceId, e);
|
||||
}
|
||||
return migrationResult.setting();
|
||||
}
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create();
|
||||
// These instance ids are forbidden because they may conflict with modpack configuration filenames
|
||||
private static final Set<String> FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest");
|
||||
|
||||
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
|
||||
public static boolean isValidInstanceId(String id) {
|
||||
if (!GameInstanceID.isValid(id))
|
||||
return false;
|
||||
|
||||
|
||||
// These version ids are forbidden because they may conflict with modpack configuration filenames
|
||||
private static final Set<String> FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList(
|
||||
"modpack", "minecraftinstance", "manifest"));
|
||||
|
||||
public static boolean isValidVersionId(String id) {
|
||||
if (FORBIDDEN_VERSION_IDS.contains(id))
|
||||
if (FORBIDDEN_INSTANCE_IDS.contains(id))
|
||||
return false;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS &&
|
||||
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
FORBIDDEN_INSTANCE_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
return false;
|
||||
|
||||
return FileUtils.isNameValid(id);
|
||||
return FileUtils.isNameValidForJar(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given version id conflicts with an existing version.
|
||||
* Returns true if the given instance id conflicts with an existing instance.
|
||||
*/
|
||||
public boolean versionIdConflicts(String id) {
|
||||
public boolean instanceIdConflicts(String instanceId) {
|
||||
try {
|
||||
return instanceIdConflicts(new GameInstanceID(instanceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean instanceIdConflicts(GameInstanceID id) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// on Windows, filenames are case-insensitive
|
||||
for (String existingId : versions.keySet()) {
|
||||
if (existingId.equalsIgnoreCase(id)) {
|
||||
for (HMCLGameInstance instance : getSnapshot().getInstances()) {
|
||||
if (instance.getId().toString().equalsIgnoreCase(id.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return versions.containsKey(id);
|
||||
return hasInstance(id);
|
||||
}
|
||||
}
|
||||
|
||||
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
|
||||
if (auto) {
|
||||
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (available <= 0) {
|
||||
return minimum;
|
||||
}
|
||||
public static long getAutoAllocatedMemory(long available, Platform platform) {
|
||||
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (usable <= 0) {
|
||||
return available;
|
||||
}
|
||||
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested = Math.min(available <= threshold
|
||||
? (long) (available * 0.8)
|
||||
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested;
|
||||
if (usable <= threshold)
|
||||
suggested = (long) (usable * 0.8);
|
||||
else
|
||||
suggested = Math.min(
|
||||
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
|
||||
16L * 1024 * 1024 * 1024);
|
||||
return Math.max(minimum, suggested);
|
||||
} else {
|
||||
return minimum;
|
||||
}
|
||||
return platform.getBits() == Bits.BIT_32
|
||||
? Math.min(suggested, 768 * 1024 * 1024) // https://github.com/HMCL-dev/HMCL/issues/6638
|
||||
: suggested;
|
||||
}
|
||||
|
||||
public static ProxyOption getProxyOption() {
|
||||
if (!config().hasProxy() || config().getProxyType() == null) {
|
||||
return ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
return switch (config().getProxyType()) {
|
||||
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||
case HTTP, SOCKS -> {
|
||||
String proxyHost = config().getProxyHost();
|
||||
int proxyPort = config().getProxyPort();
|
||||
|
||||
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||
yield ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
String proxyUser = config().getProxyUser();
|
||||
String proxyPass = config().getProxyPass();
|
||||
|
||||
if (StringUtils.isBlank(proxyUser)) {
|
||||
proxyUser = null;
|
||||
proxyPass = null;
|
||||
} else if (proxyPass == null) {
|
||||
proxyPass = "";
|
||||
}
|
||||
|
||||
if (config().getProxyType() == Proxy.Type.HTTP) {
|
||||
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
} else {
|
||||
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
}
|
||||
}
|
||||
default -> ProxyOption.Default.INSTANCE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
|
||||
/// Directory under the instance root that stores HMCL-managed instance metadata.
|
||||
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance state files.
|
||||
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||
|
||||
/// Current file name for instance-specific game settings.
|
||||
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||
|
||||
public HMCLGameRepositoryLayout(Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed metadata directory under the instance root.
|
||||
///
|
||||
/// This directory stores instance-scoped files owned by HMCL.
|
||||
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
|
||||
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed state directory under the instance metadata directory.
|
||||
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the current local game settings path under the instance configuration directory.
|
||||
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
|
||||
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
|
||||
/// Creates an empty unsealed HMCL snapshot.
|
||||
///
|
||||
/// @param repository the owning repository
|
||||
/// @param layout the HMCL layout for this snapshot
|
||||
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
|
||||
super(repository, layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameRepositorySnapshot newEmpty() {
|
||||
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public Collection<HMCLGameInstance> getInstances() {
|
||||
return (Collection<HMCLGameInstance>) super.getInstances();
|
||||
}
|
||||
}
|
||||
@@ -19,62 +19,118 @@ package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.download.GameBuilder;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
|
||||
@NotNullByDefault
|
||||
public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
private final Path zipFile;
|
||||
private final String name;
|
||||
private final GameInstanceID instanceId;
|
||||
|
||||
/// Existing instance selecting update mode, or `null` for a new installation.
|
||||
private final @Nullable DefaultGameInstance updateTarget;
|
||||
|
||||
private final HMCLGameRepository repository;
|
||||
private final DefaultDependencyManager dependency;
|
||||
private final Modpack modpack;
|
||||
private final List<Task<?>> dependencies = new ArrayList<>(1);
|
||||
private final List<Task<?>> dependents = new ArrayList<>(4);
|
||||
|
||||
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
|
||||
dependency = profile.getDependency();
|
||||
repository = profile.getRepository();
|
||||
/// Creates a task that installs a new HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the id of the new instance
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId) {
|
||||
this(repository, zipFile, modpack, instanceId, null);
|
||||
}
|
||||
|
||||
/// Creates a task that updates an existing HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instance the existing instance to update
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
|
||||
/// modpack configuration, or records another provider type
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
DefaultGameInstance instance) {
|
||||
this(repository, zipFile, modpack, instance.getId(), instance);
|
||||
}
|
||||
|
||||
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the target instance id
|
||||
/// @param updateTarget the existing instance selecting update mode, or `null` for install
|
||||
/// @throws IllegalArgumentException if an update target belongs to another repository or has
|
||||
/// no compatible configuration
|
||||
private HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable DefaultGameInstance updateTarget) {
|
||||
this.repository = repository;
|
||||
this.dependency = repository.getDependency();
|
||||
this.zipFile = zipFile;
|
||||
this.name = name;
|
||||
this.instanceId = instanceId;
|
||||
this.updateTarget = updateTarget;
|
||||
this.modpack = modpack;
|
||||
if (this.updateTarget != null) {
|
||||
dependency.validateGameInstance(this.updateTarget);
|
||||
}
|
||||
|
||||
Path run = repository.getRunDirectory(name);
|
||||
Path json = repository.getModpackConfiguration(name);
|
||||
if (repository.hasVersion(name) && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Version " + name + " already exists");
|
||||
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
|
||||
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
|
||||
if (this.updateTarget != null && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
|
||||
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
|
||||
|
||||
onDone().register(event -> {
|
||||
if (event.isFailed()) repository.removeVersionFromDisk(name);
|
||||
});
|
||||
|
||||
ModpackConfiguration<Modpack> config = null;
|
||||
@Nullable ModpackConfiguration<Modpack> config = null;
|
||||
try {
|
||||
if (Files.exists(json)) {
|
||||
if (this.updateTarget != null && Files.exists(json)) {
|
||||
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
|
||||
|
||||
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
|
||||
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
}
|
||||
} catch (JsonParseException | IOException ignore) {
|
||||
}
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
|
||||
|
||||
onDone().register(event -> {
|
||||
if (this.updateTarget == null && event.isFailed()) {
|
||||
repository.removeInstanceFromDisk(this.instanceId);
|
||||
}
|
||||
});
|
||||
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,20 +143,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
|
||||
///
|
||||
/// The pack manifest is used only for component detection. The builder creates a new
|
||||
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
|
||||
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
|
||||
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
|
||||
// reinstall libraries
|
||||
// libraries of Forge and OptiFine should be obtained by installation.
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
|
||||
continue;
|
||||
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
|
||||
}
|
||||
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
|
||||
.withId(instanceId)
|
||||
.withJar(null);
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
|
||||
|
||||
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
|
||||
try (GameBuilder builder = updateTarget == null
|
||||
? dependency.newGameBuilder(instanceId)
|
||||
: dependency.newGameBuilder(updateTarget)) {
|
||||
builder.enableIsolation();
|
||||
|
||||
String gameVersion = modpack.getGameVersion();
|
||||
builder.component(GameComponentType.GAME, gameVersion);
|
||||
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||
if (mark.componentType() == GameComponentType.GAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable String componentVersion = mark.version();
|
||||
if (componentVersion != null) {
|
||||
if (mark.componentType() == GameComponentType.OPTIFINE) {
|
||||
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
|
||||
if (matcher.matches()) {
|
||||
componentVersion = matcher.group("optifine");
|
||||
}
|
||||
}
|
||||
builder.component(mark.componentType(), componentVersion);
|
||||
}
|
||||
}
|
||||
dependencies.add(builder.buildAsync());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.mod.ModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
|
||||
public final class HMCLModpackManifest implements ModpackManifest {
|
||||
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
||||
|
||||
@@ -20,19 +20,20 @@ package org.jackhuang.hmcl.game;
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
public final class HMCLModpackProvider implements ModpackProvider {
|
||||
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
||||
@@ -43,12 +44,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
|
||||
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
|
||||
public Task<?> createUpdateTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
DefaultGameInstance instance,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
|
||||
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||
|
||||
@@ -56,9 +62,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||
}
|
||||
|
||||
Profile profile = repository.getProfile();
|
||||
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
|
||||
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,21 +70,26 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
|
||||
if (game.getJar() == null)
|
||||
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||
if (game.jar() == null)
|
||||
if (StringUtils.isBlank(manifest.getVersion()))
|
||||
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE);
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
|
||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
||||
public Task<?> getInstallTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,17 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.MaintainTask;
|
||||
import org.jackhuang.hmcl.download.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.launch.*;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
@@ -47,8 +48,10 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
@@ -57,6 +60,7 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -65,34 +69,43 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static javafx.application.Platform.runLater;
|
||||
import static javafx.application.Platform.setImplicitExit;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
|
||||
|
||||
public final class LauncherHelper {
|
||||
|
||||
private final Profile profile;
|
||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private Account account;
|
||||
private final String selectedVersion;
|
||||
private Path scriptFile;
|
||||
private final VersionSetting setting;
|
||||
private final GameSettings.Effective setting;
|
||||
private LauncherVisibility launcherVisibility;
|
||||
private boolean showLogs;
|
||||
private QuickPlayOption quickPlayOption;
|
||||
private boolean disableOfflineSkin = false;
|
||||
|
||||
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
|
||||
this.profile = Objects.requireNonNull(profile);
|
||||
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
|
||||
this.gameInstance = Objects.requireNonNull(gameInstance);
|
||||
this.account = Objects.requireNonNull(account);
|
||||
this.selectedVersion = Objects.requireNonNull(selectedVersion);
|
||||
this.setting = profile.getVersionSetting(selectedVersion);
|
||||
this.launcherVisibility = setting.getLauncherVisibility();
|
||||
this.showLogs = setting.isShowLogs();
|
||||
this.launchingStepsPane.setTitle(i18n("version.launch"));
|
||||
this.setting = gameInstance.getEffectiveSettings();
|
||||
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
|
||||
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
|
||||
this.launchingStepsPane.setTitle(i18n("instance.launch"));
|
||||
}
|
||||
|
||||
public HMCLGameInstance getGameInstance() {
|
||||
return gameInstance;
|
||||
}
|
||||
|
||||
private HMCLGameRepository repository() {
|
||||
return gameInstance.getRepository();
|
||||
}
|
||||
|
||||
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
|
||||
@@ -125,7 +138,7 @@ public final class LauncherHelper {
|
||||
public void launch() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
LOG.info("Launching game version: " + selectedVersion);
|
||||
LOG.info("Launching game instance: " + gameInstance.getId());
|
||||
|
||||
Controllers.dialog(launchingStepsPane);
|
||||
launch0();
|
||||
@@ -136,89 +149,148 @@ public final class LauncherHelper {
|
||||
launch();
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
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)));
|
||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
||||
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
|
||||
HMCLGameRepository repository = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
|
||||
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||
List<String> javaAgents = new ArrayList<>(0);
|
||||
List<String> javaArguments = new ArrayList<>(0);
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
|
||||
TaskExecutor executor = checkGameState(profile, setting, version.get())
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
|
||||
if (setting.isNotCheckGame())
|
||||
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck),
|
||||
Task.composeAsync(() -> {
|
||||
try {
|
||||
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
|
||||
@Nullable ModpackConfiguration<?> configuration =
|
||||
gameInstance.readModpackConfiguration();
|
||||
if (configuration == null) return null;
|
||||
@Nullable ModpackProvider provider =
|
||||
ModpackHelper.getProviderByType(configuration.getType());
|
||||
if (provider == null) return null;
|
||||
else return provider.createCompletionTask(dependencyManager, selectedVersion);
|
||||
else return provider.createCompletionTask(
|
||||
dependencyManager,
|
||||
gameInstance);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
Renderer renderer = setting.getRenderer();
|
||||
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
}
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|
||||
|| renderer.mesaDriverName() == null)
|
||||
return null;
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
javaAgents.add(agent);
|
||||
return null;
|
||||
}
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
javaAgents.add(agent);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
|
||||
.thenComposeAsync(() -> {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
enableAutoAgentForCurrentSetting();
|
||||
future.complete(null);
|
||||
}, () -> {
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
future.complete(null);
|
||||
});
|
||||
});
|
||||
return Task.fromCompletableFuture(future);
|
||||
}
|
||||
})
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
if (quickPlayOption != null) {
|
||||
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
|
||||
try {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg != null) {
|
||||
Object current = reg.queryValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg
|
||||
);
|
||||
if (current == null) {
|
||||
boolean result = reg.setValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg,
|
||||
"GpuPreference=2;"
|
||||
);
|
||||
if (result)
|
||||
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
|
||||
else
|
||||
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
|
||||
} else {
|
||||
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to apply high performance GPU preference", e);
|
||||
}
|
||||
}
|
||||
|
||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
repository,
|
||||
version.get(),
|
||||
gameInstance,
|
||||
launchManifest.get(),
|
||||
authInfo,
|
||||
launchOptions,
|
||||
launcherVisibility == LauncherVisibility.CLOSE
|
||||
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
|
||||
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
|
||||
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
|
||||
);
|
||||
}).thenComposeAsync(launcher -> { // launcher is prev task's result
|
||||
if (scriptFile == null) {
|
||||
@@ -242,7 +314,7 @@ public final class LauncherHelper {
|
||||
} else {
|
||||
runLater(() -> {
|
||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
||||
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
});
|
||||
}
|
||||
}).withFakeProgress(
|
||||
@@ -281,9 +353,8 @@ public final class LauncherHelper {
|
||||
} else if (ex instanceof NotDecompressingNativesException) {
|
||||
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
||||
} else if (ex instanceof LibraryDownloadException) {
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
@@ -297,8 +368,7 @@ public final class LauncherHelper {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
@@ -313,8 +383,7 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.injector_download_failure");
|
||||
} else if (ex instanceof CharacterDeletedException) {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
@@ -344,7 +413,7 @@ public final class LauncherHelper {
|
||||
message = StringUtils.getStackTrace(ex);
|
||||
}
|
||||
Controllers.dialog(message,
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
|
||||
MessageType.ERROR);
|
||||
}
|
||||
}
|
||||
@@ -357,21 +426,22 @@ public final class LauncherHelper {
|
||||
executor.start();
|
||||
}
|
||||
|
||||
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
|
||||
private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
|
||||
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||
|
||||
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
||||
try {
|
||||
return setting.getJava(gameVersion, version);
|
||||
return setting.getJava(gameVersion, manifest);
|
||||
} catch (InterruptedException e) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
});
|
||||
Task<JavaRuntime> task;
|
||||
if (setting.isNotCheckJVM()) {
|
||||
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
|
||||
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
|
||||
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
|
||||
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
|
||||
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
|
||||
if (java != null) {
|
||||
return Task.completed(java);
|
||||
@@ -384,10 +454,19 @@ public final class LauncherHelper {
|
||||
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
||||
|
||||
GameJavaVersion targetJavaVersion = null;
|
||||
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
try {
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
|
||||
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
|
||||
GameJavaVersion minimumJavaVersion = null;
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
if (minimumJavaVersion == null)
|
||||
minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
|
||||
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
|
||||
Controllers.dialog(
|
||||
@@ -402,11 +481,20 @@ public final class LauncherHelper {
|
||||
targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
} else
|
||||
targetJavaVersion = version.getJavaVersion();
|
||||
} else {
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetJavaVersion == null)
|
||||
targetJavaVersion = manifest.javaVersion();
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||
downloadJava(targetJavaVersion, profile)
|
||||
downloadJava(targetJavaVersion, gameInstance.getRepository())
|
||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(downloadedJava);
|
||||
@@ -432,8 +520,8 @@ public final class LauncherHelper {
|
||||
|
||||
if (java != null) {
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violatedMandatoryConstraints.add(constraint);
|
||||
} else {
|
||||
@@ -449,7 +537,7 @@ public final class LauncherHelper {
|
||||
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
|
||||
|
||||
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
|
||||
if (suggestedJava != null) {
|
||||
FXUtils.runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
|
||||
@@ -468,17 +556,21 @@ public final class LauncherHelper {
|
||||
return result;
|
||||
} else {
|
||||
GameJavaVersion gameJavaVersion;
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
|
||||
gameJavaVersion = GameJavaVersion.JAVA_21;
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
gameJavaVersion = version.getJavaVersion();
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
|
||||
gameJavaVersion = cleanroomVersion != null
|
||||
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
||||
: GameJavaVersion.JAVA_21;
|
||||
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
gameJavaVersion = manifest.javaVersion();
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
||||
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
else
|
||||
gameJavaVersion = null;
|
||||
|
||||
if (gameJavaVersion != null) {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
if (throwable == null) {
|
||||
setting.setJavaAutoSelected();
|
||||
future.complete(downloadedJava);
|
||||
@@ -491,7 +583,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
|
||||
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
|
||||
return result;
|
||||
} else {
|
||||
@@ -528,7 +620,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
// 32-bit JVM cannot make use of too much memory.
|
||||
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
// 1.5 * 1024 is an inaccurate number.
|
||||
// Actual memory limit depends on operating system and memory.
|
||||
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
|
||||
@@ -548,7 +640,7 @@ public final class LauncherHelper {
|
||||
break;
|
||||
case MODDED_JAVA_16:
|
||||
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
|
||||
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
|
||||
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
|
||||
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
|
||||
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
||||
else
|
||||
@@ -560,9 +652,14 @@ public final class LauncherHelper {
|
||||
case MODDED_JAVA_21:
|
||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||
break;
|
||||
case CLEANROOM_JAVA_21:
|
||||
suggestions.add(i18n("launch.advice.cleanroom"));
|
||||
case CLEANROOM: {
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null)
|
||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||
else
|
||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||
break;
|
||||
}
|
||||
case VANILLA_JAVA_8_51:
|
||||
suggestions.add(i18n("launch.advice.java8_51_1_13"));
|
||||
break;
|
||||
@@ -570,8 +667,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
@@ -582,24 +679,24 @@ public final class LauncherHelper {
|
||||
|
||||
// Cannot allocate too much memory exceeding free space.
|
||||
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
|
||||
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
|
||||
}
|
||||
|
||||
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
|
||||
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
|
||||
.map(VersionNumber::asVersion)
|
||||
.orElse(null);
|
||||
|
||||
// Forge 2760~2773 will crash game with LiteLoader.
|
||||
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
|
||||
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
|
||||
}
|
||||
|
||||
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
|
||||
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
|
||||
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
|
||||
}
|
||||
@@ -631,14 +728,14 @@ public final class LauncherHelper {
|
||||
return task.withStage("launch.state.java");
|
||||
}
|
||||
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
|
||||
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
||||
i18n("message.warning"),
|
||||
MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
|
||||
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
|
||||
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
@@ -702,7 +799,10 @@ public final class LauncherHelper {
|
||||
case HIDE_AND_REOPEN:
|
||||
runLater(() -> {
|
||||
Optional.ofNullable(Controllers.getStage())
|
||||
.ifPresent(Stage::show);
|
||||
.ifPresent(stage -> {
|
||||
stage.setIconified(false);
|
||||
stage.show();
|
||||
});
|
||||
});
|
||||
break;
|
||||
case KEEP:
|
||||
@@ -721,16 +821,22 @@ public final class LauncherHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed process listener.
|
||||
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
|
||||
* Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
*/
|
||||
private void enableAutoAgentForCurrentSetting() {
|
||||
GameSettings.Instance instance = setting.getInstance();
|
||||
if (instance != null
|
||||
&& instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) {
|
||||
instance.allowAutoAgentProperty().setValue(true);
|
||||
} else {
|
||||
setting.getPreset().allowAutoAgentProperty().setValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The managed process listener.
|
||||
/// Guarantee that one Java [Process], one [HMCLProcessListener].
|
||||
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
private final class HMCLProcessListener implements ProcessListener {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final HMCLGameRepository repository;
|
||||
private final Version version;
|
||||
private final LaunchOptions launchOptions;
|
||||
private ManagedProcess process;
|
||||
private volatile boolean lwjgl;
|
||||
@@ -742,9 +848,7 @@ public final class LauncherHelper {
|
||||
private Thread submitLogThread;
|
||||
private LinkedBlockingQueue<Log> logBuffer;
|
||||
|
||||
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.repository = repository;
|
||||
this.version = version;
|
||||
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.launchOptions = launchOptions;
|
||||
this.launchingLatch = launchingLatch;
|
||||
this.detectWindow = detectWindow;
|
||||
@@ -827,8 +931,10 @@ public final class LauncherHelper {
|
||||
runLater(() -> {
|
||||
// If application was stopped and execution services did not finish termination,
|
||||
// these codes will be executed.
|
||||
if (Controllers.getStage() != null) {
|
||||
Controllers.getStage().hide();
|
||||
@Nullable Stage stage = Controllers.getStage();
|
||||
if (stage != null) {
|
||||
stage.setIconified(false);
|
||||
stage.hide();
|
||||
launchingLatch.countDown();
|
||||
}
|
||||
});
|
||||
@@ -869,7 +975,7 @@ public final class LauncherHelper {
|
||||
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
||||
if (showLogs) {
|
||||
if (level == null)
|
||||
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
logBuffer.add(new Log(log, level));
|
||||
} else {
|
||||
lock.lock();
|
||||
@@ -901,7 +1007,7 @@ public final class LauncherHelper {
|
||||
@Override
|
||||
public void onExit(int exitCode, ExitType exitType) {
|
||||
if (showLogs) {
|
||||
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
submitLogThread.interrupt();
|
||||
try {
|
||||
submitLogThread.join();
|
||||
@@ -927,8 +1033,8 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (exitType != ExitType.NORMAL) {
|
||||
repository.markVersionLaunchedAbnormally(version.getId());
|
||||
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
|
||||
gameInstance.markLaunchedAbnormally();
|
||||
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||
}
|
||||
|
||||
checkExit();
|
||||
|
||||
+64
-32
@@ -18,12 +18,12 @@
|
||||
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;
|
||||
import org.jackhuang.hmcl.ui.versions.ModTranslations;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.ui.instances.ModTranslations;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -32,14 +32,22 @@ import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
|
||||
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
|
||||
private static final int CONTAIN_CHINESE_WEIGHT = 10;
|
||||
|
||||
private static final int INITIAL_CAPACITY = 16;
|
||||
|
||||
protected abstract RemoteModRepository getBackedRemoteModRepository();
|
||||
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
|
||||
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
@Override
|
||||
public String getApiBaseUrl() {
|
||||
return getBackedRemoteModRepository().getApiBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseUrl() {
|
||||
return getBackedRemoteModRepository().getBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
@@ -47,46 +55,55 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
||||
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
|
||||
if (englishSearchFiltersSet.contains(englishWord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
englishSearchFiltersSet.add(englishWord);
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count >= 3) break;
|
||||
}
|
||||
|
||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
||||
if (englishSearchFiltersSet.isEmpty()) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||
{
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
|
||||
SearchResult searchResult = null;
|
||||
List<RemoteAddon> remoteAddons = List.of();
|
||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
|
||||
remoteAddons = searchResult.results().toList();
|
||||
if (!remoteAddons.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (RemoteAddon remoteAddon : remoteAddons) {
|
||||
if (chineseIndex > englishIndex) {
|
||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoteMod remoteMod = iterator.next();
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
searchResultArray[chineseIndex++] = remoteMod;
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
searchResultArray[englishIndex--] = remoteMod;
|
||||
searchResultArray[englishIndex--] = remoteAddon;
|
||||
}
|
||||
}
|
||||
totalPages = searchResult.getTotalPages();
|
||||
totalPages = searchResult.totalPages();
|
||||
}
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
@@ -110,22 +127,37 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod getModById(String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getModById(id);
|
||||
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getModFile(modId, fileId);
|
||||
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionsById(id);
|
||||
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
|
||||
return getBackedRemoteModRepository().getVersionPageUrl(version);
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,18 @@ package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
|
||||
public final class Log {
|
||||
public static final int DEFAULT_LOG_LINES = 2000;
|
||||
|
||||
public static int getLogLines() {
|
||||
Integer lines = config().getLogLines();
|
||||
Integer lines = settings().logLinesProperty().get();
|
||||
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
||||
}
|
||||
|
||||
private final String log;
|
||||
private Log4jLevel level;
|
||||
private boolean selected = false;
|
||||
|
||||
public Log(String log) {
|
||||
this.log = log;
|
||||
@@ -57,14 +56,6 @@ public final class Log {
|
||||
return level;
|
||||
}
|
||||
|
||||
public boolean isSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return log;
|
||||
|
||||
@@ -17,15 +17,18 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -42,22 +45,24 @@ public final class LogExporter {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> exportLogs(
|
||||
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
|
||||
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
|
||||
PathMatcher logMatcher) {
|
||||
Path runDirectory = gameRepository.getRunDirectory(versionId);
|
||||
Path baseDirectory = gameRepository.getBaseDirectory();
|
||||
List<String> versions = new ArrayList<>();
|
||||
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
|
||||
Path runDirectory = options.getGameDir();
|
||||
List<GameInstanceID> instances = new ArrayList<>();
|
||||
|
||||
String currentVersionId = versionId;
|
||||
HashSet<String> resolvedSoFar = new HashSet<>();
|
||||
GameInstanceID currentInstanceId = instance.id;
|
||||
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
|
||||
while (true) {
|
||||
if (resolvedSoFar.contains(currentVersionId)) break;
|
||||
resolvedSoFar.add(currentVersionId);
|
||||
Version currentVersion = gameRepository.getVersion(currentVersionId);
|
||||
versions.add(currentVersionId);
|
||||
if (resolvedSoFar.contains(currentInstanceId)) break;
|
||||
resolvedSoFar.add(currentInstanceId);
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
|
||||
if (currentInstance == null)
|
||||
break;
|
||||
instances.add(currentInstanceId);
|
||||
|
||||
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
|
||||
currentVersionId = currentVersion.getInheritsFrom();
|
||||
if (currentInstance.getManifest().inheritsFrom() != null) {
|
||||
currentInstanceId = currentInstance.getManifest().inheritsFrom();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -74,10 +79,12 @@ public final class LogExporter {
|
||||
zipper.putTextFile(logs, "minecraft.log");
|
||||
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
||||
|
||||
for (String id : versions) {
|
||||
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
|
||||
if (Files.exists(versionJson)) {
|
||||
zipper.putFile(versionJson, id + ".json");
|
||||
for (GameInstanceID id : instances) {
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
|
||||
if (currentInstance != null) {
|
||||
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
|
||||
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -17,43 +17,39 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Profile profile;
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
|
||||
this.profile = profile;
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
Path subdirectory;
|
||||
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
|
||||
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
|
||||
}
|
||||
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
|
||||
|
||||
Path dest = Paths.get("externalgames").resolve(name);
|
||||
|
||||
setResult(dest);
|
||||
|
||||
new Unzipper(zipFile, dest)
|
||||
.setSubDirectory(subdirectory.toString())
|
||||
.setSubDirectory(subdirectory)
|
||||
.setTerminateIfSubDirectoryNotExists()
|
||||
.setEncoding(charset)
|
||||
.unzip();
|
||||
|
||||
@@ -20,44 +20,49 @@ package org.jackhuang.hmcl.game;
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.mod.*;
|
||||
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.modpack.*;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.toIterable;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/// Utilities for reading, installing, and applying modpack-specific game settings.
|
||||
@NotNullByDefault
|
||||
public final class ModpackHelper {
|
||||
private ModpackHelper() {
|
||||
}
|
||||
@@ -104,8 +109,8 @@ public final class ModpackHelper {
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
||||
try {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||
throw new ManuallyCreatedModpackException(file);
|
||||
} catch (IOException e) {
|
||||
// ignore it
|
||||
@@ -114,28 +119,35 @@ public final class ModpackHelper {
|
||||
throw new UnsupportedModpackException(file.toString());
|
||||
}
|
||||
|
||||
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
|
||||
Path root = fs.getPath("/");
|
||||
if (isMinecraftDirectory(root)) return root;
|
||||
try (Stream<Path> firstLayer = Files.list(root)) {
|
||||
for (Path dir : toIterable(firstLayer)) {
|
||||
if (isMinecraftDirectory(dir)) return dir;
|
||||
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
|
||||
throws IOException, UnsupportedModpackException {
|
||||
|
||||
try (Stream<Path> secondLayer = Files.list(dir)) {
|
||||
for (Path subdir : toIterable(secondLayer)) {
|
||||
if (isMinecraftDirectory(subdir)) return subdir;
|
||||
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
|
||||
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
|
||||
|
||||
if (isMinecraftDirectory(rootDir)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(firstLayer)) {
|
||||
return firstLayer.getName();
|
||||
}
|
||||
|
||||
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(secondLayer)) {
|
||||
return firstLayer.getName() + "/" + secondLayer.getName();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
throw new UnsupportedModpackException(modpackName);
|
||||
}
|
||||
|
||||
private static boolean isMinecraftDirectory(Path path) {
|
||||
return Files.isDirectory(path.resolve("versions")) &&
|
||||
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
|
||||
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
|
||||
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
|
||||
}
|
||||
|
||||
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
|
||||
@@ -146,16 +158,10 @@ public final class ModpackHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -165,39 +171,55 @@ public final class ModpackHelper {
|
||||
}
|
||||
};
|
||||
|
||||
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
|
||||
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static boolean isExternalGameNameConflicts(String name) {
|
||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||
}
|
||||
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
|
||||
if (isExternalGameNameConflicts(name)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
Profile newProfile = new Profile(name, location);
|
||||
newProfile.setUseRelativePath(true);
|
||||
Profiles.getProfiles().add(newProfile);
|
||||
Profiles.setSelectedProfile(newProfile);
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
LocalizedText.plain(name),
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
|
||||
}
|
||||
|
||||
/// Creates an install task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the new instance id
|
||||
/// @param modpack the parsed modpack
|
||||
/// @param iconUrl the optional icon URL, or `null`
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the install task
|
||||
public static Task<?> getInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
Modpack modpack,
|
||||
@Nullable String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -208,114 +230,174 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
|
||||
.withStagesHints("hmcl.modpack", "hmcl.modpack.download");
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||
return new ModpackUpdateTask(
|
||||
instance,
|
||||
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param charset the archive encoding
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the update task
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Charset charset,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
else
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
}
|
||||
|
||||
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
vs.setUsesGlobal(false);
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
if (c.isOverrideJavaLocation()) {
|
||||
vs.setJavaDir(Lang.nonNull(c.getJavaPath(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE);
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_CUSTOM_JAVA_PATH);
|
||||
setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM);
|
||||
setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideMemory()) {
|
||||
vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY
|
||||
));
|
||||
setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
if (c.getMaxMemory() != null)
|
||||
vs.setMaxMemory(c.getMaxMemory());
|
||||
vs.setMinMemory(c.getMinMemory());
|
||||
setting.maxMemoryProperty().setValue(c.getMaxMemory());
|
||||
setting.minMemoryProperty().setValue(c.getMinMemory());
|
||||
}
|
||||
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideConsole()) {
|
||||
vs.setShowLogs(c.isShowConsole());
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
|
||||
setting.showLogsProperty().setValue(c.isShowConsole());
|
||||
}
|
||||
|
||||
if (c.isOverrideWindow()) {
|
||||
vs.setFullscreen(c.isFullscreen());
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_WINDOW_TYPE,
|
||||
GameSettings.PROPERTY_WIDTH,
|
||||
GameSettings.PROPERTY_HEIGHT
|
||||
));
|
||||
setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
|
||||
if (c.getWidth() != null)
|
||||
vs.setWidth(c.getWidth());
|
||||
setting.widthProperty().setValue(c.getWidth().doubleValue());
|
||||
if (c.getHeight() != null)
|
||||
vs.setHeight(c.getHeight());
|
||||
setting.heightProperty().setValue(c.getHeight().doubleValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.toVersionSetting(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.toGameSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
|
||||
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
|
||||
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
|
||||
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
|
||||
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE
|
||||
));
|
||||
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
|
||||
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
|
||||
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
|
||||
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,10 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@@ -50,7 +53,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
private String idToken;
|
||||
|
||||
private OAuthServer(int port) {
|
||||
super(port);
|
||||
super("localhost", port);
|
||||
|
||||
this.port = port;
|
||||
|
||||
@@ -163,8 +166,16 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!future.isDone())
|
||||
future.completeExceptionally(new AuthenticationException("OAuth server is closing"));
|
||||
stop();
|
||||
}
|
||||
|
||||
public static class Factory implements OAuth.Callback {
|
||||
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
|
||||
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
|
||||
|
||||
@@ -192,6 +203,11 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loginCompletedDeviceCode() {
|
||||
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
@@ -228,6 +244,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoginCompletedDeviceCodeEvent extends Event {
|
||||
public LoginCompletedDeviceCodeEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
|
||||
public static class OpenBrowserEvent extends Event {
|
||||
private final String url;
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ import java.io.InputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -65,29 +68,18 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
// ==== Texture Loading ====
|
||||
public static class LoadedTexture {
|
||||
private final Image image;
|
||||
private final Map<String, String> metadata;
|
||||
|
||||
public LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
this.image = requireNonNull(image);
|
||||
this.metadata = requireNonNull(metadata);
|
||||
}
|
||||
|
||||
public Image getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata;
|
||||
public record LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
public LoadedTexture {
|
||||
requireNonNull(image);
|
||||
requireNonNull(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins");
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_USER_HOME.resolve("skins");
|
||||
|
||||
private static Path getTexturePath(Texture texture) {
|
||||
String url = texture.getUrl();
|
||||
String url = texture.url();
|
||||
int slash = url.lastIndexOf('/');
|
||||
int dot = url.lastIndexOf('.');
|
||||
if (dot < slash) {
|
||||
@@ -99,7 +91,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
|
||||
if (StringUtils.isBlank(texture.getUrl())) {
|
||||
if (StringUtils.isBlank(texture.url())) {
|
||||
throw new IOException("Texture url is empty");
|
||||
}
|
||||
|
||||
@@ -107,14 +99,14 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(texture.getUrl(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.getUrl());
|
||||
new FileDownloadTask(texture.url(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.url());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
// concurrency conflict?
|
||||
LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e);
|
||||
LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e);
|
||||
} else {
|
||||
throw new IOException("Failed to download texture " + texture.getUrl());
|
||||
throw new IOException("Failed to download texture " + texture.url());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +119,7 @@ public final class TexturesLoader {
|
||||
if (img.isError())
|
||||
throw img.getException();
|
||||
|
||||
Map<String, String> metadata = texture.getMetadata();
|
||||
Map<String, String> metadata = texture.metadata();
|
||||
if (metadata == null) {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
@@ -158,7 +150,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static TextureModel getDefaultModel(UUID uuid) {
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model"))
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model"))
|
||||
? TextureModel.WIDE
|
||||
: TextureModel.SLIM;
|
||||
}
|
||||
@@ -176,7 +168,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
})
|
||||
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
|
||||
.filter(it -> StringUtils.isNotBlank(it.getUrl())))
|
||||
.filter(it -> StringUtils.isNotBlank(it.url())))
|
||||
.asyncMap(it -> {
|
||||
if (it.isPresent()) {
|
||||
Texture texture = it.get();
|
||||
@@ -184,7 +176,7 @@ public final class TexturesLoader {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -195,29 +187,29 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getUUID());
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
|
||||
if (account instanceof OfflineAccount) {
|
||||
OfflineAccount offlineAccount = (OfflineAccount) account;
|
||||
|
||||
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
|
||||
InvalidationListener listener = o -> {
|
||||
Skin skin = offlineAccount.getSkin();
|
||||
String username = offlineAccount.getUsername();
|
||||
String profileName = offlineAccount.getProfileName();
|
||||
|
||||
binding.set(uuidFallback);
|
||||
if (skin != null) {
|
||||
skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
skin.load(profileName).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to load texture", exception);
|
||||
} else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) {
|
||||
} else if (result != null && result.skin() != null && result.skin().image() != null) {
|
||||
Map<String, String> metadata;
|
||||
if (result.getModel() != null) {
|
||||
metadata = singletonMap("model", result.getModel().modelName);
|
||||
if (result.model() != null) {
|
||||
metadata = singletonMap("model", result.model().modelName);
|
||||
} else {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
|
||||
binding.set(new LoadedTexture(result.getSkin().getImage(), metadata));
|
||||
binding.set(new LoadedTexture(result.skin().image(), metadata));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -234,12 +226,12 @@ public final class TexturesLoader {
|
||||
.asyncMap(textures -> {
|
||||
if (textures.isPresent()) {
|
||||
Texture texture = textures.get().get(TextureType.SKIN);
|
||||
if (texture != null && StringUtils.isNotBlank(texture.getUrl())) {
|
||||
if (texture != null && StringUtils.isNotBlank(texture.url())) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -318,7 +310,7 @@ public final class TexturesLoader {
|
||||
fxAvatarBinding(canvas, skinBinding(account));
|
||||
else {
|
||||
unbindAvatar(canvas);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getUUID()).image);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getProfileID()).image);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
|
||||
}
|
||||
|
||||
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
|
||||
private static void getAllJava(List<Path> list, Platform platform, Path platformRoot) {
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(platformRoot)) {
|
||||
for (Path file : stream) {
|
||||
try {
|
||||
@@ -117,8 +117,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
if (Files.isDirectory(javaDir)) {
|
||||
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
|
||||
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
|
||||
list.add(executable);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
@@ -131,28 +130,23 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<JavaRuntime> getAllJava(Platform platform) {
|
||||
public Collection<Path> getAllJava(Platform platform) {
|
||||
Path platformRoot = getPlatformRoot(platform);
|
||||
if (!Files.isDirectory(platformRoot))
|
||||
return Collections.emptyList();
|
||||
|
||||
ArrayList<JavaRuntime> list = new ArrayList<>();
|
||||
|
||||
getAllJava(list, platform, platformRoot, true);
|
||||
if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
|
||||
platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName());
|
||||
if (Files.isDirectory(platformRoot))
|
||||
getAllJava(list, platform, platformRoot, false);
|
||||
}
|
||||
ArrayList<Path> list = new ArrayList<>();
|
||||
|
||||
getAllJava(list, platform, platformRoot);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<JavaRuntime> getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
Path javaDir = getJavaDir(platform, gameJavaVersion);
|
||||
Path tempDir = getPlatformRoot(platform).resolve(".tmp").resolve(javaDir.getFileName());
|
||||
|
||||
return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
|
||||
return new MojangJavaDownloadTask(downloadProvider, javaDir, tempDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
|
||||
Path executable;
|
||||
try {
|
||||
executable = JavaManager.getExecutable(javaDir).toRealPath();
|
||||
@@ -165,16 +159,16 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
|
||||
JavaInfo info;
|
||||
if (JavaManager.isCompatible(platform))
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
info = JavaInfoUtils.fromExecutable(executable);
|
||||
else
|
||||
info = new JavaInfo(platform, result.download.getVersion().getName(), null);
|
||||
info = new JavaInfo(platform, result.download().version().name(), null);
|
||||
|
||||
Map<String, Object> update = new LinkedHashMap<>();
|
||||
update.put("provider", "mojang");
|
||||
update.put("component", gameJavaVersion.component());
|
||||
|
||||
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
|
||||
result.remoteFiles.getFiles().forEach((path, file) -> {
|
||||
result.remoteFiles().files().forEach((path, file) -> {
|
||||
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
|
||||
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
|
||||
if (downloadInfo != null) {
|
||||
@@ -196,12 +190,12 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
|
||||
Path javaDir = getJavaDir(platform, name);
|
||||
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
|
||||
if (!result.getInfo().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
|
||||
if (!result.info().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
|
||||
|
||||
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
||||
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
||||
return JavaRuntime.of(executable, result.getInfo(), true);
|
||||
return JavaRuntime.of(executable, result.info(), true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
@@ -27,7 +28,6 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
@@ -39,35 +39,10 @@ public final class JavaInfoUtils {
|
||||
private JavaInfoUtils() {
|
||||
}
|
||||
|
||||
private static Path tryFindReleaseFile(Path executable) {
|
||||
Path parent = executable.getParent();
|
||||
if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) {
|
||||
Path javaHome = parent.getParent();
|
||||
if (javaHome != null && javaHome.getFileName() != null) {
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
String javaHomeName = javaHome.getFileName().toString();
|
||||
if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9"))
|
||||
&& Files.isRegularFile(releaseFile)) {
|
||||
return releaseFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException {
|
||||
public static @NotNull JavaInfo fromExecutable(Path executable) throws IOException {
|
||||
assert executable.isAbsolute();
|
||||
|
||||
Path releaseFile;
|
||||
if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) {
|
||||
try {
|
||||
return JavaInfo.fromReleaseFile(releaseFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
Path thisPath = JarUtils.thisJarPath();
|
||||
|
||||
if (thisPath == null) {
|
||||
throw new IOException("Failed to find current HMCL location");
|
||||
}
|
||||
@@ -94,7 +69,6 @@ public final class JavaInfoUtils {
|
||||
? architecture
|
||||
: Architecture.SYSTEM_ARCH);
|
||||
|
||||
|
||||
return new JavaInfo(platform, result.javaVersion, result.javaVendor);
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
@@ -103,14 +77,9 @@ public final class JavaInfoUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Result {
|
||||
@SerializedName("os.name")
|
||||
public String osName;
|
||||
@SerializedName("os.arch")
|
||||
public String osArch;
|
||||
@SerializedName("java.version")
|
||||
public String javaVersion;
|
||||
@SerializedName("java.vendor")
|
||||
public String javaVendor;
|
||||
@JsonSerializable
|
||||
private record Result(@SerializedName("os.name") String osName, @SerializedName("os.arch") String osArch,
|
||||
@SerializedName("java.version") String javaVersion,
|
||||
@SerializedName("java.vendor") String javaVendor) {
|
||||
}
|
||||
}
|
||||
@@ -17,22 +17,32 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -40,9 +50,14 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -55,8 +70,18 @@ public final class JavaManager {
|
||||
private 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"));
|
||||
private static final String[] KNOWN_VENDOR_DIRECTORIES = {
|
||||
"Java",
|
||||
"BellSoft",
|
||||
"AdoptOpenJDK",
|
||||
"Zulu",
|
||||
"Microsoft",
|
||||
"Eclipse Foundation",
|
||||
"Semeru"
|
||||
};
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
|
||||
|
||||
public static String getMojangJavaPlatform(Platform platform) {
|
||||
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
||||
@@ -103,16 +128,27 @@ public final class JavaManager {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
// Windows x86-64 platform is compatible with x86 programs
|
||||
return architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
|
||||
// Since Windows 10 Build 21277, Windows Arm64 has been compatible with x86-64 programs via translation
|
||||
if (architecture == Architecture.X86_64 && Platform.isSupportedTranslationX86_64())
|
||||
return true;
|
||||
|
||||
// Windows Arm64 is compatible with x86 programs via translation
|
||||
if (architecture == Architecture.X86)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
return architecture == Architecture.X86;
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
return architecture == Architecture.X86_64;
|
||||
break;
|
||||
}
|
||||
@@ -160,12 +196,12 @@ public final class JavaManager {
|
||||
return javaRuntime;
|
||||
}
|
||||
|
||||
JavaInfo info = JavaInfoUtils.fromExecutable(executable, true);
|
||||
JavaInfo info = JavaInfoUtils.fromExecutable(executable);
|
||||
return JavaRuntime.of(executable, info, false);
|
||||
}
|
||||
|
||||
public static void refresh() {
|
||||
Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
Task.supplyAsync(() -> searchPotentialJavaExecutables(false)).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (result != null) {
|
||||
LATCH.await();
|
||||
allJava = result;
|
||||
@@ -183,9 +219,11 @@ public final class JavaManager {
|
||||
|
||||
String pathString = javaRuntime.getBinary().toString();
|
||||
|
||||
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
|
||||
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
if (!SettingsManager.isUserSettingsReadOnly()) {
|
||||
SettingsManager.userSettings().getDisabledJava().remove(pathString);
|
||||
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
}
|
||||
}
|
||||
return javaRuntime;
|
||||
});
|
||||
@@ -237,7 +275,7 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void addJava(JavaRuntime java) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (!oldMap.containsKey(java.getBinary())) {
|
||||
@@ -248,12 +286,12 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(JavaRuntime java) throws InterruptedException {
|
||||
removeJava(java.getBinary());
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(Path realPath) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (oldMap.containsKey(realPath)) {
|
||||
@@ -276,13 +314,13 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, version);
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, manifest);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
|
||||
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
|
||||
|
||||
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
@@ -303,8 +341,8 @@ public final class JavaManager {
|
||||
boolean violationSuggested = false;
|
||||
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violationMandatory = true;
|
||||
} else {
|
||||
@@ -326,7 +364,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables();
|
||||
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables(true);
|
||||
JavaManager.allJava = allJava;
|
||||
LATCH.countDown();
|
||||
FXUtils.runInFX(() -> updateAllJavaProperty(allJava));
|
||||
@@ -334,56 +372,56 @@ public final class JavaManager {
|
||||
|
||||
// search java
|
||||
|
||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables() {
|
||||
Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM);
|
||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
|
||||
Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json"));
|
||||
if (useCache)
|
||||
searcher.loadCache();
|
||||
|
||||
searcher.searchAllJavaInRepository(Platform.SYSTEM_PLATFORM);
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64);
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
|
||||
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files");
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)");
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)");
|
||||
}
|
||||
searcher.searchJavaInProgramFiles("ProgramFiles", "C:\\Program Files");
|
||||
searcher.searchJavaInProgramFiles("ProgramFiles(x86)", "C:\\Program Files (x86)");
|
||||
break;
|
||||
case LINUX:
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/java")); // Oracle RPMs
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib32/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib64/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
|
||||
break;
|
||||
case MACOS:
|
||||
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines"));
|
||||
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
|
||||
searcher.searchJavaInMacJavaVirtualMachines(Path.of("/Library/Java/JavaVirtualMachines"));
|
||||
searcher.searchJavaInMacJavaVirtualMachines(Path.of(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
|
||||
searcher.tryAddJavaExecutable(Path.of("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
|
||||
searcher.tryAddJavaExecutable(Path.of("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
|
||||
// Homebrew
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java"));
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk"));
|
||||
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) {
|
||||
searcher.tryAddJavaExecutable(Path.of("/opt/homebrew/opt/java/bin/java"));
|
||||
searcher.searchAllJavaInDirectory(Path.of("/opt/homebrew/Cellar/openjdk"));
|
||||
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Path.of("/opt/homebrew/Cellar"), "openjdk@*")) {
|
||||
for (Path dir : dirs) {
|
||||
searchAllJavaInDirectory(javaRuntimes, dir);
|
||||
searcher.searchAllJavaInDirectory(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar");
|
||||
@@ -397,16 +435,16 @@ public final class JavaManager {
|
||||
// Search Minecraft bundled runtimes
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
|
||||
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
}
|
||||
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
|
||||
// Search in PATH.
|
||||
if (System.getenv("PATH") != null) {
|
||||
@@ -420,7 +458,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
try {
|
||||
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
searcher.tryAddJavaExecutable(Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
} catch (InvalidPathException ignored) {
|
||||
}
|
||||
}
|
||||
@@ -430,16 +468,17 @@ public final class JavaManager {
|
||||
String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator);
|
||||
for (String path : paths) {
|
||||
try {
|
||||
tryAddJavaHome(javaRuntimes, Paths.get(path));
|
||||
searcher.tryAddJavaHome(Path.of(path));
|
||||
} catch (InvalidPathException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
|
||||
try {
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath));
|
||||
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path: " + javaPath);
|
||||
}
|
||||
@@ -447,233 +486,406 @@ public final class JavaManager {
|
||||
|
||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||
if (currentJava != null
|
||||
&& !javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
javaRuntimes.put(currentJava.getBinary(), currentJava);
|
||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||
}
|
||||
|
||||
LOG.trace(javaRuntimes.values().stream().sorted()
|
||||
searcher.saveCache();
|
||||
|
||||
LOG.trace(searcher.javaRuntimes.values().stream().sorted()
|
||||
.map(it -> String.format(" - %s %s (%s, %s): %s",
|
||||
it.isJDK() ? "JDK" : "JRE",
|
||||
it.getVersion(),
|
||||
it.getPlatform().getArchitecture().getDisplayName(),
|
||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
it.getBinary()))
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", "")));
|
||||
|
||||
return javaRuntimes;
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||
return searcher.javaRuntimes;
|
||||
}
|
||||
|
||||
private static void tryAddJavaHome(Map<Path, JavaRuntime> javaRuntimes, Path javaHome) {
|
||||
Path executable = getExecutable(javaHome);
|
||||
if (!Files.isRegularFile(executable)) {
|
||||
return;
|
||||
private static final class Searcher {
|
||||
private final Path cacheFile;
|
||||
final Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
|
||||
private final LinkedHashMap<Path, JavaInfoCache> caches = new LinkedHashMap<>();
|
||||
private final Set<Path> failed = new HashSet<>();
|
||||
private boolean needRefreshCache = false;
|
||||
|
||||
Searcher(Path cacheFile) {
|
||||
this.cacheFile = cacheFile;
|
||||
}
|
||||
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to resolve path " + executable, e);
|
||||
return;
|
||||
private static final Pattern CACHE_VERSION_PATTERN = Pattern.compile("(?<major>\\d+)(?:\\.(?<minor>\\d+))?");
|
||||
private static final int CACHE_MAJOR_VERSION = 0;
|
||||
private static final int CACHE_MINOR_VERSION = 0;
|
||||
|
||||
private record JavaInfoCache(String key, JavaInfo info) {
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
void loadCache() {
|
||||
if (Files.notExists(cacheFile))
|
||||
return;
|
||||
|
||||
JavaInfo info = null;
|
||||
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
if (Files.exists(releaseFile)) {
|
||||
try {
|
||||
info = JavaInfo.fromReleaseFile(releaseFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read release file " + releaseFile, e);
|
||||
JsonObject jsonFile = JsonUtils.fromJsonFile(cacheFile, JsonObject.class);
|
||||
JsonElement fileVersion = jsonFile.get("version");
|
||||
|
||||
Matcher matcher;
|
||||
if (fileVersion instanceof JsonPrimitive version
|
||||
&& (matcher = CACHE_VERSION_PATTERN.matcher(version.getAsString())).matches()) {
|
||||
int major = Integer.parseInt(matcher.group("major"));
|
||||
|
||||
String minorString = matcher.group("minor");
|
||||
int minor = minorString != null ? Integer.parseInt(minorString) : 0;
|
||||
|
||||
if (major != CACHE_MAJOR_VERSION || minor < CACHE_MINOR_VERSION)
|
||||
throw new IOException("Unsupported cache file, version: %s".formatted(version.getAsString()));
|
||||
} else
|
||||
throw new IOException("Invalid version JSON: " + fileVersion);
|
||||
|
||||
JsonArray cachesArray = jsonFile.getAsJsonArray("caches");
|
||||
|
||||
for (JsonElement element : cachesArray) {
|
||||
try {
|
||||
var obj = (JsonObject) element;
|
||||
|
||||
Path realPath = Path.of(obj.getAsJsonPrimitive("path").getAsString()).toRealPath();
|
||||
String key = obj.getAsJsonPrimitive("key").getAsString();
|
||||
|
||||
OperatingSystem osName = OperatingSystem.parseOSName(obj.getAsJsonPrimitive("os.name").getAsString());
|
||||
Architecture osArch = Architecture.parseArchName(obj.getAsJsonPrimitive("os.arch").getAsString());
|
||||
String javaVersion = obj.getAsJsonPrimitive("java.version").getAsString();
|
||||
|
||||
JavaInfo.Builder infoBuilder = JavaInfo.newBuilder(Platform.getPlatform(osName, osArch), javaVersion);
|
||||
|
||||
if (obj.get("java.vendor") instanceof JsonPrimitive vendor)
|
||||
infoBuilder.setVendor(vendor.getAsString());
|
||||
|
||||
caches.put(realPath, new JavaInfoCache(key, infoBuilder.build()));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Invalid cache: " + element);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load cache file: " + cacheFile);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (info == null) {
|
||||
void saveCache() {
|
||||
if (!needRefreshCache)
|
||||
return;
|
||||
|
||||
needRefreshCache = false;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
FileUtils.saveSafely(cacheFile, output -> {
|
||||
try (var writer = new JsonWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) {
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("version").value("%d.%d".formatted(CACHE_MAJOR_VERSION, CACHE_MINOR_VERSION));
|
||||
|
||||
writer.name("caches");
|
||||
writer.beginArray();
|
||||
for (Map.Entry<Path, JavaInfoCache> entry : caches.entrySet()) {
|
||||
Path path = entry.getKey();
|
||||
JavaInfoCache cache = entry.getValue();
|
||||
JavaInfo info = cache.info();
|
||||
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("path").value(path.toString());
|
||||
writer.name("key").value(cache.key());
|
||||
|
||||
writer.name("os.name").value(info.getPlatform().os().getCheckedName());
|
||||
writer.name("os.arch").value(info.getPlatform().arch().getCheckedName());
|
||||
writer.name("java.version").value(info.getVersion());
|
||||
if (info.getVendor() != null)
|
||||
writer.name("java.vendor").value(info.getVendor());
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
writer.endArray();
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to save cache file: " + cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String createCacheKey(Path realPath) {
|
||||
Path binDir = realPath.getParent();
|
||||
if (binDir == null || !FileUtils.getName(binDir).equals("bin"))
|
||||
return null;
|
||||
|
||||
if (Files.isRegularFile(realPath.resolveSibling("ikvm.properties")))
|
||||
return null;
|
||||
|
||||
Path javaHome = binDir.getParent();
|
||||
if (javaHome == null)
|
||||
return null;
|
||||
|
||||
Path libDir = javaHome.resolve("lib");
|
||||
if (!Files.isDirectory(libDir))
|
||||
return null;
|
||||
|
||||
BasicFileAttributes launcherAttributes;
|
||||
String releaseHash = null;
|
||||
BasicFileAttributes coreLibsAttributes = null;
|
||||
|
||||
try {
|
||||
launcherAttributes = Files.readAttributes(realPath, BasicFileAttributes.class);
|
||||
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
if (Files.exists(releaseFile)) {
|
||||
releaseHash = DigestUtils.digestToString("SHA-1", releaseFile);
|
||||
} else {
|
||||
Path coreLibsFile = libDir.resolve("rt.jar");
|
||||
if (!Files.isRegularFile(coreLibsFile)) {
|
||||
coreLibsFile = javaHome.resolve("jre/lib/rt.jar");
|
||||
if (!Files.isRegularFile(coreLibsFile))
|
||||
return null;
|
||||
|
||||
coreLibsAttributes = Files.readAttributes(coreLibsFile, BasicFileAttributes.class);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to create cache key for " + realPath, e);
|
||||
return null;
|
||||
}
|
||||
|
||||
StringJoiner joiner = new StringJoiner(",");
|
||||
|
||||
joiner.add("sz:" + launcherAttributes.size());
|
||||
joiner.add("lm:" + launcherAttributes.lastModifiedTime().toMillis());
|
||||
|
||||
if (releaseHash != null)
|
||||
joiner.add(releaseHash);
|
||||
|
||||
if (coreLibsAttributes != null) {
|
||||
joiner.add("rsz:" + coreLibsAttributes.size());
|
||||
joiner.add("rlm:" + coreLibsAttributes.lastModifiedTime().toMillis());
|
||||
}
|
||||
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
void addResult(Path realPath, JavaRuntime javaRuntime) {
|
||||
javaRuntimes.put(realPath, javaRuntime);
|
||||
}
|
||||
|
||||
void tryAddJavaHome(Path javaHome) {
|
||||
tryAddJavaExecutable(getExecutable(javaHome));
|
||||
}
|
||||
|
||||
void tryAddJavaExecutable(Path executable) {
|
||||
tryAddJavaExecutable(executable, false);
|
||||
}
|
||||
|
||||
void tryAddJavaExecutable(Path executable, boolean isManaged) {
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable)
|
||||
|| failed.contains(executable)
|
||||
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = createCacheKey(executable);
|
||||
if (cacheKey != null) {
|
||||
JavaInfoCache cache = caches.get(executable);
|
||||
if (cache != null) {
|
||||
if (isCompatible(cache.info().getPlatform()) && cacheKey.equals(cache.key())) {
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, cache.info(), isManaged));
|
||||
return;
|
||||
} else {
|
||||
caches.remove(executable);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
} else if (caches.remove(executable) != null) {
|
||||
needRefreshCache = true;
|
||||
}
|
||||
|
||||
JavaInfo info;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e);
|
||||
failed.add(executable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cacheKey != null) {
|
||||
caches.put(executable, new JavaInfoCache(cacheKey, info));
|
||||
needRefreshCache = true;
|
||||
}
|
||||
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, isManaged));
|
||||
}
|
||||
|
||||
void tryAddJavaInComponentDir(String platform, Path component, boolean verify) {
|
||||
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
|
||||
if (!Files.isRegularFile(sha1File))
|
||||
return;
|
||||
|
||||
Path dir = component.resolve(platform).resolve(component.getFileName());
|
||||
|
||||
if (verify) {
|
||||
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
int idx = line.indexOf(" /#//");
|
||||
if (idx <= 0)
|
||||
throw new IOException("Illegal line: " + line);
|
||||
|
||||
Path file = dir.resolve(line.substring(0, idx));
|
||||
|
||||
// Should we check the sha1 of files? This will take a lot of time.
|
||||
if (Files.notExists(file))
|
||||
throw new NoSuchFileException(file.toAbsolutePath().toString());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to verify Java in " + component, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Path macPath = dir.resolve("jre.bundle/Contents/Home");
|
||||
if (Files.exists(macPath)) {
|
||||
tryAddJavaHome(macPath);
|
||||
return;
|
||||
} else
|
||||
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
|
||||
}
|
||||
|
||||
tryAddJavaHome(dir);
|
||||
}
|
||||
|
||||
void searchAllJavaInRepository(Platform platform) {
|
||||
for (Path java : REPOSITORY.getAllJava(platform)) {
|
||||
tryAddJavaExecutable(java, true);
|
||||
}
|
||||
|
||||
for (Path java : LOCAL_REPOSITORY.getAllJava(platform)) {
|
||||
tryAddJavaExecutable(java, true);
|
||||
}
|
||||
|
||||
if (platform.os() == OperatingSystem.MACOS) {
|
||||
// In the past, we used 'osx' as the checked name for macOS
|
||||
Path platformRoot = REPOSITORY.getPlatformRoot(platform).resolveSibling("osx-" + platform.getArchitecture().getCheckedName());
|
||||
searchAllJavaInDirectory(platformRoot);
|
||||
}
|
||||
}
|
||||
|
||||
if (info != null && isCompatible(info.getPlatform()))
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
|
||||
}
|
||||
void searchAllOfficialJava(Path directory, boolean verify) {
|
||||
if (!Files.isDirectory(directory))
|
||||
return;
|
||||
// Examples:
|
||||
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
|
||||
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
|
||||
|
||||
private static void tryAddJavaExecutable(Map<Path, JavaRuntime> javaRuntimes, Path executable) {
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
|
||||
if (javaPlatform != null) {
|
||||
searchAllOfficialJava(directory, javaPlatform, verify);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
}
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
JavaInfo info = null;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, true);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e);
|
||||
}
|
||||
|
||||
if (info != null && isCompatible(info.getPlatform())) {
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void tryAddJavaInComponentDir(Map<Path, JavaRuntime> javaRuntimes, String platform, Path component, boolean verify) {
|
||||
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
|
||||
if (!Files.isRegularFile(sha1File))
|
||||
return;
|
||||
|
||||
Path dir = component.resolve(platform).resolve(component.getFileName());
|
||||
|
||||
if (verify) {
|
||||
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
int idx = line.indexOf(" /#//");
|
||||
if (idx <= 0)
|
||||
throw new IOException("Illegal line: " + line);
|
||||
|
||||
Path file = dir.resolve(line.substring(0, idx));
|
||||
|
||||
// Should we check the sha1 of files? This will take a lot of time.
|
||||
if (Files.notExists(file))
|
||||
throw new NoSuchFileException(file.toAbsolutePath().toString());
|
||||
void searchAllOfficialJava(Path directory, String platform, boolean verify) {
|
||||
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
|
||||
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
|
||||
for (Path component : dir) {
|
||||
tryAddJavaInComponentDir(platform, component, verify);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to verify Java in " + component, e);
|
||||
return;
|
||||
LOG.warning("Failed to list java-runtime directory " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Path macPath = dir.resolve("jre.bundle/Contents/Home");
|
||||
if (Files.exists(macPath)) {
|
||||
tryAddJavaHome(javaRuntimes, macPath);
|
||||
void searchAllJavaInDirectory(Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
} else
|
||||
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
|
||||
}
|
||||
}
|
||||
|
||||
tryAddJavaHome(javaRuntimes, dir);
|
||||
}
|
||||
|
||||
private static void searchAllJavaInRepository(Map<Path, JavaRuntime> javaRuntimes, Platform platform) {
|
||||
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) {
|
||||
if (!Files.isDirectory(directory))
|
||||
return;
|
||||
// Examples:
|
||||
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
|
||||
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
|
||||
|
||||
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
|
||||
if (javaPlatform != null) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(subDir);
|
||||
}
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, String platform, boolean verify) {
|
||||
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
|
||||
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
|
||||
for (Path component : dir) {
|
||||
tryAddJavaInComponentDir(javaRuntimes, platform, component, verify);
|
||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(programFiles);
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to list java-runtime directory " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllJavaInDirectory(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(javaRuntimes, subDir);
|
||||
for (String vendor : KNOWN_VENDOR_DIRECTORIES) {
|
||||
searchAllJavaInDirectory(path.resolve(vendor));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchJavaInProgramFiles(Map<Path, JavaRuntime> javaRuntimes, String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Paths.get(programFiles);
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) {
|
||||
searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor));
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchJavaInMacJavaVirtualMachines(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home"));
|
||||
void searchJavaInMacJavaVirtualMachines(Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(subDir.resolve("Contents/Home"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Windows Registry Support ====
|
||||
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, WinReg.HKEY hkey, String location) {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg == null)
|
||||
return;
|
||||
// ==== Windows Registry Support ====
|
||||
void queryJavaInRegistryKey(WinReg.HKEY hkey, String location) {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg == null)
|
||||
return;
|
||||
|
||||
for (String java : reg.querySubKeys(hkey, location)) {
|
||||
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
|
||||
continue;
|
||||
Object home = reg.queryValue(hkey, java, "JavaHome");
|
||||
if (home instanceof String) {
|
||||
try {
|
||||
tryAddJavaHome(javaRuntimes, Paths.get((String) home));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path in system registry: " + home);
|
||||
for (String java : reg.querySubKeys(hkey, location)) {
|
||||
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
|
||||
continue;
|
||||
if (reg.queryValue(hkey, java, "JavaHome") instanceof String home) {
|
||||
try {
|
||||
tryAddJavaHome(Path.of(home));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path in system registry: " + home);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,33 +34,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
* @author Glavo
|
||||
*/
|
||||
@JsonAdapter(JavaManifest.Serializer.class)
|
||||
public final class JavaManifest {
|
||||
|
||||
private final JavaInfo info;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, Object> update;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, JavaLocalFiles.Local> files;
|
||||
|
||||
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
this.info = info;
|
||||
this.update = update;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public JavaInfo getInfo() {
|
||||
return info;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUpdate() {
|
||||
return update;
|
||||
}
|
||||
|
||||
public Map<String, JavaLocalFiles.Local> getFiles() {
|
||||
return files;
|
||||
}
|
||||
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
|
||||
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
||||
|
||||
@@ -69,16 +43,16 @@ public final class JavaManifest {
|
||||
@Override
|
||||
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.getInfo().getVersion());
|
||||
res.addProperty("java.vendor", src.getInfo().getVendor());
|
||||
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.info().getVersion());
|
||||
res.addProperty("java.vendor", src.info().getVendor());
|
||||
|
||||
if (src.getUpdate() != null)
|
||||
res.add("update", context.serialize(src.getUpdate()));
|
||||
if (src.update() != null)
|
||||
res.add("update", context.serialize(src.update()));
|
||||
|
||||
if (src.getFiles() != null)
|
||||
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
|
||||
if (src.files() != null)
|
||||
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores account metadata records in a detached JSON file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory and stores account metadata in the `accounts` list.
|
||||
/// Private account data is persisted separately in [AccountPrivateData].
|
||||
@JsonAdapter(AccountMetadataStore.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountMetadataStore extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account metadata store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("accounts", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty account metadata store.
|
||||
AccountMetadataStore() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(accounts);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates an account metadata store from already serialized account records.
|
||||
///
|
||||
/// @param records the serialized account records
|
||||
/// @return an account metadata store containing the given records
|
||||
static AccountMetadataStore fromRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.getAccounts().setAll(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Creates a copy of this account metadata store with replacement account records.
|
||||
///
|
||||
/// @param records the replacement account records
|
||||
/// @return a copy preserving this store's schema and unknown top-level members
|
||||
AccountMetadataStore copyWithRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.setSchema(getSchema());
|
||||
result.getAccounts().setAll(records);
|
||||
result.unknownFields.putAll(unknownFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The schema used by this account metadata store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account metadata store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account metadata store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this account metadata store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Serialized account records.
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<JsonObject> accounts = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns serialized account records.
|
||||
public ObservableList<JsonObject> getAccounts() {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountMetadataStore].
|
||||
static final class Adapter extends ObservableSetting.Adapter<AccountMetadataStore> {
|
||||
/// Creates an empty account metadata store for deserialization.
|
||||
@Override
|
||||
protected AccountMetadataStore createInstance() {
|
||||
return new AccountMetadataStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonSerializationContext;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores private account data in a protected payload.
|
||||
@JsonAdapter(AccountPrivateData.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountPrivateData extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account private data store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("account-private-data", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// JVM system property selecting the account private data protection mode.
|
||||
static final String PROTECTION_PROPERTY = "hmcl.account.privateData.protection";
|
||||
|
||||
/// Creates an empty account private data store.
|
||||
AccountPrivateData() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(privateData);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this account private data store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Private account data keyed by account ID.
|
||||
private final ObservableMap<AccountID, JsonObject> privateData =
|
||||
FXCollections.observableMap(new LinkedHashMap<>());
|
||||
|
||||
/// Whether this account private data store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account private data store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account private data store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Returns private account data keyed by account ID.
|
||||
ObservableMap<AccountID, JsonObject> getPrivateData() {
|
||||
return privateData;
|
||||
}
|
||||
|
||||
/// Returns the protection mode selected for writing account private data.
|
||||
private static ProtectedPayload.ProtectionMode protectionMode() {
|
||||
return ProtectedPayload.ProtectionMode.fromConfiguredId(System.getProperty(PROTECTION_PROPERTY));
|
||||
}
|
||||
|
||||
/// Returns whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Finds the first private data entry matching an account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param privateDataStores private data stores searched in order
|
||||
/// @return the first matching private data object, or `null` if no store contains one
|
||||
static @Nullable JsonObject findPrivateData(
|
||||
AccountID accountID,
|
||||
List<AccountPrivateData> privateDataStores) {
|
||||
for (AccountPrivateData privateDataStore : privateDataStores) {
|
||||
@Nullable JsonObject accountPrivateData = privateDataStore.privateData.get(accountID);
|
||||
if (accountPrivateData != null) {
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns whether this store contains private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether this store contains private data for the account ID
|
||||
boolean containsPrivateData(AccountID accountID) {
|
||||
return privateData.containsKey(accountID);
|
||||
}
|
||||
|
||||
/// Removes private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether private data was removed
|
||||
boolean removePrivateData(AccountID accountID) {
|
||||
return privateData.remove(accountID) != null;
|
||||
}
|
||||
|
||||
/// Stores private data for the account ID, or removes existing private data when the object is empty.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param accountPrivateData the private account data
|
||||
void putPrivateData(AccountID accountID, JsonObject accountPrivateData) {
|
||||
if (accountPrivateData.isEmpty()) {
|
||||
privateData.remove(accountID);
|
||||
} else {
|
||||
privateData.put(accountID, accountPrivateData);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces this private data store with another store.
|
||||
///
|
||||
/// @param other the private data store to copy from
|
||||
void replaceWith(AccountPrivateData other) {
|
||||
privateData.clear();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : other.privateData.entrySet()) {
|
||||
putPrivateData(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountPrivateData].
|
||||
static final class Adapter implements JsonSerializer<AccountPrivateData>,
|
||||
com.google.gson.JsonDeserializer<AccountPrivateData> {
|
||||
/// Creates the JSON payload protected inside an account private data file.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to serialize
|
||||
/// @param context the JSON serialization context
|
||||
/// @return the plain payload before protection
|
||||
private static JsonArray createPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonSerializationContext context) {
|
||||
JsonArray payload = new JsonArray();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : accountPrivateData.privateData.entrySet()) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.add("accountID", context.serialize(entry.getKey(), AccountID.class));
|
||||
item.add("privateData", entry.getValue());
|
||||
payload.add(item);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Reads an account ID from a private data entry.
|
||||
private static @Nullable AccountID readAccountID(
|
||||
@Nullable JsonElement accountIDElement,
|
||||
JsonDeserializationContext context) {
|
||||
if (accountIDElement == null || accountIDElement.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return context.deserialize(accountIDElement, AccountID.class);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the protected payload into the given private data store.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to update
|
||||
/// @param payload the revealed payload
|
||||
/// @param context the JSON deserialization context
|
||||
private static void readPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonArray payload,
|
||||
JsonDeserializationContext context) {
|
||||
for (JsonElement itemElement : payload) {
|
||||
if (!(itemElement instanceof JsonObject item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable AccountID accountID = readAccountID(item.get("accountID"), context);
|
||||
if (accountID == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement privateDataElement = item.get("privateData");
|
||||
if (privateDataElement == null || !privateDataElement.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accountPrivateData.putPrivateData(accountID, privateDataElement.getAsJsonObject());
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the private data store as a protected payload envelope.
|
||||
@Override
|
||||
public JsonElement serialize(
|
||||
AccountPrivateData accountPrivateData,
|
||||
Type typeOfSrc,
|
||||
JsonSerializationContext context) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty(JsonSchema.PROPERTY_SCHEMA, accountPrivateData.getSchema().url());
|
||||
protectionMode().writePayload(result, createPayload(accountPrivateData, context));
|
||||
accountPrivateData.unknownFields.forEach(result::add);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Deserializes the private data store from a protected payload envelope.
|
||||
@Override
|
||||
public @Nullable AccountPrivateData deserialize(
|
||||
@Nullable JsonElement json,
|
||||
Type typeOfT,
|
||||
JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json == null || json.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
if (!(json instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Account private data is not an object");
|
||||
}
|
||||
|
||||
AccountPrivateData accountPrivateData = new AccountPrivateData();
|
||||
Map<String, JsonElement> values = new LinkedHashMap<>(object.asMap());
|
||||
JsonElement schema = values.remove(JsonSchema.PROPERTY_SCHEMA);
|
||||
if (schema != null && schema.isJsonPrimitive() && schema.getAsJsonPrimitive().isString()) {
|
||||
accountPrivateData.setSchema(new JsonSchema(schema.getAsString()));
|
||||
}
|
||||
values.remove(ProtectedPayload.PROPERTY_PROTECTION);
|
||||
values.remove(ProtectedPayload.PROPERTY_NONCE);
|
||||
values.remove(ProtectedPayload.PROPERTY_PAYLOAD);
|
||||
accountPrivateData.unknownFields.putAll(values);
|
||||
|
||||
readPayload(accountPrivateData, ProtectedPayload.read(object, JsonArray.class), context);
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -35,28 +35,22 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
|
||||
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.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -74,7 +68,7 @@ public final class Accounts {
|
||||
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
|
||||
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
|
||||
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
|
||||
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||
|
||||
// ==== login type / account factory mapping ====
|
||||
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
||||
@@ -120,9 +114,6 @@ public final class Accounts {
|
||||
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
||||
}
|
||||
|
||||
private static final String GLOBAL_PREFIX = "$GLOBAL:";
|
||||
private static final ObservableList<Map<Object, Object>> globalAccountStorages = FXCollections.observableArrayList();
|
||||
|
||||
private static final ObservableList<Account> accounts = observableArrayList(account -> new Observable[]{account});
|
||||
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
|
||||
|
||||
@@ -131,120 +122,219 @@ public final class Accounts {
|
||||
*/
|
||||
private static boolean initialized = false;
|
||||
|
||||
private static Map<Object, Object> getAccountStorage(Account account) {
|
||||
Map<Object, Object> storage = account.toStorage();
|
||||
storage.put("type", getLoginType(getAccountFactory(account)));
|
||||
return storage;
|
||||
private static SerializedAccount serializeAccount(Account account) {
|
||||
JsonObject metadata = new JsonObject();
|
||||
metadata.addProperty("type", getLoginType(getAccountFactory(account)));
|
||||
account.writeMetadata(metadata);
|
||||
JsonObject privateData = new JsonObject();
|
||||
account.writePrivateData(privateData);
|
||||
return new SerializedAccount(metadata, privateData);
|
||||
}
|
||||
|
||||
private static void updateAccountStorages() {
|
||||
// don't update the underlying storage before data loading is completed
|
||||
/// Ensures account IDs are unique across local and shared account metadata records before accounts are instantiated.
|
||||
private static AccountIDNormalization ensureUniqueAccountIDs() {
|
||||
Set<String> usedAccountIDs = new HashSet<>();
|
||||
boolean localChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.gameAccounts(),
|
||||
usedAccountIDs,
|
||||
false);
|
||||
boolean sharedChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.userGameAccounts(),
|
||||
usedAccountIDs,
|
||||
true);
|
||||
return new AccountIDNormalization(localChanged, sharedChanged);
|
||||
}
|
||||
|
||||
/// Result of normalizing account IDs across loaded metadata stores.
|
||||
///
|
||||
/// @param localChanged whether the per-workspace account metadata changed
|
||||
/// @param sharedChanged whether the shared account metadata changed
|
||||
private record AccountIDNormalization(boolean localChanged, boolean sharedChanged) {
|
||||
}
|
||||
|
||||
/// Returns account IDs from metadata records.
|
||||
///
|
||||
/// @param first the first metadata record list
|
||||
/// @param second the second metadata record list
|
||||
/// @return account IDs found in the metadata records
|
||||
private static List<AccountID> getAccountIDs(List<JsonObject> first, List<JsonObject> second) {
|
||||
ArrayList<AccountID> accountIDs = new ArrayList<>(first.size() + second.size());
|
||||
addAccountIDs(accountIDs, first);
|
||||
addAccountIDs(accountIDs, second);
|
||||
return accountIDs;
|
||||
}
|
||||
|
||||
/// Adds account IDs from metadata records to a target list.
|
||||
///
|
||||
/// @param accountIDs the target account ID list
|
||||
/// @param metadataRecords the metadata records to read
|
||||
private static void addAccountIDs(List<AccountID> accountIDs, List<JsonObject> metadataRecords) {
|
||||
for (JsonObject metadata : metadataRecords) {
|
||||
@Nullable AccountID accountID = Account.getAccountID(metadata);
|
||||
if (accountID != null && !accountIDs.contains(accountID)) {
|
||||
accountIDs.add(accountID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateAccountMetadataRecords() {
|
||||
// don't update the underlying account records before data loading is completed
|
||||
// otherwise it might cause data loss
|
||||
if (!initialized)
|
||||
return;
|
||||
// update storage
|
||||
|
||||
ArrayList<Map<Object, Object>> global = new ArrayList<>();
|
||||
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
|
||||
ArrayList<JsonObject> globalMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> globalPrivateData = new LinkedHashMap<>();
|
||||
ArrayList<JsonObject> portableMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> portablePrivateData = new LinkedHashMap<>();
|
||||
|
||||
for (Account account : accounts) {
|
||||
Map<Object, Object> storage = getAccountStorage(account);
|
||||
if (account.isPortable())
|
||||
portable.add(storage);
|
||||
else
|
||||
global.add(storage);
|
||||
}
|
||||
|
||||
if (!global.equals(globalAccountStorages))
|
||||
globalAccountStorages.setAll(global);
|
||||
if (!portable.equals(config().getAccountStorages()))
|
||||
config().getAccountStorages().setAll(portable);
|
||||
}
|
||||
|
||||
private static void loadGlobalAccountStorages() {
|
||||
Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json");
|
||||
if (Files.exists(globalAccountsFile)) {
|
||||
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
|
||||
globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load global accounts", e);
|
||||
SerializedAccount serialized = serializeAccount(account);
|
||||
if (account.isPortable()) {
|
||||
portableMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
portablePrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
} else {
|
||||
globalMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
globalPrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalAccountStorages.addListener(onInvalidating(() ->
|
||||
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
|
||||
List<AccountID> retainedAccountIDs = getAccountIDs(globalMetadata, portableMetadata);
|
||||
if (!SettingsManager.isUserGameAccountsReadOnly())
|
||||
SettingsManager.updateUserGameAccounts(globalMetadata, globalPrivateData, retainedAccountIDs);
|
||||
if (!SettingsManager.isGameAccountsReadOnly())
|
||||
SettingsManager.updateGameAccounts(portableMetadata, portablePrivateData, retainedAccountIDs);
|
||||
}
|
||||
|
||||
private static Account parseAccount(Map<Object, Object> storage) {
|
||||
AccountFactory<?> factory = type2factory.get(storage.get("type"));
|
||||
/// Returns whether the account metadata and credential files selected by the portability flag are read-only.
|
||||
///
|
||||
/// @param portable whether the account is stored in the local account file
|
||||
public static boolean isAccountFilesReadOnly(boolean portable) {
|
||||
return portable ? SettingsManager.isGameAccountsReadOnly() : SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Returns whether the files containing the given account are read-only.
|
||||
public static boolean isAccountFilesReadOnly(Account account) {
|
||||
return isAccountFilesReadOnly(account.isPortable());
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be removed from its current account files.
|
||||
public static boolean canRemoveAccount(Account account) {
|
||||
return !isAccountFilesReadOnly(account);
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be moved between local and user account files.
|
||||
public static boolean canMoveAccount(Account account) {
|
||||
return !SettingsManager.isGameAccountsReadOnly() && !SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account metadata and credential files selected by the portability flag.
|
||||
///
|
||||
/// @param portable whether the target account files are local
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(boolean portable) throws IOException {
|
||||
if (portable) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account files containing the given account.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(Account account) throws IOException {
|
||||
forceOverwriteAccountFiles(account.isPortable());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites both local and user account metadata and credential files.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles() throws IOException {
|
||||
if (SettingsManager.isGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
}
|
||||
if (SettingsManager.isUserGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
private static Account parseAccount(JsonObject record, boolean portable) {
|
||||
AccountFactory<?> factory = type2factory.get(JsonUtils.getString(record, "type"));
|
||||
if (factory == null) {
|
||||
LOG.warning("Unrecognized account type: " + storage);
|
||||
LOG.warning("Unrecognized account type: " + describeAccountRecord(record));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return factory.fromStorage(storage);
|
||||
AccountID accountID = Account.readAccountID(record);
|
||||
return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load account: " + storage, e);
|
||||
LOG.warning("Failed to load account: " + describeAccountRecord(record), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when it's ready to load accounts from {@link ConfigHolder#config()}.
|
||||
*/
|
||||
static void init() {
|
||||
/// Serialized account metadata and private data.
|
||||
///
|
||||
/// @param metadata public metadata stored in `accounts.json`
|
||||
/// @param privateData private account data stored in account private data
|
||||
private record SerializedAccount(JsonObject metadata, JsonObject privateData) {
|
||||
}
|
||||
|
||||
/// Returns a safe account record description for diagnostics.
|
||||
private static String describeAccountRecord(JsonObject record) {
|
||||
AccountID accountID = Account.getAccountID(record);
|
||||
if (accountID != null) {
|
||||
return accountID.toString();
|
||||
}
|
||||
|
||||
String type = JsonUtils.getString(record, "type");
|
||||
return type != null ? "{type=" + type + "}" : "<unknown>";
|
||||
}
|
||||
|
||||
/// Called when it's ready to load accounts from [SettingsManager#settings()].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
if (!config().isAddedLittleSkin()) {
|
||||
AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/");
|
||||
|
||||
if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) {
|
||||
config().getAuthlibInjectorServers().add(0, littleSkin);
|
||||
}
|
||||
|
||||
config().setAddedLittleSkin(true);
|
||||
AccountIDNormalization accountIDNormalization = ensureUniqueAccountIDs();
|
||||
if (accountIDNormalization.localChanged()) {
|
||||
SettingsManager.saveGameAccountMetadataRecords();
|
||||
}
|
||||
if (accountIDNormalization.sharedChanged()) {
|
||||
SettingsManager.saveUserGameAccountMetadataRecords();
|
||||
}
|
||||
|
||||
loadGlobalAccountStorages();
|
||||
|
||||
// load accounts
|
||||
Account selected = null;
|
||||
for (Map<Object, Object> storage : config().getAccountStorages()) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, true);
|
||||
if (account != null) {
|
||||
account.setPortable(true);
|
||||
accounts.add(account);
|
||||
if (Boolean.TRUE.equals(storage.get("selected"))) {
|
||||
if (JsonUtils.getBoolean(record, "selected", false)) {
|
||||
selected = account;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Map<Object, Object> storage : globalAccountStorages) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getUserAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, false);
|
||||
if (account != null) {
|
||||
accounts.add(account);
|
||||
}
|
||||
}
|
||||
|
||||
String selectedAccountIdentifier = config().getSelectedAccount();
|
||||
if (selected == null && selectedAccountIdentifier != null) {
|
||||
boolean portable = true;
|
||||
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
|
||||
portable = false;
|
||||
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
|
||||
}
|
||||
|
||||
AccountID selectedAccountID = settings().selectedAccountProperty().get();
|
||||
if (selected == null && selectedAccountID != null) {
|
||||
for (Account account : accounts) {
|
||||
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
|
||||
if (portable == account.isPortable()) {
|
||||
selected = account;
|
||||
break;
|
||||
} else if (selected == null) {
|
||||
selected = account;
|
||||
}
|
||||
if (account.getAccountID().equals(selectedAccountID)) {
|
||||
selected = account;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,15 +343,18 @@ public final class Accounts {
|
||||
selected = accounts.get(0);
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
for (Account account : accounts) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
accounts.addListener(new ListChangeListener<Account>() {
|
||||
@Override
|
||||
public void onChanged(Change<? extends Account> change) {
|
||||
@@ -269,7 +362,8 @@ public final class Accounts {
|
||||
for (Account account : change.getAddedSubList()) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
accounts.removeListener(this);
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,7 +375,7 @@ public final class Accounts {
|
||||
|
||||
InvalidationListener listener = o -> {
|
||||
// this method first checks whether the current selection is valid
|
||||
// if it's valid, the underlying storage will be updated
|
||||
// if it's valid, the underlying account records will be updated
|
||||
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
|
||||
Account account = selectedAccount.get();
|
||||
if (accounts.isEmpty()) {
|
||||
@@ -304,16 +398,16 @@ public final class Accounts {
|
||||
selectedAccount.addListener(onInvalidating(() -> {
|
||||
Account account = selectedAccount.get();
|
||||
if (account != null)
|
||||
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
|
||||
settings().selectedAccountProperty().set(account.getAccountID());
|
||||
else
|
||||
config().setSelectedAccount(null);
|
||||
settings().selectedAccountProperty().set(null);
|
||||
}));
|
||||
accounts.addListener(listener);
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountMetadataRecords));
|
||||
|
||||
initialized = true;
|
||||
|
||||
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
|
||||
if (selected != null) {
|
||||
Account finalSelected = selected;
|
||||
@@ -326,7 +420,7 @@ public final class Accounts {
|
||||
});
|
||||
}
|
||||
|
||||
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
|
||||
for (AuthlibInjectorServer server : getAuthlibInjectorServers()) {
|
||||
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
||||
continue;
|
||||
Schedulers.io().execute(() -> {
|
||||
@@ -373,12 +467,12 @@ public final class Accounts {
|
||||
}
|
||||
|
||||
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
||||
return config().getAuthlibInjectorServers().stream()
|
||||
return getAuthlibInjectorServers().stream()
|
||||
.filter(server -> url.equals(server.getUrl()))
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
return server;
|
||||
});
|
||||
}
|
||||
@@ -391,7 +485,8 @@ public final class Accounts {
|
||||
accounts.stream()
|
||||
.filter(AuthlibInjectorAccount.class::isInstance)
|
||||
.map(AuthlibInjectorAccount.class::cast)
|
||||
.filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(it -> !getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(Accounts::canRemoveAccount)
|
||||
.collect(toList())
|
||||
.forEach(accounts::remove);
|
||||
}
|
||||
@@ -468,7 +563,9 @@ public final class Accounts {
|
||||
}
|
||||
} else if (exception instanceof MicrosoftService.XBox400Exception) {
|
||||
return i18n("account.methods.microsoft.error.wrong_verify_method");
|
||||
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_license");
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_character");
|
||||
} else if (exception instanceof MicrosoftService.NoXuiException) {
|
||||
return i18n("account.methods.microsoft.error.add_family");
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace authlib-injector authentication servers.
|
||||
///
|
||||
/// The JSON representation is saved as `config/authlib-injector-servers.json`
|
||||
/// under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(AuthlibInjectorServerList.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
|
||||
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
|
||||
|
||||
/// The JSON schema supported by this authlib-injector server list.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty authlib-injector server list.
|
||||
public AuthlibInjectorServerList() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates the default authlib-injector server list for a newly created file.
|
||||
public static AuthlibInjectorServerList createDefault() {
|
||||
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
|
||||
result.addLittleSkinIfAbsent();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Adds the bundled LittleSkin server when it is not already present.
|
||||
public void addLittleSkinIfAbsent() {
|
||||
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
|
||||
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema used by this authlib-injector server list file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector authentication servers available for account login.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<AuthlibInjectorServer> servers =
|
||||
FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector authentication servers available for account login.
|
||||
public ObservableList<AuthlibInjectorServer> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerList].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerList> {
|
||||
/// Creates an empty authlib-injector server list for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerList createInstance() {
|
||||
return new AuthlibInjectorServerList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Stores derived authlib-injector server metadata separately from the user-managed server list.
|
||||
///
|
||||
/// The JSON representation is saved as `cache/authlib-injector-server-metadata.json`
|
||||
/// under the current HMCL directory.
|
||||
@JsonAdapter(AuthlibInjectorServerMetadataCache.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AuthlibInjectorServerMetadataCache extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this metadata cache.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-server-metadata", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty metadata cache.
|
||||
AuthlibInjectorServerMetadataCache() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this metadata cache file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this metadata cache file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this metadata cache file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current metadata cache file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector metadata entries.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<Entry> servers = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector metadata entries.
|
||||
ObservableList<Entry> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// Initializes metadata for a server that is being connected to this cache.
|
||||
///
|
||||
/// @param server the server to initialize
|
||||
/// @param storeExistingMetadata whether metadata already present on the server should replace cached metadata
|
||||
void initialize(AuthlibInjectorServer server, boolean storeExistingMetadata) {
|
||||
if (storeExistingMetadata && server.getMetadataResponse().isPresent()) {
|
||||
store(server);
|
||||
} else {
|
||||
restore(server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores cached metadata into the given server when a valid cache entry exists.
|
||||
void restore(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entry entry = servers.get(index);
|
||||
String metadataResponse = entry.getMetadataResponse();
|
||||
if (metadataResponse.isEmpty()) {
|
||||
servers.remove(index);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
server.restoreMetadataCache(metadataResponse, entry.getMetadataTimestamp());
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Ignoring malformed authlib-injector server metadata cache: " + server.getUrl(), e);
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the current metadata response from the given server when it has one.
|
||||
void store(AuthlibInjectorServer server) {
|
||||
server.getMetadataResponse().ifPresent(metadataResponse -> {
|
||||
Entry entry = new Entry(server.getUrl(), metadataResponse, server.getMetadataTimestamp());
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.set(index, entry);
|
||||
} else {
|
||||
servers.add(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Removes cached metadata for the given server.
|
||||
void remove(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the cache entry for the given normalized server URL.
|
||||
private int indexOf(String url) {
|
||||
for (int i = 0; i < servers.size(); i++) {
|
||||
if (url.equals(servers.get(i).getUrl())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Metadata cached for one authlib-injector server.
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
static final class Entry {
|
||||
/// Normalized authlib-injector server URL.
|
||||
@SerializedName("url")
|
||||
private @Nullable String url = "";
|
||||
|
||||
/// Raw metadata response returned by the authlib-injector endpoint.
|
||||
@SerializedName("metadataResponse")
|
||||
private @Nullable String metadataResponse = "";
|
||||
|
||||
/// Wall-clock time when the metadata response was fetched.
|
||||
@SerializedName("metadataTimestamp")
|
||||
private long metadataTimestamp;
|
||||
|
||||
/// Creates an empty metadata entry for deserialization.
|
||||
private Entry() {
|
||||
}
|
||||
|
||||
/// Creates a metadata entry.
|
||||
///
|
||||
/// @param url normalized authlib-injector server URL
|
||||
/// @param metadataResponse raw metadata response returned by the authlib-injector endpoint
|
||||
/// @param metadataTimestamp wall-clock time when the metadata response was fetched
|
||||
Entry(String url, String metadataResponse, long metadataTimestamp) {
|
||||
this.url = Objects.requireNonNull(url);
|
||||
this.metadataResponse = Objects.requireNonNull(metadataResponse);
|
||||
this.metadataTimestamp = metadataTimestamp;
|
||||
}
|
||||
|
||||
/// Returns the normalized authlib-injector server URL.
|
||||
String getUrl() {
|
||||
return Objects.requireNonNullElse(url, "");
|
||||
}
|
||||
|
||||
/// Returns the raw metadata response, or an empty string when the cache entry is incomplete.
|
||||
String getMetadataResponse() {
|
||||
return Objects.requireNonNullElse(metadataResponse, "");
|
||||
}
|
||||
|
||||
/// Returns the wall-clock time when the metadata response was fetched.
|
||||
long getMetadataTimestamp() {
|
||||
return metadataTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerMetadataCache].
|
||||
@NotNullByDefault
|
||||
static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerMetadataCache> {
|
||||
/// Creates an empty metadata cache for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerMetadataCache createInstance() {
|
||||
return new AuthlibInjectorServerMetadataCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonSerializable
|
||||
@@ -71,7 +72,7 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
configLocation = Paths.get(CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
AuthlibInjectorServers configInstance;
|
||||
try {
|
||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||
@@ -81,11 +82,11 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
}
|
||||
|
||||
if (!configInstance.urls.isEmpty()) {
|
||||
config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
for (String url : configInstance.urls) {
|
||||
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
||||
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
servers.add(server);
|
||||
})
|
||||
.start();
|
||||
|
||||
+18
-27
@@ -15,37 +15,28 @@
|
||||
* 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.gson;
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/**
|
||||
* To resolve JsonParseException: duplicate key: null
|
||||
* By skipping inserting data with key null
|
||||
* @param <K>
|
||||
* @param <V>
|
||||
*/
|
||||
public final class JsonMap<K, V> extends HashMap<K, V> {
|
||||
public JsonMap(int initialCapacity, float loadFactor) {
|
||||
super(initialCapacity, loadFactor);
|
||||
}
|
||||
/// Selects the source used to render the launcher background.
|
||||
@NotNullByDefault
|
||||
public enum BackgroundType {
|
||||
/// Uses the selected theme background when available, otherwise a built-in fallback wallpaper.
|
||||
DEFAULT,
|
||||
|
||||
public JsonMap(int initialCapacity) {
|
||||
super(initialCapacity);
|
||||
}
|
||||
/// Uses one built-in wallpaper selected by launcher settings.
|
||||
BUILTIN,
|
||||
|
||||
public JsonMap() {
|
||||
super();
|
||||
}
|
||||
/// Uses a user-selected local image file or a random image from a selected directory.
|
||||
CUSTOM,
|
||||
|
||||
public JsonMap(Map<? extends K, ? extends V> m) {
|
||||
super(m);
|
||||
}
|
||||
/// Uses an image loaded from a user-provided URL.
|
||||
NETWORK,
|
||||
|
||||
@Override
|
||||
public V put(K key, V value) {
|
||||
if (key == null) return null;
|
||||
return super.put(key, value);
|
||||
}
|
||||
/// Uses the configured paint value as a flat background.
|
||||
PAINT,
|
||||
|
||||
/// Uses the current theme color scheme surface container as a flat background.
|
||||
THEME_COLOR;
|
||||
}
|
||||
@@ -1,756 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
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;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(value = Config.Adapter.class)
|
||||
public final class Config extends ObservableSetting {
|
||||
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
public static final int CURRENT_UI_VERSION = 0;
|
||||
|
||||
public static final Gson CONFIG_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.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();
|
||||
|
||||
@Nullable
|
||||
public static Config fromJson(String json) throws JsonParseException {
|
||||
return CONFIG_GSON.fromJson(json, Config.class);
|
||||
}
|
||||
|
||||
public Config() {
|
||||
tracker.markDirty(configVersion);
|
||||
tracker.markDirty(uiVersion);
|
||||
register();
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
@SerializedName("_version")
|
||||
private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION);
|
||||
|
||||
public IntegerProperty configVersionProperty() {
|
||||
return configVersion;
|
||||
}
|
||||
|
||||
public int getConfigVersion() {
|
||||
return configVersion.get();
|
||||
}
|
||||
|
||||
public void setConfigVersion(int configVersion) {
|
||||
this.configVersion.set(configVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of UI that the user have last used.
|
||||
* If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased.
|
||||
* When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown,
|
||||
* then this property is set to the same value as {@link #CURRENT_UI_VERSION}.
|
||||
* In particular, the property is default to 0, so that whoever open the application for the first time will see the guide.
|
||||
*/
|
||||
@SerializedName("uiVersion")
|
||||
private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION);
|
||||
|
||||
public IntegerProperty uiVersionProperty() {
|
||||
return uiVersion;
|
||||
}
|
||||
|
||||
public int getUiVersion() {
|
||||
return uiVersion.get();
|
||||
}
|
||||
|
||||
public void setUiVersion(int uiVersion) {
|
||||
this.uiVersion.set(uiVersion);
|
||||
}
|
||||
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
@SerializedName("localization")
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
public ObjectProperty<SupportedLocale> localizationProperty() {
|
||||
return localization;
|
||||
}
|
||||
|
||||
public SupportedLocale getLocalization() {
|
||||
return localization.get();
|
||||
}
|
||||
|
||||
public void setLocalization(SupportedLocale localization) {
|
||||
this.localization.set(localization);
|
||||
}
|
||||
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
public String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
public void setPromptedVersion(String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
public boolean isAcceptPreviewUpdate() {
|
||||
return acceptPreviewUpdate.get();
|
||||
}
|
||||
|
||||
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
|
||||
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
|
||||
}
|
||||
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
@SerializedName("commonDirType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirTypeProperty() {
|
||||
return commonDirType;
|
||||
}
|
||||
|
||||
public EnumCommonDirectory getCommonDirType() {
|
||||
return commonDirType.get();
|
||||
}
|
||||
|
||||
public void setCommonDirType(EnumCommonDirectory commonDirType) {
|
||||
this.commonDirType.set(commonDirType);
|
||||
}
|
||||
|
||||
@SerializedName("commonpath")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString());
|
||||
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
public String getCommonDirectory() {
|
||||
return commonDirectory.get();
|
||||
}
|
||||
|
||||
public void setCommonDirectory(String commonDirectory) {
|
||||
this.commonDirectory.set(commonDirectory);
|
||||
}
|
||||
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
public ObjectProperty<Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
public Integer getLogLines() {
|
||||
return logLines.get();
|
||||
}
|
||||
|
||||
public void setLogLines(Integer logLines) {
|
||||
this.logLines.set(logLines);
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
@SerializedName("themeBrightness")
|
||||
private final StringProperty themeBrightness = new SimpleStringProperty("light");
|
||||
|
||||
public StringProperty themeBrightnessProperty() {
|
||||
return themeBrightness;
|
||||
}
|
||||
|
||||
public String getThemeBrightness() {
|
||||
return themeBrightness.get();
|
||||
}
|
||||
|
||||
public void setThemeBrightness(String themeBrightness) {
|
||||
this.themeBrightness.set(themeBrightness);
|
||||
}
|
||||
|
||||
@SerializedName("theme")
|
||||
private final ObjectProperty<ThemeColor> themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
public ObjectProperty<ThemeColor> themeColorProperty() {
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
public ThemeColor getThemeColor() {
|
||||
return themeColor.get();
|
||||
}
|
||||
|
||||
public void setThemeColor(ThemeColor themeColor) {
|
||||
this.themeColor.set(themeColor);
|
||||
}
|
||||
|
||||
@SerializedName("fontFamily")
|
||||
private final StringProperty fontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty fontFamilyProperty() {
|
||||
return fontFamily;
|
||||
}
|
||||
|
||||
public String getFontFamily() {
|
||||
return fontFamily.get();
|
||||
}
|
||||
|
||||
public void setFontFamily(String fontFamily) {
|
||||
this.fontFamily.set(fontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("fontSize")
|
||||
private final DoubleProperty fontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
public DoubleProperty fontSizeProperty() {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
public double getFontSize() {
|
||||
return fontSize.get();
|
||||
}
|
||||
|
||||
public void setFontSize(double fontSize) {
|
||||
this.fontSize.set(fontSize);
|
||||
}
|
||||
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
public String getLauncherFontFamily() {
|
||||
return launcherFontFamily.get();
|
||||
}
|
||||
|
||||
public void setLauncherFontFamily(String launcherFontFamily) {
|
||||
this.launcherFontFamily.set(launcherFontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("animationDisabled")
|
||||
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
return animationDisabled.get();
|
||||
}
|
||||
|
||||
public void setAnimationDisabled(boolean animationDisabled) {
|
||||
this.animationDisabled.set(animationDisabled);
|
||||
}
|
||||
|
||||
@SerializedName("titleTransparent")
|
||||
private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty titleTransparentProperty() {
|
||||
return titleTransparent;
|
||||
}
|
||||
|
||||
public boolean isTitleTransparent() {
|
||||
return titleTransparent.get();
|
||||
}
|
||||
|
||||
public void setTitleTransparent(boolean titleTransparent) {
|
||||
this.titleTransparent.set(titleTransparent);
|
||||
}
|
||||
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<EnumBackgroundImage> backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumBackgroundImage> backgroundImageTypeProperty() {
|
||||
return backgroundImageType;
|
||||
}
|
||||
|
||||
public EnumBackgroundImage getBackgroundImageType() {
|
||||
return backgroundImageType.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) {
|
||||
this.backgroundImageType.set(backgroundImageType);
|
||||
}
|
||||
|
||||
@SerializedName("bgpath")
|
||||
private final StringProperty backgroundImage = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageProperty() {
|
||||
return backgroundImage;
|
||||
}
|
||||
|
||||
public String getBackgroundImage() {
|
||||
return backgroundImage.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImage(String backgroundImage) {
|
||||
this.backgroundImage.set(backgroundImage);
|
||||
}
|
||||
|
||||
@SerializedName("bgurl")
|
||||
private final StringProperty backgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageUrlProperty() {
|
||||
return backgroundImageUrl;
|
||||
}
|
||||
|
||||
public String getBackgroundImageUrl() {
|
||||
return backgroundImageUrl.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageUrl(String backgroundImageUrl) {
|
||||
this.backgroundImageUrl.set(backgroundImageUrl);
|
||||
}
|
||||
|
||||
@SerializedName("bgpaint")
|
||||
private final ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
public Paint getBackgroundPaint() {
|
||||
return backgroundPaint.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Paint> backgroundPaintProperty() {
|
||||
return backgroundPaint;
|
||||
}
|
||||
|
||||
public void setBackgroundPaint(Paint backgroundPaint) {
|
||||
this.backgroundPaint.set(backgroundPaint);
|
||||
}
|
||||
|
||||
@SerializedName("bgImageOpacity")
|
||||
private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
|
||||
|
||||
public IntegerProperty backgroundImageOpacityProperty() {
|
||||
return backgroundImageOpacity;
|
||||
}
|
||||
|
||||
public int getBackgroundImageOpacity() {
|
||||
return backgroundImageOpacity.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
|
||||
this.backgroundImageOpacity.set(backgroundImageOpacity);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
public boolean getAutoDownloadThreads() {
|
||||
return autoDownloadThreads.get();
|
||||
}
|
||||
|
||||
public void setAutoDownloadThreads(boolean autoDownloadThreads) {
|
||||
this.autoDownloadThreads.set(autoDownloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
public int getDownloadThreads() {
|
||||
return downloadThreads.get();
|
||||
}
|
||||
|
||||
public void setDownloadThreads(int downloadThreads) {
|
||||
this.downloadThreads.set(downloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadType")
|
||||
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID);
|
||||
|
||||
public StringProperty downloadTypeProperty() {
|
||||
return downloadType;
|
||||
}
|
||||
|
||||
public String getDownloadType() {
|
||||
return downloadType.get();
|
||||
}
|
||||
|
||||
public void setDownloadType(String downloadType) {
|
||||
this.downloadType.set(downloadType);
|
||||
}
|
||||
|
||||
@SerializedName("autoChooseDownloadType")
|
||||
private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoChooseDownloadTypeProperty() {
|
||||
return autoChooseDownloadType;
|
||||
}
|
||||
|
||||
public boolean isAutoChooseDownloadType() {
|
||||
return autoChooseDownloadType.get();
|
||||
}
|
||||
|
||||
public void setAutoChooseDownloadType(boolean autoChooseDownloadType) {
|
||||
this.autoChooseDownloadType.set(autoChooseDownloadType);
|
||||
}
|
||||
|
||||
@SerializedName("versionListSource")
|
||||
private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID);
|
||||
|
||||
public StringProperty versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
public String getVersionListSource() {
|
||||
return versionListSource.get();
|
||||
}
|
||||
|
||||
public void setVersionListSource(String versionListSource) {
|
||||
this.versionListSource.set(versionListSource);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxy")
|
||||
private final BooleanProperty hasProxy = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyProperty() {
|
||||
return hasProxy;
|
||||
}
|
||||
|
||||
public boolean hasProxy() {
|
||||
return hasProxy.get();
|
||||
}
|
||||
|
||||
public void setHasProxy(boolean hasProxy) {
|
||||
this.hasProxy.set(hasProxy);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
public boolean hasProxyAuth() {
|
||||
return hasProxyAuth.get();
|
||||
}
|
||||
|
||||
public void setHasProxyAuth(boolean hasProxyAuth) {
|
||||
this.hasProxyAuth.set(hasProxyAuth);
|
||||
}
|
||||
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<Proxy.Type> proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP);
|
||||
|
||||
public ObjectProperty<Proxy.Type> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
public Proxy.Type getProxyType() {
|
||||
return proxyType.get();
|
||||
}
|
||||
|
||||
public void setProxyType(Proxy.Type proxyType) {
|
||||
this.proxyType.set(proxyType);
|
||||
}
|
||||
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
public String getProxyHost() {
|
||||
return proxyHost.get();
|
||||
}
|
||||
|
||||
public void setProxyHost(String proxyHost) {
|
||||
this.proxyHost.set(proxyHost);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
public int getProxyPort() {
|
||||
return proxyPort.get();
|
||||
}
|
||||
|
||||
public void setProxyPort(int proxyPort) {
|
||||
this.proxyPort.set(proxyPort);
|
||||
}
|
||||
|
||||
@SerializedName("proxyUserName")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
public String getProxyUser() {
|
||||
return proxyUser.get();
|
||||
}
|
||||
|
||||
public void setProxyUser(String proxyUser) {
|
||||
this.proxyUser.set(proxyUser);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPass = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyPassProperty() {
|
||||
return proxyPass;
|
||||
}
|
||||
|
||||
public String getProxyPass() {
|
||||
return proxyPass.get();
|
||||
}
|
||||
|
||||
public void setProxyPass(String proxyPass) {
|
||||
this.proxyPass.set(proxyPass);
|
||||
}
|
||||
|
||||
// Game
|
||||
|
||||
@SerializedName("disableAutoGameOptions")
|
||||
private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty disableAutoGameOptionsProperty() {
|
||||
return disableAutoGameOptions;
|
||||
}
|
||||
|
||||
public boolean isDisableAutoGameOptions() {
|
||||
return disableAutoGameOptions.get();
|
||||
}
|
||||
|
||||
public void setDisableAutoGameOptions(boolean disableAutoGameOptions) {
|
||||
this.disableAutoGameOptions.set(disableAutoGameOptions);
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
@SerializedName("authlibInjectorServers")
|
||||
private final ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
|
||||
|
||||
public ObservableList<AuthlibInjectorServer> getAuthlibInjectorServers() {
|
||||
return authlibInjectorServers;
|
||||
}
|
||||
|
||||
@SerializedName("addedLittleSkin")
|
||||
private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty addedLittleSkinProperty() {
|
||||
return addedLittleSkin;
|
||||
}
|
||||
|
||||
public boolean isAddedLittleSkin() {
|
||||
return addedLittleSkin.get();
|
||||
}
|
||||
|
||||
public void setAddedLittleSkin(boolean addedLittleSkin) {
|
||||
this.addedLittleSkin.set(addedLittleSkin);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred login type to use when the user wants to add an account.
|
||||
*/
|
||||
@SerializedName("preferredLoginType")
|
||||
private final StringProperty preferredLoginType = new SimpleStringProperty();
|
||||
|
||||
public StringProperty preferredLoginTypeProperty() {
|
||||
return preferredLoginType;
|
||||
}
|
||||
|
||||
public String getPreferredLoginType() {
|
||||
return preferredLoginType.get();
|
||||
}
|
||||
|
||||
public void setPreferredLoginType(String preferredLoginType) {
|
||||
this.preferredLoginType.set(preferredLoginType);
|
||||
}
|
||||
|
||||
@SerializedName("selectedAccount")
|
||||
private final StringProperty selectedAccount = new SimpleStringProperty();
|
||||
|
||||
public StringProperty selectedAccountProperty() {
|
||||
return selectedAccount;
|
||||
}
|
||||
|
||||
public String getSelectedAccount() {
|
||||
return selectedAccount.get();
|
||||
}
|
||||
|
||||
public void setSelectedAccount(String selectedAccount) {
|
||||
this.selectedAccount.set(selectedAccount);
|
||||
}
|
||||
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<Map<Object, Object>> getAccountStorages() {
|
||||
return accountStorages;
|
||||
}
|
||||
|
||||
// Configurations
|
||||
|
||||
@SerializedName("last")
|
||||
private final StringProperty selectedProfile = new SimpleStringProperty("");
|
||||
|
||||
public StringProperty selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
public String getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public void setSelectedProfile(String selectedProfile) {
|
||||
this.selectedProfile.set(selectedProfile);
|
||||
}
|
||||
|
||||
@SerializedName("configurations")
|
||||
private final SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
|
||||
|
||||
public MapProperty<String, Profile> getConfigurations() {
|
||||
return configurations;
|
||||
}
|
||||
|
||||
public static final class Adapter extends ObservableSetting.Adapter<Config> {
|
||||
@Override
|
||||
protected Config createInstance() {
|
||||
return new Config();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ConfigHolder {
|
||||
|
||||
private ConfigHolder() {
|
||||
}
|
||||
|
||||
public static final String CONFIG_FILENAME = "hmcl.json";
|
||||
public static final String CONFIG_FILENAME_LINUX = ".hmcl.json";
|
||||
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json");
|
||||
|
||||
private static Path configLocation;
|
||||
private static Config configInstance;
|
||||
private static GlobalConfig globalConfigInstance;
|
||||
private static boolean newlyCreated;
|
||||
private static boolean ownerChanged = false;
|
||||
private static boolean unsupportedVersion = false;
|
||||
|
||||
public static Config config() {
|
||||
if (configInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return configInstance;
|
||||
}
|
||||
|
||||
public static GlobalConfig globalConfig() {
|
||||
if (globalConfigInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return globalConfigInstance;
|
||||
}
|
||||
|
||||
public static Path configLocation() {
|
||||
return configLocation;
|
||||
}
|
||||
|
||||
public static boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
public static boolean isOwnerChanged() {
|
||||
return ownerChanged;
|
||||
}
|
||||
|
||||
public static boolean isUnsupportedVersion() {
|
||||
return unsupportedVersion;
|
||||
}
|
||||
|
||||
public static void init() throws IOException {
|
||||
if (configInstance != null) {
|
||||
throw new IllegalStateException("Configuration is already loaded");
|
||||
}
|
||||
|
||||
configLocation = locateConfig();
|
||||
|
||||
LOG.info("Config location: " + configLocation);
|
||||
|
||||
configInstance = loadConfig();
|
||||
if (!unsupportedVersion)
|
||||
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
|
||||
|
||||
globalConfigInstance = loadGlobalConfig();
|
||||
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
|
||||
|
||||
Locale.setDefault(config().getLocalization().getLocale());
|
||||
I18n.setLocale(configInstance.getLocalization());
|
||||
LOG.setLogRetention(globalConfig().getLogRetention());
|
||||
Settings.init();
|
||||
|
||||
if (newlyCreated) {
|
||||
LOG.info("Creating config file " + configLocation);
|
||||
FileUtils.saveSafely(configLocation, configInstance.toJson());
|
||||
}
|
||||
|
||||
if (!Files.isWritable(configLocation)) {
|
||||
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 {
|
||||
// the config cannot be saved
|
||||
// throw up the error now to prevent further data loss
|
||||
throw new IOException("Config at " + configLocation + " is not writable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Path locateConfig() {
|
||||
Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(defaultConfigFile))
|
||||
return defaultConfigFile;
|
||||
|
||||
try {
|
||||
Path jarPath = JarUtils.thisJarPath();
|
||||
if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) {
|
||||
jarPath = jarPath.getParent();
|
||||
|
||||
Path config = jarPath.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
}
|
||||
|
||||
} catch (Throwable ignore) {
|
||||
}
|
||||
|
||||
Path config = Paths.get(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
|
||||
// create new
|
||||
return defaultConfigFile;
|
||||
}
|
||||
|
||||
private static Config loadConfig() throws IOException {
|
||||
if (Files.exists(configLocation)) {
|
||||
try {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
&& "root".equals(System.getProperty("user.name"))
|
||||
&& !"root".equals(Files.getOwner(configLocation).getName())) {
|
||||
ownerChanged = true;
|
||||
}
|
||||
} catch (IOException e1) {
|
||||
LOG.warning("Failed to get owner");
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(configLocation);
|
||||
Config deserialized = Config.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
if (configVersion < Config.CURRENT_VERSION) {
|
||||
ConfigUpgrader.upgradeConfig(deserialized, content);
|
||||
} else if (configVersion > Config.CURRENT_VERSION) {
|
||||
unsupportedVersion = true;
|
||||
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion));
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreated = true;
|
||||
return new Config();
|
||||
}
|
||||
|
||||
// Global Config
|
||||
|
||||
private static GlobalConfig loadGlobalConfig() throws IOException {
|
||||
if (Files.exists(GLOBAL_CONFIG_PATH)) {
|
||||
try {
|
||||
String content = Files.readString(GLOBAL_CONFIG_PATH);
|
||||
GlobalConfig deserialized = GlobalConfig.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("Creating an empty global config");
|
||||
return new GlobalConfig();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
final class ConfigUpgrader {
|
||||
private ConfigUpgrader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is for the compatibility with old HMCL versions.
|
||||
*
|
||||
* @param deserialized deserialized config settings
|
||||
* @param rawContent raw json content of the config settings without modification
|
||||
*/
|
||||
static void upgradeConfig(Config deserialized, String rawContent) {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
|
||||
if (configVersion >= Config.CURRENT_VERSION)
|
||||
return;
|
||||
|
||||
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION));
|
||||
Map<?, ?> rawJson = Collections.unmodifiableMap(new Gson().<Map<?, ?>>fromJson(rawContent, Map.class));
|
||||
|
||||
if (configVersion < 1) {
|
||||
// Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important.
|
||||
tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> {
|
||||
tryCast(auth.get("offline"), Map.class).ifPresent(offline -> {
|
||||
String selected = rawJson.containsKey("selectedAccount") ? null
|
||||
: tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null);
|
||||
|
||||
tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> {
|
||||
((Map<?, ?>) uuidMap).forEach((key, value) -> {
|
||||
Map<Object, Object> storage = new HashMap<>();
|
||||
storage.put("type", "offline");
|
||||
storage.put("username", key);
|
||||
storage.put("uuid", value);
|
||||
if (key.equals(selected)) {
|
||||
storage.put("selected", true);
|
||||
}
|
||||
deserialized.getAccountStorages().add(storage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Upgrade configuration of HMCL earlier than 3.1.70
|
||||
if (!rawJson.containsKey("commonDirType"))
|
||||
deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM);
|
||||
if (!rawJson.containsKey("backgroundType"))
|
||||
deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT);
|
||||
if (!rawJson.containsKey("hasProxy"))
|
||||
deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost()));
|
||||
if (!rawJson.containsKey("hasProxyAuth"))
|
||||
deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser()));
|
||||
|
||||
if (!rawJson.containsKey("downloadType")) {
|
||||
tryCast(rawJson.get("downloadtype"), Number.class)
|
||||
.map(Number::intValue)
|
||||
.ifPresent(id -> {
|
||||
if (id == 0) {
|
||||
deserialized.setDownloadType("mojang");
|
||||
} else if (id == 1) {
|
||||
deserialized.setDownloadType("bmclapi");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deserialized.setConfigVersion(Config.CURRENT_VERSION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/// @author Glavo
|
||||
public enum DefaultIsolationType {
|
||||
/// Default to isolate all instances.
|
||||
ALWAYS,
|
||||
|
||||
/// Default to isolate instances with any modloader.
|
||||
MODDED,
|
||||
|
||||
/// No instances are isolated by default.
|
||||
NEVER,
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
@@ -33,82 +32,70 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
|
||||
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
|
||||
|
||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
||||
|
||||
private static final DownloadProvider MOJANG_PROVIDER;
|
||||
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
|
||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
||||
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
|
||||
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
|
||||
DownloadProvider mojang = new MojangDownloadProvider();
|
||||
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
|
||||
|
||||
DEFAULT_PROVIDER = mojang;
|
||||
DIRECT_PROVIDERS = Lang.mapOf(
|
||||
pair("mojang", mojang),
|
||||
pair("bmclapi", bmclapi)
|
||||
);
|
||||
|
||||
AUTO_PROVIDERS = Lang.mapOf(
|
||||
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
|
||||
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
|
||||
List.of(mojang, bmclapiRaw),
|
||||
List.of(bmclapiRaw, mojang)
|
||||
) : mojang),
|
||||
pair("mirror", bmclapi)
|
||||
);
|
||||
|
||||
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
MOJANG_PROVIDER = new MojangDownloadProvider();
|
||||
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Initializes download provider settings and synchronizes download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
|
||||
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
|
||||
? DEFAULT_CONCURRENCY
|
||||
: config().getDownloadThreads());
|
||||
: settings().downloadThreadsProperty().get());
|
||||
};
|
||||
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
if (config().isAutoChooseDownloadType()) {
|
||||
String versionListSource = config().getVersionListSource();
|
||||
DownloadProvider downloadProvider = versionListSource != null
|
||||
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER;
|
||||
PROVIDER_WRAPPER.setProvider(downloadProvider);
|
||||
} else {
|
||||
String downloadType = config().getDownloadType();
|
||||
PROVIDER_WRAPPER.setProvider(downloadType != null
|
||||
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER);
|
||||
}
|
||||
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
|
||||
settings().versionListSourceProperty().get(),
|
||||
settings().fileDownloadSourceProperty().get()));
|
||||
};
|
||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
config().downloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
}
|
||||
|
||||
/// Creates a download provider with independent version-list and file download preferences.
|
||||
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
|
||||
return new AutoDownloadProvider(
|
||||
getCandidates(versionListSource),
|
||||
getCandidates(fileDownloadSource));
|
||||
}
|
||||
|
||||
/// Returns provider candidates ordered by the given source preference.
|
||||
private static List<DownloadProvider> getCandidates(DownloadSource source) {
|
||||
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
|
||||
return switch (normalized) {
|
||||
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
|
||||
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
|
||||
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
|
||||
case OFFICIAL -> List.of(MOJANG_PROVIDER);
|
||||
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
|
||||
+12
-19
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -15,26 +15,19 @@
|
||||
* 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.game;
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public class GameException extends Exception {
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
public GameException() {
|
||||
}
|
||||
/// Preferred source selection for download providers.
|
||||
@NotNullByDefault
|
||||
public enum DownloadSource {
|
||||
/// Automatically chooses the best source for the current environment.
|
||||
DEFAULT,
|
||||
|
||||
public GameException(String message) {
|
||||
super(message);
|
||||
}
|
||||
/// Prefers official download sources.
|
||||
OFFICIAL,
|
||||
|
||||
public GameException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public GameException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
/// Prefers mirror download sources.
|
||||
MIRROR,
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -56,17 +56,17 @@ public final class FontManager {
|
||||
|
||||
// Recommended
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
// Legacy
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -74,7 +74,7 @@ public final class FontManager {
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -103,7 +103,7 @@ public final class FontManager {
|
||||
}
|
||||
|
||||
private static void updateFont() {
|
||||
String fontFamily = config().getLauncherFontFamily();
|
||||
String fontFamily = settings().launcherFontFamilyProperty().get();
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getProperty("hmcl.font.override");
|
||||
if (fontFamily == null)
|
||||
@@ -236,7 +236,7 @@ public final class FontManager {
|
||||
}
|
||||
|
||||
public static void setFontFamily(String fontFamily) {
|
||||
config().setLauncherFontFamily(fontFamily);
|
||||
settings().launcherFontFamilyProperty().set(fontFamily);
|
||||
updateFont();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores game directories independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory in either the current
|
||||
/// HMCL directory or the user HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameDirectories.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameDirectories extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game directory store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-directories", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game directory store.
|
||||
public GameDirectories() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this game directory store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game directory store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game directory store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this game directory store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Game directories stored in this file.
|
||||
@SerializedName("directories")
|
||||
private final ObservableList<GameDirectory> gameDirectories =
|
||||
FXCollections.observableArrayList(profile -> new Observable[] { profile });
|
||||
|
||||
/// Whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
private transient boolean userFile;
|
||||
|
||||
/// Returns the game directories stored in this file.
|
||||
public ObservableList<GameDirectory> getGameDirectories() {
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Sets whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
void setUserFile(boolean userFile) {
|
||||
this.userFile = userFile;
|
||||
}
|
||||
|
||||
/// Returns whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
boolean isUserFile() {
|
||||
return userFile;
|
||||
}
|
||||
|
||||
/// Whether this store was created because its JSON file did not exist.
|
||||
private transient boolean newlyCreated;
|
||||
|
||||
/// Returns whether this store was created because its JSON file did not exist.
|
||||
boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
/// Sets whether this store was created because its JSON file did not exist.
|
||||
void setNewlyCreated(boolean newlyCreated) {
|
||||
this.newlyCreated = newlyCreated;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameDirectories].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameDirectories> {
|
||||
/// Creates an empty game directory store for deserialization.
|
||||
@Override
|
||||
protected GameDirectories createInstance() {
|
||||
return new GameDirectories();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Persistent configuration for a game directory.
|
||||
@JsonAdapter(GameDirectory.Serializer.class)
|
||||
@NotNullByDefault
|
||||
public final class GameDirectory implements Observable {
|
||||
/// The stable game directory ID.
|
||||
private final GameDirectoryID id;
|
||||
|
||||
/// Returns the stable game directory ID.
|
||||
public GameDirectoryID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// The game directory path.
|
||||
private final ObjectProperty<PortablePath> path;
|
||||
|
||||
/// Returns the game directory path property.
|
||||
public ObjectProperty<PortablePath> pathProperty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Returns the game directory path.
|
||||
public PortablePath getPath() {
|
||||
return path.get();
|
||||
}
|
||||
|
||||
/// Sets the game directory path.
|
||||
public void setPath(PortablePath path) {
|
||||
this.path.set(Objects.requireNonNull(path));
|
||||
}
|
||||
|
||||
/// The custom localized game directory name, or `null` when no name is stored.
|
||||
private final ObjectProperty<@Nullable LocalizedText> name;
|
||||
|
||||
/// Returns the custom localized game directory name property.
|
||||
public ObjectProperty<@Nullable LocalizedText> nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Returns the custom localized game directory name, or `null` when no name is stored.
|
||||
public @Nullable LocalizedText getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
/// Sets the custom localized game directory name.
|
||||
public void setName(@Nullable LocalizedText name) {
|
||||
this.name.set(name);
|
||||
}
|
||||
|
||||
/// The migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
|
||||
private final ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettings;
|
||||
|
||||
/// Returns the migrated legacy game settings preset ID property.
|
||||
public ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettingsProperty() {
|
||||
return legacyGameSettings;
|
||||
}
|
||||
|
||||
/// Returns the migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
|
||||
public @Nullable GameSettingsPresetID getLegacyGameSettings() {
|
||||
return legacyGameSettings.get();
|
||||
}
|
||||
|
||||
/// Sets the migrated legacy game settings preset ID.
|
||||
public void setLegacyGameSettings(@Nullable GameSettingsPresetID legacyGameSettings) {
|
||||
this.legacyGameSettings.set(legacyGameSettings);
|
||||
}
|
||||
|
||||
/// Creates a game directory.
|
||||
public GameDirectory(GameDirectoryID id, @Nullable LocalizedText name, PortablePath path) {
|
||||
this(id, name, path, null);
|
||||
}
|
||||
|
||||
/// Creates a game directory.
|
||||
public GameDirectory(
|
||||
GameDirectoryID id,
|
||||
@Nullable LocalizedText name,
|
||||
PortablePath path,
|
||||
@Nullable GameSettingsPresetID legacyGameSettings) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.name = new SimpleObjectProperty<>(this, "name", name);
|
||||
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
|
||||
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
|
||||
|
||||
addPropertyChangedListener(arg -> invalidate());
|
||||
}
|
||||
|
||||
/// Returns a debug string containing the game directory path and display metadata.
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("path", getPath())
|
||||
.append("name", getName())
|
||||
.toString();
|
||||
}
|
||||
|
||||
/// Registers a listener that invalidates this game directory when any stored property changes.
|
||||
private void addPropertyChangedListener(InvalidationListener listener) {
|
||||
name.addListener(listener);
|
||||
path.addListener(listener);
|
||||
legacyGameSettings.addListener(listener);
|
||||
}
|
||||
|
||||
/// Helper that stores and dispatches invalidation listeners for this game directory.
|
||||
private final ObservableHelper observableHelper = new ObservableHelper(this);
|
||||
|
||||
/// Adds an invalidation listener.
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
observableHelper.addListener(listener);
|
||||
}
|
||||
|
||||
/// Removes an invalidation listener.
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
observableHelper.removeListener(listener);
|
||||
}
|
||||
|
||||
/// Notifies game directory observers on the JavaFX thread when the toolkit is available.
|
||||
private void invalidate() {
|
||||
try {
|
||||
Platform.runLater(observableHelper::invalidate);
|
||||
} catch (IllegalStateException e) {
|
||||
observableHelper.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes and deserializes game directories.
|
||||
@NotNullByDefault
|
||||
public static final class Serializer implements JsonSerializer<GameDirectory>, JsonDeserializer<GameDirectory> {
|
||||
/// Serializes a game directory to JSON.
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable GameDirectory src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null)
|
||||
return JsonNull.INSTANCE;
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("id", context.serialize(src.getId(), GameDirectoryID.class));
|
||||
if (src.getName() != null) {
|
||||
JsonElement name = context.serialize(src.getName(), LocalizedText.class);
|
||||
if (name != null && !name.isJsonNull()) {
|
||||
jsonObject.add("name", name);
|
||||
}
|
||||
}
|
||||
jsonObject.add("path", context.serialize(src.getPath(), PortablePath.class));
|
||||
if (src.getLegacyGameSettings() != null) {
|
||||
jsonObject.add("legacyGameSettings", context.serialize(src.getLegacyGameSettings(), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
/// Deserializes a game directory from JSON.
|
||||
@Override
|
||||
public @Nullable GameDirectory deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject obj)) return null;
|
||||
GameDirectoryID id = context.deserialize(obj.get("id"), GameDirectoryID.class);
|
||||
if (id == null) {
|
||||
throw new JsonParseException("Game directory ID cannot be null");
|
||||
} else if (GameDirectoryID.NIL.equals(id)) {
|
||||
throw new JsonParseException("Game directory ID cannot be nil");
|
||||
}
|
||||
PortablePath path = context.deserialize(obj.get("path"), PortablePath.class);
|
||||
if (path == null) {
|
||||
throw new JsonParseException("Game directory path cannot be null");
|
||||
}
|
||||
@Nullable LocalizedText name = context.deserialize(obj.get("name"), LocalizedText.class);
|
||||
|
||||
return new GameDirectory(id,
|
||||
name,
|
||||
path,
|
||||
context.deserialize(obj.get("legacyGameSettings"), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game directory entry.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameDirectoryID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameDirectoryID(UUID uuid) implements TypedID {
|
||||
/// The serialized game directory ID prefix.
|
||||
public static final String PREFIX = "game-directory";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameDirectoryID NIL = new GameDirectoryID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game directory ID.
|
||||
public GameDirectoryID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game directory ID from a prefixed UUID string.
|
||||
public static GameDirectoryID parse(String value) {
|
||||
return new GameDirectoryID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game directory ID.
|
||||
public static GameDirectoryID generate() {
|
||||
return new GameDirectoryID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game directory ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameDirectoryID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameDirectoryID> {
|
||||
/// Creates a game directory ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameDirectoryID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Manages the merged runtime view of local and user game directories.
|
||||
///
|
||||
/// Local game directories take precedence over user game directories with the same ID. This class is
|
||||
/// the owner of the currently selected game directory and selected instance bookkeeping;
|
||||
/// callers should use this API instead of mutating the game-directory selection fields in
|
||||
/// [LauncherSettings] directly.
|
||||
@NotNullByDefault
|
||||
public final class GameDirectoryManager {
|
||||
|
||||
/// The stable ID shared by newly created and migrated current-workspace game directories.
|
||||
static final GameDirectoryID DEFAULT_GAME_DIRECTORY_ID =
|
||||
new GameDirectoryID(new UUID(0x7105bc1f490e5e8cL, 0x878cf5844c3d4bc3L));
|
||||
|
||||
/// The stable ID shared by newly created and migrated user-home game directories.
|
||||
static final GameDirectoryID HOME_GAME_DIRECTORY_ID =
|
||||
new GameDirectoryID(new UUID(0xf3eafde8506e5a77L, 0xbc88f24b4728dfb2L));
|
||||
|
||||
/// The default current-workspace game directory path.
|
||||
private static final PortablePath CURRENT_GAME_DIRECTORY_PATH = PortablePath.of(".minecraft");
|
||||
|
||||
/// The default user-home game directory path.
|
||||
private static final PortablePath HOME_GAME_DIRECTORY_PATH = PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY);
|
||||
|
||||
/// Prevents instantiation.
|
||||
private GameDirectoryManager() {
|
||||
}
|
||||
|
||||
/// Creates a game directory ID that does not collide with existing game directories.
|
||||
public static GameDirectoryID newGameDirectoryId() {
|
||||
GameDirectoryID id;
|
||||
do {
|
||||
id = GameDirectoryID.generate();
|
||||
} while (hasGameDirectoryId(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Returns whether an existing game directory uses the given ID.
|
||||
private static boolean hasGameDirectoryId(GameDirectoryID id) {
|
||||
return localGameDirectories().getGameDirectories().stream()
|
||||
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id))
|
||||
|| userGameDirectories().getGameDirectories().stream()
|
||||
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id));
|
||||
}
|
||||
|
||||
/// Returns the display name for the given game directory.
|
||||
public static String getGameDirectoryDisplayName(GameDirectory gameDirectory) {
|
||||
String name = getGameDirectoryCustomName(gameDirectory);
|
||||
if (name != null) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
|
||||
return i18n("game_directory.default");
|
||||
}
|
||||
if (isGameDirectoryPath(gameDirectory, HOME_GAME_DIRECTORY_PATH)) {
|
||||
return i18n("game_directory.home");
|
||||
}
|
||||
|
||||
return gameDirectory.getId().toString();
|
||||
}
|
||||
|
||||
/// Returns the custom game directory name in the current locale.
|
||||
public static @Nullable String getGameDirectoryCustomName(GameDirectory gameDirectory) {
|
||||
@Nullable LocalizedText name = gameDirectory.getName();
|
||||
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Returns whether the game directory uses the given path.
|
||||
private static boolean isGameDirectoryPath(GameDirectory gameDirectory, PortablePath expectedPath) {
|
||||
PortablePath actualPath = gameDirectory.getPath();
|
||||
return actualPath.isAbsolute() == expectedPath.isAbsolute()
|
||||
&& actualPath.getPath().equals(expectedPath.getPath());
|
||||
}
|
||||
|
||||
/// True if [#init()] hasn't been called.
|
||||
private static boolean initialized = false;
|
||||
|
||||
/// The mutable merged game directory list used by UI bindings and game directory selection.
|
||||
private static final ObservableList<GameDirectory> mergedGameDirectories =
|
||||
FXCollections.observableArrayList(gameDirectory -> new javafx.beans.Observable[]{gameDirectory});
|
||||
|
||||
/// Read-only view of [#mergedGameDirectories] exposed to callers.
|
||||
private static final @UnmodifiableView ObservableList<GameDirectory> mergedGameDirectoriesUnmodifiable =
|
||||
FXCollections.unmodifiableObservableList(mergedGameDirectories);
|
||||
|
||||
/// Runtime repositories keyed by game directory object identity.
|
||||
private static final Map<GameDirectory, HMCLGameRepository> repositories = new IdentityHashMap<>();
|
||||
|
||||
/// The selected game directory, or `null` before the fallback game directory is resolved.
|
||||
private static final ObjectProperty<@UnknownNullability GameDirectory> selectedGameDirectory = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedGameDirectory");
|
||||
|
||||
/// The selected game repository, or `null` before the fallback game directory is resolved.
|
||||
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
|
||||
|
||||
/// The selected instance projected from the selected repository's current snapshot.
|
||||
private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance =
|
||||
new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
|
||||
|
||||
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
|
||||
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
|
||||
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
|
||||
|
||||
/// Reacts when the selected repository publishes a new snapshot.
|
||||
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
|
||||
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
|
||||
|
||||
/// Initializes game directory state from the stores loaded by [SettingsManager].
|
||||
///
|
||||
/// This method creates the built-in local and user-home game directories when required, rebuilds
|
||||
/// the merged game directory view, and restores the selected game directory from [LauncherSettings].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
initialized = true;
|
||||
|
||||
rebuildGameDirectories();
|
||||
|
||||
boolean needRebuildGameDirectories = false;
|
||||
if (localGameDirectories().isNewlyCreated() && localGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = localGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = userGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
|
||||
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
|
||||
if (needRebuildGameDirectories) {
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
assert !mergedGameDirectories.isEmpty();
|
||||
|
||||
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
|
||||
GameDirectory currentGameDirectory = null;
|
||||
|
||||
if (selectedId != null) {
|
||||
for (GameDirectory gameDirectory : mergedGameDirectories) {
|
||||
if (gameDirectory.getId().equals(selectedId)) {
|
||||
currentGameDirectory = gameDirectory;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedGameDirectory.addListener((a, b, newValue) -> {
|
||||
if (newValue == null) {
|
||||
throw new IllegalStateException("selectedGameDirectory cannot be null");
|
||||
}
|
||||
|
||||
settings().selectedGameDirectoryProperty().set(newValue.getId());
|
||||
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
|
||||
if (oldRepository != null) {
|
||||
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
|
||||
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
|
||||
}
|
||||
HMCLGameRepository repository = getOrCreateRepository(newValue);
|
||||
selectedRepository.set(repository);
|
||||
selectedInstance.set(repository.getSelectedInstance());
|
||||
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
|
||||
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
|
||||
if (repository.isLoaded()) {
|
||||
onSelectedRepositorySnapshotChanged();
|
||||
}
|
||||
repository.refreshAsync().start();
|
||||
});
|
||||
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
|
||||
}
|
||||
|
||||
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
|
||||
private static void onSelectedRepositorySnapshotChanged() {
|
||||
@Nullable HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository == null || !repository.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.refreshSelectedInstance();
|
||||
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
|
||||
listener.accept(repository);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the built-in game directories only when no game directory exists.
|
||||
private static boolean createDefaultGameDirectoriesIfEmpty() {
|
||||
if (localGameDirectories().getGameDirectories().isEmpty()
|
||||
&& userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
localGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
userGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the merged runtime game directory view from the two backing stores.
|
||||
private static void rebuildGameDirectories() {
|
||||
GameDirectories userGameDirectories = userGameDirectories();
|
||||
GameDirectories localGameDirectories = localGameDirectories();
|
||||
Map<GameDirectoryID, GameDirectory> visibleGameDirectories = new LinkedHashMap<>();
|
||||
|
||||
for (GameDirectory gameDirectory : localGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.put(id, gameDirectory);
|
||||
}
|
||||
for (GameDirectory gameDirectory : userGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.putIfAbsent(id, gameDirectory);
|
||||
}
|
||||
|
||||
mergedGameDirectories.setAll(visibleGameDirectories.values());
|
||||
}
|
||||
|
||||
/// Returns the read-only merged game directory list.
|
||||
public static @UnmodifiableView ObservableList<GameDirectory> getGameDirectories() {
|
||||
return mergedGameDirectoriesUnmodifiable;
|
||||
}
|
||||
|
||||
/// Returns the repository for the given game directory, creating it when needed.
|
||||
private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the per-workspace store.
|
||||
public static void addLocalGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("Local game directories are read-only");
|
||||
}
|
||||
addGameDirectory(localGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the user store.
|
||||
public static void addUserGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("User game directories are read-only");
|
||||
}
|
||||
addGameDirectory(userGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether an existing game directory can be updated to the given path.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
/// @return whether both the current and target stores may be edited
|
||||
public static boolean canUpdateGameDirectory(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
return !isGameDirectoriesReadOnly(source) && !isGameDirectoriesReadOnly(target);
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to update a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
forceOverwriteGameDirectoriesIfReadOnly(source);
|
||||
if (target != source) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a game directory, moving it between local and user stores when the path type changes.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param name the new custom game directory name, or `null` for an unnamed game directory
|
||||
/// @param path the new game directory path
|
||||
public static void updateGameDirectory(GameDirectory gameDirectory, @Nullable LocalizedText name, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
if (isGameDirectoriesReadOnly(source) || isGameDirectoriesReadOnly(target)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
if (source == target) {
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
return;
|
||||
}
|
||||
|
||||
source.getGameDirectories().remove(gameDirectory);
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
addGameDirectory(target, gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether the game directory can be removed from all stores containing it.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
/// @return whether all containing stores may be edited
|
||||
public static boolean canRemoveGameDirectory(GameDirectory gameDirectory) {
|
||||
boolean localContains = localGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
boolean userContains = userGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
return (!localContains || !SettingsManager.isLocalGameDirectoriesReadOnly())
|
||||
&& (!userContains || !SettingsManager.isUserGameDirectoriesReadOnly());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to remove a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory) {
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(localGameDirectories());
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(userGameDirectories());
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a game directory to the given store, replacing a game directory with the same ID in that store.
|
||||
private static void addGameDirectory(GameDirectories gameDirectories, GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
ObservableList<GameDirectory> entries = gameDirectories.getGameDirectories();
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
if (entries.get(i).getId().equals(id)) {
|
||||
repositories.remove(entries.get(i));
|
||||
settings().setSelectedInstance(id, null);
|
||||
entries.set(i, gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
/// Removes a game directory and recreates the built-in game directories when the list becomes empty.
|
||||
public static void removeGameDirectory(GameDirectory gameDirectory) {
|
||||
if (!canRemoveGameDirectory(gameDirectory)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
userGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
localGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
repositories.remove(gameDirectory);
|
||||
createDefaultGameDirectoriesIfEmpty();
|
||||
rebuildGameDirectories();
|
||||
|
||||
if (!mergedGameDirectories.contains(selectedGameDirectory.get())) {
|
||||
setSelectedGameDirectory(mergedGameDirectories.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the store that should own game directories with the given path.
|
||||
private static GameDirectories getGameDirectoryStore(PortablePath path) {
|
||||
Objects.requireNonNull(path);
|
||||
return path.isAbsolute() ? userGameDirectories() : localGameDirectories();
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory.
|
||||
private static @Nullable GameDirectories findGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return localGameDirectories();
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return userGameDirectories();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory, or throws when it is detached.
|
||||
private static GameDirectories requireGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
@Nullable GameDirectories gameDirectories = findGameDirectoryStore(gameDirectory);
|
||||
if (gameDirectories == null) {
|
||||
throw new IllegalArgumentException("Game directory does not belong to a game directory store");
|
||||
}
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Returns whether the given game directory store is read-only.
|
||||
private static boolean isGameDirectoriesReadOnly(GameDirectories gameDirectories) {
|
||||
return gameDirectories.isUserFile()
|
||||
? SettingsManager.isUserGameDirectoriesReadOnly()
|
||||
: SettingsManager.isLocalGameDirectoriesReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the given game directory store when it is read-only.
|
||||
private static void forceOverwriteGameDirectoriesIfReadOnly(GameDirectories gameDirectories) {
|
||||
if (!isGameDirectoriesReadOnly(gameDirectories)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameDirectories.isUserFile()) {
|
||||
SettingsManager.forceOverwriteUserGameDirectories();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteLocalGameDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected game directory, creating built-in game directories first if the list is empty.
|
||||
public static GameDirectory getSelectedGameDirectory() {
|
||||
GameDirectory gameDirectory = selectedGameDirectory.get();
|
||||
if (gameDirectory == null) {
|
||||
throw new IllegalStateException("Selected game directory cannot be null");
|
||||
}
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository.
|
||||
public static HMCLGameRepository getSelectedRepository() {
|
||||
HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository == null) {
|
||||
throw new IllegalStateException("Selected repository cannot be null");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Sets the selected game directory.
|
||||
public static void setSelectedGameDirectory(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (!mergedGameDirectories.contains(gameDirectory)) {
|
||||
throw new IllegalArgumentException("Unknown game directory: " + gameDirectory);
|
||||
}
|
||||
selectedGameDirectory.set(gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns the selected game directory property.
|
||||
///
|
||||
/// The property is exposed for UI binding. Values should be changed through
|
||||
/// [#setSelectedGameDirectory(GameDirectory)] or by bidirectional bindings that only select
|
||||
/// game directories from [#getGameDirectories()].
|
||||
public static ObjectProperty<GameDirectory> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository property.
|
||||
public static ObjectProperty<HMCLGameRepository> selectedRepositoryProperty() {
|
||||
return selectedRepository;
|
||||
}
|
||||
|
||||
/// Returns the selected instance property projected from the selected repository.
|
||||
///
|
||||
/// The value is `null` when the selected repository has no registered selected instance.
|
||||
///
|
||||
/// @return the read-only selected-instance property
|
||||
public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
|
||||
return selectedInstance.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the selected instance from the selected repository's current snapshot.
|
||||
///
|
||||
/// @return the selected instance, or `null` when none is registered
|
||||
public static @Nullable HMCLGameInstance getSelectedInstance() {
|
||||
return getSelectedRepository().getSelectedInstance();
|
||||
}
|
||||
|
||||
/// Sets the selected instance for the selected repository.
|
||||
///
|
||||
/// @param instance the instance to select, or `null` to clear the selection
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository
|
||||
public static void setSelectedInstance(@Nullable HMCLGameInstance instance) {
|
||||
getSelectedRepository().setSelectedInstance(instance);
|
||||
}
|
||||
|
||||
/// Listeners notified after the selected repository has loaded versions.
|
||||
private static final List<Consumer<HMCLGameRepository>> versionsListeners = new ArrayList<>(4);
|
||||
|
||||
/// Registers a listener that is notified when the selected repository's versions are available.
|
||||
public static void registerVersionsListener(Consumer<HMCLGameRepository> listener) {
|
||||
HMCLGameRepository repository = getSelectedRepository();
|
||||
if (repository.isLoaded())
|
||||
listener.accept(repository);
|
||||
versionsListeners.add(listener);
|
||||
}
|
||||
}
|
||||
+30
-11
@@ -18,10 +18,12 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public enum VersionIconType {
|
||||
public enum GameInstanceIconType {
|
||||
DEFAULT("/assets/img/grass.png"),
|
||||
|
||||
GRASS("/assets/img/grass.png"),
|
||||
@@ -42,21 +44,38 @@ public enum VersionIconType {
|
||||
|
||||
// Please append new items at last
|
||||
|
||||
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
|
||||
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
|
||||
return switch (modLoaderType) {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGED -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
case CLEANROOM -> VersionIconType.CLEANROOM;
|
||||
default -> VersionIconType.COMMAND;
|
||||
case FORGE -> GameInstanceIconType.FORGE;
|
||||
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||
case FABRIC -> GameInstanceIconType.FABRIC;
|
||||
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
|
||||
case QUILT -> GameInstanceIconType.QUILT;
|
||||
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
|
||||
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||
default -> GameInstanceIconType.COMMAND;
|
||||
};
|
||||
}
|
||||
|
||||
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
|
||||
return switch (componentType) {
|
||||
case GAME -> GameInstanceIconType.GRASS;
|
||||
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
|
||||
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
|
||||
case FORGE -> GameInstanceIconType.FORGE;
|
||||
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||
case LITELOADER -> GameInstanceIconType.CHICKEN;
|
||||
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
|
||||
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
|
||||
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private final String resourceUrl;
|
||||
|
||||
VersionIconType(String resourceUrl) {
|
||||
GameInstanceIconType(String resourceUrl) {
|
||||
this.resourceUrl = resourceUrl;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game settings preset.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameSettingsPresetID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameSettingsPresetID(UUID uuid) implements TypedID {
|
||||
/// The serialized game settings preset ID prefix.
|
||||
public static final String PREFIX = "game-settings-preset";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameSettingsPresetID NIL = new GameSettingsPresetID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game settings preset ID.
|
||||
public GameSettingsPresetID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game settings preset ID from a prefixed UUID string.
|
||||
public static GameSettingsPresetID parse(String value) {
|
||||
return new GameSettingsPresetID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game settings preset ID.
|
||||
public static GameSettingsPresetID generate() {
|
||||
return new GameSettingsPresetID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game settings preset ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameSettingsPresetID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameSettingsPresetID> {
|
||||
/// Creates a game settings preset ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameSettingsPresetID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores reusable game settings presets independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved as `config/game-settings.json` under the current HMCL
|
||||
/// directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameSettingsPresets.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game settings preset store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game settings preset store.
|
||||
public GameSettingsPresets() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Copies another preset store into this instance.
|
||||
void copyFrom(GameSettingsPresets source) {
|
||||
if (source == this) {
|
||||
return;
|
||||
}
|
||||
|
||||
presets.setAll(source.getPresets());
|
||||
}
|
||||
|
||||
/// The schema used by this game settings preset store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game settings preset store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game settings preset store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this preset store may be saved back to `config/game-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Reusable game setting presets.
|
||||
@SerializedName("presets")
|
||||
private final ObservableList<GameSettings.Preset> presets =
|
||||
FXCollections.observableArrayList(setting -> new Observable[] { setting });
|
||||
|
||||
/// Returns the reusable game setting presets.
|
||||
public ObservableList<GameSettings.Preset> getPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
/// Creates a preset ID that does not collide with existing presets.
|
||||
public GameSettingsPresetID newPresetId() {
|
||||
GameSettingsPresetID id;
|
||||
do {
|
||||
id = GameSettingsPresetID.generate();
|
||||
} while (getPreset(id) != null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Returns the first automatic preset number that is not used by existing presets.
|
||||
public int newPresetAutoNameNumber() {
|
||||
mainLoop:
|
||||
for (int index = 1; ; index++) {
|
||||
for (GameSettings.Preset setting : getPresets()) {
|
||||
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
|
||||
if ((autoNameNumber != null && autoNameNumber == index)) {
|
||||
continue mainLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the preset with the given ID.
|
||||
public GameSettings.@Nullable Preset getPreset(@Nullable GameSettingsPresetID id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (GameSettings.Preset setting : presets) {
|
||||
if (id.equals(setting.idProperty().getValue())) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameSettingsPresets].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameSettingsPresets> {
|
||||
/// Creates an empty preset store for deserialization.
|
||||
@Override
|
||||
protected GameSettingsPresets createInstance() {
|
||||
return new GameSettingsPresets();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/// The initial display mode of the game window.
|
||||
///
|
||||
/// @author Glavo
|
||||
public enum GameWindowType {
|
||||
/// Start the game in a normal window.
|
||||
WINDOWED,
|
||||
|
||||
/// Start the game in fullscreen mode.
|
||||
FULLSCREEN,
|
||||
|
||||
/// Start the game in a maximized window.
|
||||
MAXIMIZED,
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(GlobalConfig.Adapter.class)
|
||||
public final class GlobalConfig extends ObservableSetting {
|
||||
|
||||
@Nullable
|
||||
public static GlobalConfig fromJson(String json) throws JsonParseException {
|
||||
return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
|
||||
}
|
||||
|
||||
public GlobalConfig() {
|
||||
register();
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return Config.CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
@SerializedName("agreementVersion")
|
||||
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty agreementVersionProperty() {
|
||||
return agreementVersion;
|
||||
}
|
||||
|
||||
public int getAgreementVersion() {
|
||||
return agreementVersion.get();
|
||||
}
|
||||
|
||||
public void setAgreementVersion(int agreementVersion) {
|
||||
this.agreementVersion.set(agreementVersion);
|
||||
}
|
||||
|
||||
@SerializedName("terracottaAgreementVersion")
|
||||
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty terracottaAgreementVersionProperty() {
|
||||
return terracottaAgreementVersion;
|
||||
}
|
||||
|
||||
public int getTerracottaAgreementVersion() {
|
||||
return terracottaAgreementVersion.get();
|
||||
}
|
||||
|
||||
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
|
||||
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
|
||||
}
|
||||
|
||||
@SerializedName("platformPromptVersion")
|
||||
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty platformPromptVersionProperty() {
|
||||
return platformPromptVersion;
|
||||
}
|
||||
|
||||
public int getPlatformPromptVersion() {
|
||||
return platformPromptVersion.get();
|
||||
}
|
||||
|
||||
public void setPlatformPromptVersion(int platformPromptVersion) {
|
||||
this.platformPromptVersion.set(platformPromptVersion);
|
||||
}
|
||||
|
||||
@SerializedName("logRetention")
|
||||
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
|
||||
|
||||
public IntegerProperty logRetentionProperty() {
|
||||
return logRetention;
|
||||
}
|
||||
|
||||
public int getLogRetention() {
|
||||
return logRetention.get();
|
||||
}
|
||||
|
||||
public void setLogRetention(int logRetention) {
|
||||
this.logRetention.set(logRetention);
|
||||
}
|
||||
|
||||
@SerializedName("enableOfflineAccount")
|
||||
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty enableOfflineAccountProperty() {
|
||||
return enableOfflineAccount;
|
||||
}
|
||||
|
||||
public boolean isEnableOfflineAccount() {
|
||||
return enableOfflineAccount.get();
|
||||
}
|
||||
|
||||
public void setEnableOfflineAccount(boolean value) {
|
||||
enableOfflineAccount.set(value);
|
||||
}
|
||||
|
||||
@SerializedName("fontAntiAliasing")
|
||||
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
|
||||
|
||||
public StringProperty fontAntiAliasingProperty() {
|
||||
return fontAntiAliasing;
|
||||
}
|
||||
|
||||
public String getFontAntiAliasing() {
|
||||
return fontAntiAliasing.get();
|
||||
}
|
||||
|
||||
public void setFontAntiAliasing(String value) {
|
||||
this.fontAntiAliasing.set(value);
|
||||
}
|
||||
|
||||
@SerializedName("userJava")
|
||||
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
public ObservableSet<String> getUserJava() {
|
||||
return userJava;
|
||||
}
|
||||
|
||||
@SerializedName("disabledJava")
|
||||
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
public ObservableSet<String> getDisabledJava() {
|
||||
return disabledJava;
|
||||
}
|
||||
|
||||
static final class Adapter extends ObservableSetting.Adapter<GlobalConfig> {
|
||||
@Override
|
||||
protected GlobalConfig createInstance() {
|
||||
return new GlobalConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,27 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Selects how HMCL resolves the Java runtime used to launch a game.
|
||||
///
|
||||
/// Additional payload for these modes is stored in `GameSettings`:
|
||||
/// - [VERSION] uses `customJavaVersion` as the user-entered Java version selector.
|
||||
/// - [DETECTED] uses `detectedJava` as the detected runtime reference.
|
||||
/// - [CUSTOM] uses `customJavaPath` as the executable path selected by the user.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum JavaVersionType {
|
||||
DEFAULT, AUTO, VERSION, DETECTED, CUSTOM
|
||||
/// Automatically selects a suitable Java runtime for the target game version.
|
||||
AUTO,
|
||||
|
||||
/// Selects an installed Java runtime by major version.
|
||||
VERSION,
|
||||
|
||||
/// Selects one runtime from the Java runtimes detected by HMCL.
|
||||
DETECTED,
|
||||
|
||||
/// Uses a user-provided Java executable path.
|
||||
CUSTOM
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Marks a detached settings object that stores its own JSON schema URL.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
interface JsonSchemaSetting {
|
||||
/// Returns the schema used by this JSON settings file.
|
||||
JsonSchema getSchema();
|
||||
|
||||
/// Sets the schema used by this JSON settings file.
|
||||
///
|
||||
/// @param schema the schema to store
|
||||
void setSchema(JsonSchema schema);
|
||||
|
||||
/// Returns whether this settings object may be saved back to its JSON file.
|
||||
boolean isSavable();
|
||||
|
||||
/// Sets whether this settings object may be saved back to its JSON file.
|
||||
///
|
||||
/// @param savable whether this settings object may be saved
|
||||
void setSavable(boolean savable);
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
boolean isBackupOnNextSave();
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
///
|
||||
/// @param backupOnNextSave whether the next successful save should back up the current on-disk file first
|
||||
void setBackupOnNextSave(boolean backupOnNextSave);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Loads, saves, and validates a detached JSON settings file with a [JsonSchema] marker.
|
||||
///
|
||||
/// @param <T> the settings object type
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
|
||||
/// The settings file location.
|
||||
private final Path location;
|
||||
|
||||
/// The human-readable file type name used in logs.
|
||||
private final String displayName;
|
||||
|
||||
/// The settings object type.
|
||||
private final Class<T> type;
|
||||
|
||||
/// The JSON schema supported by the current code.
|
||||
private final JsonSchema expectedSchema;
|
||||
|
||||
/// Creates a default settings object.
|
||||
private final Supplier<T> createDefault;
|
||||
|
||||
/// Creates a detached JSON settings file helper.
|
||||
///
|
||||
/// @param location the settings file location
|
||||
/// @param displayName the human-readable file type name used in logs
|
||||
/// @param type the settings object type
|
||||
/// @param expectedSchema the JSON schema supported by the current code
|
||||
/// @param createDefault creates a default settings object
|
||||
JsonSettingFile(
|
||||
Path location,
|
||||
String displayName,
|
||||
Class<T> type,
|
||||
JsonSchema expectedSchema,
|
||||
Supplier<T> createDefault) {
|
||||
this.location = Objects.requireNonNull(location);
|
||||
this.displayName = Objects.requireNonNull(displayName);
|
||||
this.type = Objects.requireNonNull(type);
|
||||
this.expectedSchema = Objects.requireNonNull(expectedSchema);
|
||||
this.createDefault = Objects.requireNonNull(createDefault);
|
||||
}
|
||||
|
||||
/// Loads the settings file, falling back to migrated data or a default object when absent.
|
||||
///
|
||||
/// @param migrated migrated settings data used when the file is absent
|
||||
/// @return the loaded settings object
|
||||
/// @throws IOException if reading the file fails
|
||||
LoadResult<T> load(@Nullable T migrated) throws IOException {
|
||||
if (Files.exists(location)) {
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(location, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning(displayName + " are empty: " + location);
|
||||
return result(createDefault.get(), true);
|
||||
} else {
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, expectedSchema);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in " + displayName + ": " + location);
|
||||
case INVALID -> LOG.warning("Invalid schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.invalidValue());
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.actual());
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected " + displayName + " schema. Expected: "
|
||||
+ expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported " + displayName
|
||||
+ " schema. Expected: " + expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
T deserialized = LauncherSettings.SETTINGS_GSON.<@Nullable T>fromJson(jsonObject, type);
|
||||
if (deserialized != null) {
|
||||
// Patch-compatible files keep their original schema because unknown members are preserved.
|
||||
if (!schemaResult.preserveSchema() && !expectedSchema.equals(deserialized.getSchema())) {
|
||||
deserialized.setSchema(expectedSchema);
|
||||
}
|
||||
|
||||
return result(deserialized, schemaResult.allowSave()
|
||||
? SettingFileAccess.READ_WRITE
|
||||
: SettingFileAccess.READ_ONLY);
|
||||
}
|
||||
|
||||
LOG.warning(displayName + " deserialized to null: " + location);
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed " + displayName + ".", e);
|
||||
return result(createDefault.get(), true, true);
|
||||
}
|
||||
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
return result(Objects.requireNonNullElseGet(migrated, createDefault), true);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable) {
|
||||
return result(value, savable, false);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable, boolean backupOnNextSave) {
|
||||
value.setSavable(savable);
|
||||
value.setBackupOnNextSave(backupOnNextSave);
|
||||
return new LoadResult<>(value, SettingFileAccess.READ_WRITE);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the access metadata on the settings object.
|
||||
private LoadResult<T> result(T value, SettingFileAccess access) {
|
||||
value.setSavable(access.canSave());
|
||||
value.setBackupOnNextSave(false);
|
||||
return new LoadResult<>(value, access);
|
||||
}
|
||||
|
||||
/// Installs an automatic save listener on a settings object.
|
||||
///
|
||||
/// @param value the settings object to observe
|
||||
void installAutoSave(T value) {
|
||||
value.addListener(observable -> {
|
||||
if (value.shouldSaveImmediately(observable)) {
|
||||
save(value);
|
||||
value.setSavePending(false);
|
||||
} else {
|
||||
value.setSavePending(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Saves a settings object.
|
||||
///
|
||||
/// @param value the settings object to save
|
||||
void save(T value) {
|
||||
if (value.isBackupOnNextSave()) {
|
||||
value.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
}
|
||||
FileSaver.save(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
|
||||
}
|
||||
|
||||
/// Saves a settings object synchronously.
|
||||
///
|
||||
/// @param value the settings object to save
|
||||
/// @throws IOException if saving the file fails
|
||||
void saveSync(T value) throws IOException {
|
||||
if (value.isBackupOnNextSave()) {
|
||||
value.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
}
|
||||
FileUtils.saveSafely(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
|
||||
}
|
||||
|
||||
/// Prepares a settings object for overwriting a detached settings file.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
private void prepareOverwrite(T value) {
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
value.setSchema(expectedSchema);
|
||||
value.setSavable(true);
|
||||
value.setBackupOnNextSave(false);
|
||||
}
|
||||
|
||||
/// Backs up the current file and overwrites it with the given value using the current schema.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
void backupAndOverwrite(T value) {
|
||||
prepareOverwrite(value);
|
||||
save(value);
|
||||
}
|
||||
|
||||
/// Backs up the current file and overwrites it synchronously with the given value using the current schema.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
/// @throws IOException if saving the file fails
|
||||
void backupAndOverwriteSync(T value) throws IOException {
|
||||
prepareOverwrite(value);
|
||||
saveSync(value);
|
||||
}
|
||||
|
||||
/// Result of loading a detached JSON settings file.
|
||||
///
|
||||
/// @param value the loaded settings object
|
||||
/// @param access whether the source file may be read and overwritten
|
||||
record LoadResult<T extends ObservableSetting & JsonSchemaSetting>(T value, SettingFileAccess access) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
|
||||
import org.jackhuang.hmcl.theme.BuiltinBackground;
|
||||
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.ThemeReference;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/// Stores the current workspace's main launcher settings.
|
||||
///
|
||||
/// This file keeps launcher-level choices such as UI preferences, network settings, selected game directory,
|
||||
/// selected instances, and account selection. Larger domain-specific stores, such as game directories,
|
||||
/// game settings presets, accounts, launcher state, and authlib-injector servers, are persisted in detached
|
||||
/// JSON files managed by [SettingsManager].
|
||||
@JsonAdapter(value = LauncherSettings.Adapter.class)
|
||||
@NotNullByDefault
|
||||
public final class LauncherSettings extends ObservableSetting implements JsonSchemaSetting {
|
||||
|
||||
/// The JSON schema supported by this launcher settings class.
|
||||
public static final JsonSchema CURRENT_SCHEMA = new JsonSchema("launcher-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// The JSON property name for the default game setting preset ID.
|
||||
static final String PROPERTY_DEFAULT_GAME_SETTINGS_PRESET = "defaultGameSettingsPreset";
|
||||
|
||||
/// The JSON property name for the selected game directory ID.
|
||||
static final String PROPERTY_SELECTED_GAME_DIRECTORY = "selectedGameDirectory";
|
||||
|
||||
/// The JSON property name for selected instance IDs keyed by game directory ID.
|
||||
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
|
||||
|
||||
/// Default launcher theme used when no stored theme reference is available.
|
||||
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
|
||||
|
||||
/// Theme appearance override key for theme brightness mode.
|
||||
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
|
||||
|
||||
/// Theme appearance override key for theme color seed.
|
||||
public static final String THEME_APPEARANCE_COLOR = "themeColor";
|
||||
|
||||
/// Theme appearance override key for theme color style.
|
||||
public static final String THEME_APPEARANCE_COLOR_STYLE = "themeColorStyle";
|
||||
|
||||
/// Theme appearance override key for title-bar transparency.
|
||||
public static final String THEME_APPEARANCE_TITLE_BAR_TRANSPARENT = "titleBarTransparent";
|
||||
|
||||
/// Theme appearance override key for window transparency.
|
||||
public static final String THEME_APPEARANCE_WINDOW_TRANSPARENT = "windowTransparent";
|
||||
|
||||
/// Theme appearance override key for the primary background source.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND = "background";
|
||||
|
||||
/// Theme appearance override key for background opacity.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND_OPACITY = "backgroundOpacity";
|
||||
|
||||
/// Gson instance used for launcher settings and related settings objects that depend on JavaFX properties.
|
||||
public static final Gson SETTINGS_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
/// Deserializes launcher settings from JSON.
|
||||
///
|
||||
/// @param json the JSON object to read
|
||||
/// @return the deserialized launcher settings
|
||||
/// @throws JsonParseException if the JSON cannot be deserialized as launcher settings
|
||||
public static LauncherSettings fromJson(JsonObject json) throws JsonParseException {
|
||||
return SETTINGS_GSON.fromJson(json, LauncherSettings.class);
|
||||
}
|
||||
|
||||
/// Creates empty launcher settings using current defaults.
|
||||
public LauncherSettings() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Serializes these launcher settings to formatted JSON.
|
||||
public String toJson() {
|
||||
return SETTINGS_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
/// The schema used by this launcher settings file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher settings file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher settings file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
private transient boolean backupOnNextSave = false;
|
||||
|
||||
/// Returns whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The launcher UI language.
|
||||
@SerializedName("language")
|
||||
private final ObjectProperty<SupportedLocale> language = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
/// Returns the launcher UI language property.
|
||||
public ObjectProperty<SupportedLocale> languageProperty() {
|
||||
return language;
|
||||
}
|
||||
|
||||
/// Whether preview builds are accepted by update checks.
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the preview update opt-in property.
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
/// Whether automatic update dialogs are disabled.
|
||||
@SerializedName("disableAutoShowUpdateDialog")
|
||||
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the automatic update dialog disable property.
|
||||
public BooleanProperty disableAutoShowUpdateDialogProperty() {
|
||||
return disableAutoShowUpdateDialog;
|
||||
}
|
||||
|
||||
/// Whether April Fools features are disabled.
|
||||
@SerializedName("disableAprilFools")
|
||||
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the April Fools disable property.
|
||||
public BooleanProperty disableAprilFoolsProperty() {
|
||||
return disableAprilFools;
|
||||
}
|
||||
|
||||
/// The common Minecraft directory selection mode.
|
||||
@SerializedName("commonDirectoryType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
/// Returns the common Minecraft directory selection mode property.
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirectoryTypeProperty() {
|
||||
return commonDirectoryType;
|
||||
}
|
||||
|
||||
/// The custom common Minecraft directory path.
|
||||
@SerializedName("commonDirectory")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty();
|
||||
|
||||
/// Returns the custom common Minecraft directory property.
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
/// Returns the default common Minecraft directory path.
|
||||
public static String getDefaultCommonDirectory() {
|
||||
return Metadata.MINECRAFT_DIRECTORY.toString();
|
||||
}
|
||||
|
||||
/// Resolves the effective common Minecraft directory from the current directory settings.
|
||||
///
|
||||
/// @return the effective directory path, or `null` when the configured mode is not recognized
|
||||
public String getResolvedCommonDirectory() {
|
||||
EnumCommonDirectory type = commonDirectoryType.get();
|
||||
String customPath = commonDirectory.get();
|
||||
|
||||
return type == EnumCommonDirectory.CUSTOM && StringUtils.isNotBlank(customPath)
|
||||
? customPath
|
||||
: getDefaultCommonDirectory();
|
||||
}
|
||||
|
||||
/// The maximum number of log lines kept in log views.
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<@Nullable Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the log line limit property.
|
||||
public ObjectProperty<@Nullable Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
// Theme selection
|
||||
|
||||
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
|
||||
@SerializedName("selectedTheme")
|
||||
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
|
||||
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
|
||||
|
||||
/// Returns the selected installed theme property.
|
||||
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
/// Returns the selected installed theme, falling back to the built-in default theme.
|
||||
public ThemeReference getSelectedThemeOrDefault() {
|
||||
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
|
||||
}
|
||||
|
||||
// Theme appearance overrides
|
||||
|
||||
/// Theme appearance setting keys overridden by the launcher.
|
||||
@SerializedName("themeAppearanceOverrides")
|
||||
private final ObservableSet<String> themeAppearanceOverrides = FXCollections.observableSet();
|
||||
|
||||
/// Returns the theme appearance setting keys overridden by the launcher.
|
||||
public ObservableSet<String> getThemeAppearanceOverrides() {
|
||||
return themeAppearanceOverrides;
|
||||
}
|
||||
|
||||
// Theme appearance values
|
||||
|
||||
/// The configured theme brightness mode identifier.
|
||||
@SerializedName("themeBrightnessMode")
|
||||
private final StringProperty themeBrightnessMode = new SimpleStringProperty("auto");
|
||||
|
||||
/// Returns the theme brightness mode property.
|
||||
public StringProperty themeBrightnessModeProperty() {
|
||||
return themeBrightnessMode;
|
||||
}
|
||||
|
||||
/// The custom launcher theme color preserved when dynamic color extraction is used.
|
||||
@SerializedName("customThemeColor")
|
||||
private final ObjectProperty<ThemeColor> customThemeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
/// Returns the custom launcher theme color property.
|
||||
public ObjectProperty<ThemeColor> customThemeColorProperty() {
|
||||
return customThemeColor;
|
||||
}
|
||||
|
||||
/// The source used to choose the launcher Monet theme color seed.
|
||||
@SerializedName("themeColorType")
|
||||
private final ObjectProperty<ThemeColorType> themeColorType = new RawPreservingObjectProperty<>(ThemeColorType.DEFAULT);
|
||||
|
||||
/// Returns the launcher theme color source type property.
|
||||
public ObjectProperty<ThemeColorType> themeColorTypeProperty() {
|
||||
return themeColorType;
|
||||
}
|
||||
|
||||
/// The MonetFX color style used to generate the launcher color scheme.
|
||||
@SerializedName("themeColorStyle")
|
||||
private final ObjectProperty<ColorStyle> themeColorStyle = new RawPreservingObjectProperty<>(ColorStyle.FIDELITY);
|
||||
|
||||
/// Returns the launcher theme color style property.
|
||||
public ObjectProperty<ColorStyle> themeColorStyleProperty() {
|
||||
return themeColorStyle;
|
||||
}
|
||||
|
||||
/// Whether the launcher title bar is transparent.
|
||||
@SerializedName("titleBarTransparent")
|
||||
private final BooleanProperty titleBarTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the transparent title-bar property.
|
||||
public BooleanProperty titleBarTransparentProperty() {
|
||||
return titleBarTransparent;
|
||||
}
|
||||
|
||||
/// Whether translucent launcher backgrounds reveal content behind the window.
|
||||
@SerializedName("windowTransparent")
|
||||
private final BooleanProperty windowTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the window transparency property.
|
||||
public BooleanProperty windowTransparentProperty() {
|
||||
return windowTransparent;
|
||||
}
|
||||
|
||||
// Background source
|
||||
|
||||
/// The launcher background source type.
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<BackgroundType> backgroundType = new RawPreservingObjectProperty<>(BackgroundType.DEFAULT);
|
||||
|
||||
/// Returns the launcher background source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundTypeProperty() {
|
||||
return backgroundType;
|
||||
}
|
||||
|
||||
/// The selected built-in launcher wallpaper ID.
|
||||
@SerializedName("builtinBackgroundId")
|
||||
private final StringProperty builtinBackgroundId = new SimpleStringProperty(BuiltinBackground.FALLBACK.id());
|
||||
|
||||
/// Returns the selected built-in launcher wallpaper ID property.
|
||||
public StringProperty builtinBackgroundIdProperty() {
|
||||
return builtinBackgroundId;
|
||||
}
|
||||
|
||||
/// The local custom launcher background image path.
|
||||
@SerializedName("customBackgroundImagePath")
|
||||
private final StringProperty customBackgroundImagePath = new SimpleStringProperty();
|
||||
|
||||
/// Returns the local custom launcher background image path property.
|
||||
public StringProperty customBackgroundImagePathProperty() {
|
||||
return customBackgroundImagePath;
|
||||
}
|
||||
|
||||
/// The remote network launcher background image URL.
|
||||
@SerializedName("networkBackgroundImageUrl")
|
||||
private final StringProperty networkBackgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
/// Returns the remote network launcher background image URL property.
|
||||
public StringProperty networkBackgroundImageUrlProperty() {
|
||||
return networkBackgroundImageUrl;
|
||||
}
|
||||
|
||||
/// The custom launcher background paint.
|
||||
@SerializedName("customBackgroundPaint")
|
||||
private final ObjectProperty<@Nullable Paint> customBackgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the custom launcher background paint property.
|
||||
public ObjectProperty<@Nullable Paint> customBackgroundPaintProperty() {
|
||||
return customBackgroundPaint;
|
||||
}
|
||||
|
||||
// Background appearance
|
||||
|
||||
/// The launcher background opacity value.
|
||||
@SerializedName("backgroundOpacity")
|
||||
private final DoubleProperty backgroundOpacity = new SimpleDoubleProperty(1.0);
|
||||
|
||||
/// Returns the custom launcher background opacity value property.
|
||||
public DoubleProperty backgroundOpacityProperty() {
|
||||
return backgroundOpacity;
|
||||
}
|
||||
|
||||
// Background loading
|
||||
|
||||
/// The URL image cache policy for network launcher backgrounds.
|
||||
@SerializedName("networkBackgroundImageCachePolicy")
|
||||
private final ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicy =
|
||||
new RawPreservingObjectProperty<>(NetworkBackgroundImageCachePolicy.ENABLED);
|
||||
|
||||
/// Returns the URL image cache policy for network launcher backgrounds.
|
||||
public ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicyProperty() {
|
||||
return networkBackgroundImageCachePolicy;
|
||||
}
|
||||
|
||||
/// The fallback source used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackType")
|
||||
private final ObjectProperty<BackgroundType> backgroundFallbackType =
|
||||
new RawPreservingObjectProperty<>(BackgroundType.BUILTIN);
|
||||
|
||||
/// Returns the launcher background fallback source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundFallbackTypeProperty() {
|
||||
return backgroundFallbackType;
|
||||
}
|
||||
|
||||
/// The fallback paint used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackPaint")
|
||||
private final ObjectProperty<Paint> backgroundFallbackPaint = new SimpleObjectProperty<>(Color.WHITE);
|
||||
|
||||
/// Returns the launcher background fallback paint property.
|
||||
public ObjectProperty<Paint> backgroundFallbackPaintProperty() {
|
||||
return backgroundFallbackPaint;
|
||||
}
|
||||
|
||||
/// How the launcher displays its window while the selected background is loading.
|
||||
@SerializedName("backgroundLoadPolicy")
|
||||
private final ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicy =
|
||||
new RawPreservingObjectProperty<>(BackgroundLoadPolicy.WAIT_FOR_BACKGROUND);
|
||||
|
||||
/// Returns the launcher background loading policy property.
|
||||
public ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicyProperty() {
|
||||
return backgroundLoadPolicy;
|
||||
}
|
||||
|
||||
// Fonts
|
||||
|
||||
/// The font family used by launcher log views.
|
||||
@SerializedName("logFontFamily")
|
||||
private final StringProperty logFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher log font family property.
|
||||
public StringProperty logFontFamilyProperty() {
|
||||
return logFontFamily;
|
||||
}
|
||||
|
||||
/// The launcher log font size.
|
||||
@SerializedName("logFontSize")
|
||||
private final DoubleProperty logFontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
/// Returns the launcher log font size property.
|
||||
public DoubleProperty logFontSizeProperty() {
|
||||
return logFontSize;
|
||||
}
|
||||
|
||||
/// The font family used by launcher chrome.
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher chrome font family property.
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
// General UI
|
||||
|
||||
/// Whether UI animations are disabled.
|
||||
@SerializedName("animationDisabled")
|
||||
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the UI animation disable property.
|
||||
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
// Avoid accessing FXUtils too early during startup
|
||||
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
/// Whether HMCL automatically selects the number of download threads.
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
/// Returns the automatic download thread count property.
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
/// The configured number of download threads.
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
/// Returns the download thread count property.
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
/// The selected game version list download source.
|
||||
@SerializedName("versionListSource")
|
||||
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected game version list download source property.
|
||||
public ObjectProperty<DownloadSource> versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
/// The selected file download source.
|
||||
@SerializedName("fileDownloadSource")
|
||||
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected file download source property.
|
||||
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
|
||||
return fileDownloadSource;
|
||||
}
|
||||
|
||||
/// The selected default add-on source ID.
|
||||
@SerializedName("defaultAddonSource")
|
||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
||||
|
||||
/// Returns the selected default add-on source ID property.
|
||||
public StringProperty defaultAddonSourceProperty() {
|
||||
return defaultAddonSource;
|
||||
}
|
||||
|
||||
/// Whether proxy authentication is enabled.
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
/// Returns the proxy authentication enable property.
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
/// The configured network proxy selection mode.
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<ProxyType> proxyType = new SimpleObjectProperty<>(ProxyType.SYSTEM);
|
||||
|
||||
/// Returns the network proxy selection mode property.
|
||||
public ObjectProperty<ProxyType> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
/// The configured network proxy host.
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
/// Returns the network proxy host property.
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
/// The configured network proxy port.
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the network proxy port property.
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication username.
|
||||
@SerializedName("proxyUser")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication username property.
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication password.
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPassword = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication password property.
|
||||
public StringProperty proxyPasswordProperty() {
|
||||
return proxyPassword;
|
||||
}
|
||||
|
||||
/// The selected game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_GAME_DIRECTORY)
|
||||
private final ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectory =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_SELECTED_GAME_DIRECTORY);
|
||||
|
||||
/// Returns the selected game directory ID property.
|
||||
///
|
||||
/// This property is exposed for persistence and [GameDirectoryManager] integration. Code outside [GameDirectoryManager]
|
||||
/// should use `GameDirectoryManager.setSelectedGameDirectory` instead of modifying this property directly.
|
||||
public ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// The default game setting preset ID.
|
||||
@SerializedName(PROPERTY_DEFAULT_GAME_SETTINGS_PRESET)
|
||||
private final ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPreset =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_DEFAULT_GAME_SETTINGS_PRESET);
|
||||
|
||||
/// Returns the default game setting preset ID property.
|
||||
public ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPresetProperty() {
|
||||
return defaultGameSettingsPreset;
|
||||
}
|
||||
|
||||
/// Selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_INSTANCE)
|
||||
private final ObservableMap<GameDirectoryID, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// The map stores persisted selected instance values by game directory ID.
|
||||
public ObservableMap<GameDirectoryID, GameInstanceID> getSelectedInstance() {
|
||||
return selectedInstance;
|
||||
}
|
||||
|
||||
/// Returns the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// The value is loaded by the game repository for the matching game directory.
|
||||
public @Nullable GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
|
||||
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
|
||||
}
|
||||
|
||||
/// Sets the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// Blank values remove the persisted selected instance entry.
|
||||
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable GameInstanceID selectedInstance) {
|
||||
if (gameDirectoryId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedInstance != null) {
|
||||
this.selectedInstance.put(gameDirectoryId, selectedInstance);
|
||||
} else {
|
||||
this.selectedInstance.remove(gameDirectoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
/// The preferred login type to use when the user wants to add an account.
|
||||
@SerializedName("preferredLoginType")
|
||||
private final StringProperty preferredLoginType = new SimpleStringProperty();
|
||||
|
||||
/// Returns the preferred login type property.
|
||||
public StringProperty preferredLoginTypeProperty() {
|
||||
return preferredLoginType;
|
||||
}
|
||||
|
||||
/// The selected account reference.
|
||||
@SerializedName("selectedAccount")
|
||||
private final ObjectProperty<@Nullable AccountID> selectedAccount = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the selected account reference property.
|
||||
public ObjectProperty<@Nullable AccountID> selectedAccountProperty() {
|
||||
return selectedAccount;
|
||||
}
|
||||
|
||||
/// JSON adapter for [LauncherSettings].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<LauncherSettings> {
|
||||
/// Creates empty launcher settings for deserialization.
|
||||
@Override
|
||||
protected LauncherSettings createInstance() {
|
||||
return new LauncherSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace launcher runtime state independently from the main settings file.
|
||||
///
|
||||
/// The JSON representation is saved as `state/launcher-state.json` under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(LauncherState.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class LauncherState extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this launcher state store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("launcher-state", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty launcher state store.
|
||||
public LauncherState() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Returns whether the changed field should trigger automatic persistence immediately.
|
||||
@Override
|
||||
public boolean shouldSaveImmediately(Observable observable) {
|
||||
return observable != x
|
||||
&& observable != y
|
||||
&& observable != width
|
||||
&& observable != height;
|
||||
}
|
||||
|
||||
/// The schema used by this launcher state file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher state file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher state file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The normalized launcher window content X position.
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content X position property.
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content X position.
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content X position.
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
/// The normalized launcher window content Y position.
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content Y position property.
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content Y position.
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content Y position.
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
/// The launcher window content width.
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content width property.
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content width.
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content width.
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
/// The launcher window content height.
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content height property.
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content height.
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content height.
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
/// The latest update version that has already prompted the user.
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
/// Returns the latest prompted update version.
|
||||
public @Nullable String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
/// Returns the latest prompted update version property.
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
/// Sets the latest prompted update version.
|
||||
public void setPromptedVersion(@Nullable String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
/// Tip markers that prevent repeated prompts.
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns tip markers that prevent repeated prompts.
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
/// JSON adapter for [LauncherState].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<LauncherState> {
|
||||
/// Creates an empty launcher state store for deserialization.
|
||||
@Override
|
||||
protected LauncherState createInstance() {
|
||||
return new LauncherState();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,557 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.property.InheritableProperty;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
import org.jetbrains.annotations.VisibleForTesting;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Converts legacy game settings JSON into `GameSettings` models.
|
||||
@NotNullByDefault
|
||||
public final class LegacyGameSettingsMigrator {
|
||||
/// Legacy file name used by old per-version `VersionSetting` data.
|
||||
static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg";
|
||||
|
||||
/// Receipt file name used to record successful legacy per-version settings migration.
|
||||
private static final String LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME =
|
||||
"instance-game-settings.migration-receipt.json";
|
||||
|
||||
/// Directory under the version root that stores HMCL-managed instance metadata.
|
||||
static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||
static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance state files.
|
||||
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||
|
||||
/// Current file name for instance-specific game settings.
|
||||
static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||
|
||||
/// Legacy game directory modes stored by old configuration files.
|
||||
private enum GameDirectoryType {
|
||||
/// Use the root `.minecraft` folder.
|
||||
ROOT_FOLDER,
|
||||
|
||||
/// Use the version-specific folder.
|
||||
VERSION_FOLDER,
|
||||
|
||||
/// Use a custom game directory.
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
/// Legacy `VersionIconType` ordinal order used by old local settings.
|
||||
private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
|
||||
GameInstanceIconType.DEFAULT,
|
||||
GameInstanceIconType.GRASS,
|
||||
GameInstanceIconType.CHEST,
|
||||
GameInstanceIconType.CHICKEN,
|
||||
GameInstanceIconType.COMMAND,
|
||||
GameInstanceIconType.OPTIFINE,
|
||||
GameInstanceIconType.CRAFT_TABLE,
|
||||
GameInstanceIconType.FABRIC,
|
||||
GameInstanceIconType.FORGE,
|
||||
GameInstanceIconType.NEO_FORGE,
|
||||
GameInstanceIconType.FURNACE,
|
||||
GameInstanceIconType.QUILT,
|
||||
GameInstanceIconType.APRIL_FOOLS,
|
||||
GameInstanceIconType.CLEANROOM,
|
||||
GameInstanceIconType.LEGACY_FABRIC
|
||||
};
|
||||
|
||||
/// Legacy `JavaVersionType` ordinal order used by old game settings.
|
||||
private static final JavaVersionType @Unmodifiable [] LEGACY_JAVA_VERSION_TYPES = {
|
||||
// Legacy ordinal 0 was DEFAULT and ordinal 1 was AUTO; both now migrate to AUTO.
|
||||
JavaVersionType.AUTO,
|
||||
JavaVersionType.AUTO,
|
||||
JavaVersionType.VERSION,
|
||||
JavaVersionType.DETECTED,
|
||||
JavaVersionType.CUSTOM
|
||||
};
|
||||
|
||||
/// Legacy native library mode ordinal order used by old game settings.
|
||||
private static final String @Unmodifiable [] LEGACY_NATIVES_DIRECTORY_TYPES = {
|
||||
"VERSION_FOLDER",
|
||||
"CUSTOM"
|
||||
};
|
||||
|
||||
/// Prevents instantiation.
|
||||
private LegacyGameSettingsMigrator() {
|
||||
}
|
||||
|
||||
/// Converts a legacy profile-level setting JSON object into a preset with the given ID.
|
||||
public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoNameNumber, @Nullable JsonObject source) {
|
||||
GameSettings.Preset target = new GameSettings.Preset(id);
|
||||
target.autoNameNumberProperty().setValue(autoNameNumber);
|
||||
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.VERSION_FOLDER) {
|
||||
target.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS);
|
||||
}
|
||||
if (source != null) {
|
||||
copyCommonProperties(source, target, GameSettings.DetectedJava::ofLegacyPath);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/// Migrates a legacy per-version game setting file into an instance setting.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the profile
|
||||
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
|
||||
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
|
||||
HMCLGameRepository repository,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable GameSettingsPresetID parent) {
|
||||
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
|
||||
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
|
||||
if (!Files.exists(file)) {
|
||||
return null;
|
||||
}
|
||||
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
|
||||
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
|
||||
if (MigrationReceipt.matches(receiptLocation, file)) {
|
||||
LOG.info("Skipping already migrated legacy version setting " + file);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject legacySettingJson = JsonUtils.fromJsonFile(file, JsonObject.class);
|
||||
if (legacySettingJson == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean inheritsLegacyParent = JsonUtils.getBoolean(legacySettingJson, "usesGlobal", false);
|
||||
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
|
||||
GameSettings.Instance setting = toInstance(
|
||||
parent,
|
||||
parentSetting,
|
||||
legacySettingJson,
|
||||
inheritsLegacyParent,
|
||||
GameSettings.DetectedJava::ofLegacyPath);
|
||||
if (!inheritsLegacyParent) {
|
||||
Path baseDirectory = repository.getBaseDirectory();
|
||||
if (parentSetting != null
|
||||
&& getLegacyGameDirType(legacySettingJson, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.ROOT_FOLDER
|
||||
&& StringUtils.isNotBlank(parentSetting.runningDirectoryProperty().getValue())) {
|
||||
setting.runningDirectoryProperty().setValue(baseDirectory.toString());
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
return new InstanceMigrationResult(file, receiptLocation, setting);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to migrate legacy version setting " + file, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
public static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent) {
|
||||
return toInstance(parent, source, inheritsLegacyParent, GameSettings.DetectedJava::ofLegacyPath);
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting with a custom
|
||||
/// detected Java converter.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
|
||||
/// detected Java reference
|
||||
@VisibleForTesting
|
||||
static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
return toInstance(parent, null, source, inheritsLegacyParent, detectedJavaFactory);
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting with parent context.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param parentSetting the migrated parent preset, if available during migration
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
|
||||
/// detected Java reference
|
||||
@VisibleForTesting
|
||||
static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable GameSettings.Preset parentSetting,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
GameSettings.Instance target = new GameSettings.Instance();
|
||||
target.parentProperty().setValue(parent);
|
||||
target.iconProperty().setValue(parseLegacyVersionIconType(source));
|
||||
if (inheritsLegacyParent
|
||||
&& parentSetting != null
|
||||
&& parentSetting.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS
|
||||
&& StringUtils.isBlank(parentSetting.runningDirectoryProperty().getValue())) {
|
||||
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
if (source != null && !inheritsLegacyParent) {
|
||||
copyCommonProperties(source, target, detectedJavaFactory);
|
||||
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) != GameDirectoryType.ROOT_FOLDER) {
|
||||
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
target.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_JAVA_TYPE,
|
||||
GameSettings.PROPERTY_CUSTOM_JAVA_VERSION,
|
||||
GameSettings.PROPERTY_CUSTOM_JAVA_PATH,
|
||||
GameSettings.PROPERTY_DETECTED_JAVA,
|
||||
GameSettings.PROPERTY_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NO_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NO_OPTIMIZING_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NOT_CHECK_JVM,
|
||||
GameSettings.PROPERTY_NOT_CHECK_GAME,
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE,
|
||||
GameSettings.PROPERTY_WINDOW_TYPE,
|
||||
GameSettings.PROPERTY_WIDTH,
|
||||
GameSettings.PROPERTY_HEIGHT,
|
||||
GameSettings.PROPERTY_PROCESS_PRIORITY,
|
||||
GameSettings.PROPERTY_LAUNCHER_VISIBILITY,
|
||||
GameSettings.PROPERTY_GAME_ARGS,
|
||||
GameSettings.PROPERTY_GRAPHICS_BACKEND,
|
||||
GameSettings.PROPERTY_OPENGL_RENDERER,
|
||||
GameSettings.PROPERTY_VULKAN_RENDERER,
|
||||
GameSettings.PROPERTY_ENVIRONMENT_VARIABLES,
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND,
|
||||
GameSettings.PROPERTY_POST_EXIT_COMMAND,
|
||||
GameSettings.PROPERTY_QUICK_PLAY,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_MULTIPLAYER,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_SINGLEPLAYER,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_REALMS,
|
||||
GameSettings.PROPERTY_SHOW_LOGS,
|
||||
GameSettings.PROPERTY_ENABLE_DEBUG_LOG_OUTPUT,
|
||||
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
|
||||
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
|
||||
GameSettings.PROPERTY_NATIVES_DIRECTORY,
|
||||
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
|
||||
GameSettings.PROPERTY_USE_NATIVE_OPENAL
|
||||
));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/// Returns the legacy game directory type from a setting JSON object.
|
||||
private static GameDirectoryType getLegacyGameDirType(@Nullable JsonObject source, GameDirectoryType defaultValue) {
|
||||
return parseEnum(source, "gameDirType", GameDirectoryType.class, defaultValue);
|
||||
}
|
||||
|
||||
/// Copies shared legacy properties into the target setting.
|
||||
private static void copyCommonProperties(
|
||||
JsonObject source,
|
||||
GameSettings target,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
JavaVersionType javaVersionType = parseLegacyJavaVersionType(source);
|
||||
String legacyJavaVersion = Objects.requireNonNullElse(parseLegacyJavaVersion(source), "");
|
||||
target.javaTypeProperty().setValue(javaVersionType);
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
target.customJavaVersionProperty().setValue(legacyJavaVersion);
|
||||
} else if (javaVersionType == JavaVersionType.DETECTED && StringUtils.isNotBlank(legacyJavaVersion)) {
|
||||
target.detectedJavaProperty().setValue(detectedJavaFactory.apply(
|
||||
legacyJavaVersion,
|
||||
JsonUtils.getString(source, "defaultJavaPath", "")));
|
||||
}
|
||||
target.customJavaPathProperty().setValue(JsonUtils.getString(source, "javaDir", ""));
|
||||
|
||||
target.jvmOptionsProperty().setValue(JsonUtils.getString(source, "javaArgs", ""));
|
||||
target.noJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noJVMArgs", false));
|
||||
target.noOptimizingJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noOptimizingJVMArgs", false));
|
||||
target.notCheckJVMProperty().setValue(JsonUtils.getBoolean(source, "notCheckJVM", false));
|
||||
target.notCheckGameProperty().setValue(JsonUtils.getBoolean(source, "notCheckGame", false));
|
||||
|
||||
target.autoMemoryProperty().setValue(JsonUtils.getBoolean(source, "autoMemory", true));
|
||||
target.minMemoryProperty().setValue(JsonUtils.getNullableInt(source, "minMemory"));
|
||||
int maxMemory = JsonUtils.getInt(source, "maxMemory", GameSettings.SUGGESTED_MEMORY);
|
||||
target.maxMemoryProperty().setValue(maxMemory > 0 ? maxMemory : GameSettings.SUGGESTED_MEMORY);
|
||||
target.permSizeProperty().setValue(JsonUtils.getString(source, "permSize", ""));
|
||||
|
||||
target.windowTypeProperty().setValue(JsonUtils.getBoolean(source, "fullscreen", false) ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
|
||||
target.widthProperty().setValue((double) JsonUtils.getInt(source, "width", 0));
|
||||
target.heightProperty().setValue((double) JsonUtils.getInt(source, "height", 0));
|
||||
target.runningDirectoryProperty().setValue(getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.CUSTOM
|
||||
? JsonUtils.getString(source, "gameDir", "")
|
||||
: "");
|
||||
|
||||
target.processPriorityProperty().setValue(parseEnum(source, "processPriority", ProcessPriority.class, ProcessPriority.NORMAL));
|
||||
target.launcherVisibilityProperty().setValue(parseEnum(source, "launcherVisibility", LauncherVisibility.class, LauncherVisibility.HIDE));
|
||||
target.gameArgumentsProperty().setValue(JsonUtils.getString(source, "minecraftArgs", ""));
|
||||
migrateLegacyRenderer(source, target);
|
||||
target.environmentVariablesProperty().setValue(JsonUtils.getString(source, "environmentVariables", ""));
|
||||
target.commandWrapperProperty().setValue(JsonUtils.getString(source, "wrapper", ""));
|
||||
target.preLaunchCommandProperty().setValue(JsonUtils.getString(source, "precalledCommand", ""));
|
||||
target.postExitCommandProperty().setValue(JsonUtils.getString(source, "postExitCommand", ""));
|
||||
|
||||
String serverIp = JsonUtils.getString(source, "serverIp", "");
|
||||
if (StringUtils.isBlank(serverIp)) {
|
||||
target.quickPlayProperty().setValue(QuickPlayType.NONE);
|
||||
} else {
|
||||
target.quickPlayProperty().setValue(QuickPlayType.MULTIPLAYER);
|
||||
target.quickPlayMultiplayerProperty().setValue(serverIp);
|
||||
}
|
||||
|
||||
target.showLogsProperty().setValue(JsonUtils.getBoolean(source, "showLogs", false));
|
||||
target.enableDebugLogOutputProperty().setValue(JsonUtils.getBoolean(source, "enableDebugLogOutput", false));
|
||||
target.notPatchNativesProperty().setValue(JsonUtils.getBoolean(source, "notPatchNatives", false));
|
||||
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
|
||||
target.useCustomNativesProperty().setValue(useCustomNatives);
|
||||
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
|
||||
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
|
||||
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
|
||||
}
|
||||
|
||||
/// Returns whether old game settings selected user-managed native libraries.
|
||||
private static boolean isLegacyCustomNativesDirectory(JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "nativesDirType");
|
||||
if (primitive == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0
|
||||
&& index < LEGACY_NATIVES_DIRECTORY_TYPES.length
|
||||
&& "CUSTOM".equals(LEGACY_NATIVES_DIRECTORY_TYPES[index]);
|
||||
}
|
||||
|
||||
return "CUSTOM".equalsIgnoreCase(primitive.getAsString());
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates the legacy renderer fields into current renderer properties.
|
||||
private static void migrateLegacyRenderer(JsonObject source, GameSettings setting) {
|
||||
Renderer renderer = parseLegacyRenderer(source);
|
||||
GraphicsAPI graphicsBackend = parseGraphicsBackend(source, renderer);
|
||||
setting.graphicsBackendProperty().setValue(graphicsBackend);
|
||||
|
||||
if (renderer instanceof Renderer.Driver driver) {
|
||||
rendererPropertyForApi(setting, driver.api()).setValue(renderer);
|
||||
if (graphicsBackend == GraphicsAPI.DEFAULT) {
|
||||
setting.graphicsBackendProperty().setValue(driver.api());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphicsBackend == GraphicsAPI.OPENGL || graphicsBackend == GraphicsAPI.VULKAN) {
|
||||
rendererPropertyForApi(setting, graphicsBackend).setValue(renderer);
|
||||
} else {
|
||||
setting.openGLRendererProperty().setValue(renderer);
|
||||
setting.vulkanRendererProperty().setValue(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the renderer property for the given non-default graphics API.
|
||||
private static InheritableProperty<Renderer> rendererPropertyForApi(GameSettings setting, GraphicsAPI api) {
|
||||
return switch (api) {
|
||||
case OPENGL -> setting.openGLRendererProperty();
|
||||
case VULKAN -> setting.vulkanRendererProperty();
|
||||
case DEFAULT -> throw new IllegalArgumentException("The default graphics API has no renderer property");
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy Java selection mode.
|
||||
private static JavaVersionType parseLegacyJavaVersionType(JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "javaVersionType");
|
||||
if (primitive != null) {
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < LEGACY_JAVA_VERSION_TYPES.length
|
||||
? LEGACY_JAVA_VERSION_TYPES[index]
|
||||
: JavaVersionType.AUTO;
|
||||
}
|
||||
|
||||
return switch (primitive.getAsString().toUpperCase(Locale.ROOT)) {
|
||||
case "DEFAULT", "AUTO" -> JavaVersionType.AUTO;
|
||||
case "VERSION" -> JavaVersionType.VERSION;
|
||||
case "DETECTED" -> JavaVersionType.DETECTED;
|
||||
case "CUSTOM" -> JavaVersionType.CUSTOM;
|
||||
default -> JavaVersionType.AUTO;
|
||||
};
|
||||
} catch (RuntimeException ignored) {
|
||||
return JavaVersionType.AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
return switch (JsonUtils.getString(source, "java", "")) {
|
||||
case "Default", "Auto" -> JavaVersionType.AUTO;
|
||||
case "Custom" -> JavaVersionType.CUSTOM;
|
||||
default -> JavaVersionType.AUTO;
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy Java version value.
|
||||
private static @Nullable String parseLegacyJavaVersion(JsonObject source) {
|
||||
if (source.has("javaVersionType")) {
|
||||
return switch (parseLegacyJavaVersionType(source)) {
|
||||
case VERSION, DETECTED -> JsonUtils.getString(source, "java", null);
|
||||
case AUTO, CUSTOM -> "";
|
||||
};
|
||||
}
|
||||
|
||||
String java = JsonUtils.getString(source, "java", "");
|
||||
return switch (java) {
|
||||
case "Default", "Auto", "Custom" -> "";
|
||||
default -> java;
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy renderer selection.
|
||||
private static Renderer parseLegacyRenderer(JsonObject source) {
|
||||
JsonElement renderer = source.get("renderer");
|
||||
if (renderer != null && !renderer.isJsonNull()) {
|
||||
Renderer parsed = parseLegacyRenderer(renderer);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonUtils.getBoolean(source, "useSoftwareRenderer", false) ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT;
|
||||
}
|
||||
|
||||
/// Parses a renderer from a legacy JSON element.
|
||||
private static @Nullable Renderer parseLegacyRenderer(JsonElement element) {
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return Renderer.of(primitive.getAsString());
|
||||
}
|
||||
|
||||
if (element.isJsonObject()) {
|
||||
JsonElement name = element.getAsJsonObject().get("name");
|
||||
if (name instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return Renderer.of(primitive.getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parses the graphics API selection with renderer-derived fallback.
|
||||
private static GraphicsAPI parseGraphicsBackend(JsonObject source, Renderer renderer) {
|
||||
String name = JsonUtils.getString(source, "graphicsBackend", null);
|
||||
if (name != null) {
|
||||
try {
|
||||
return GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return renderer instanceof Renderer.Driver driver ? driver.api() : GraphicsAPI.DEFAULT;
|
||||
}
|
||||
|
||||
/// Parses the legacy icon selection with frozen ordinal order.
|
||||
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
|
||||
if (primitive == null) {
|
||||
return GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
|
||||
? LEGACY_VERSION_ICON_TYPES[index]
|
||||
: GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
String value = primitive.getAsString();
|
||||
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
|
||||
if (iconType.name().equalsIgnoreCase(value)) {
|
||||
return iconType;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
|
||||
return GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
/// Reads an enum property from either ordinal or name.
|
||||
private static <E extends Enum<E>> E parseEnum(@Nullable JsonObject source, String name, Class<E> type, E defaultValue) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, name);
|
||||
if (primitive == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
E[] constants = type.getEnumConstants();
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < constants.length ? constants[index] : defaultValue;
|
||||
}
|
||||
|
||||
String value = primitive.getAsString();
|
||||
for (E constant : constants) {
|
||||
if (constant.name().equalsIgnoreCase(value)) {
|
||||
return constant;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// Result of migrating a legacy per-version game settings file.
|
||||
///
|
||||
/// @param path the legacy per-version game settings path
|
||||
/// @param receiptLocation the receipt path to write after saving the migrated settings
|
||||
/// @param setting the migrated instance game settings
|
||||
public record InstanceMigrationResult(
|
||||
Path path,
|
||||
Path receiptLocation,
|
||||
GameSettings.Instance setting) {
|
||||
/// Writes the migration receipt when receipt tracking is enabled.
|
||||
public void saveReceipt() {
|
||||
MigrationReceipt.save(receiptLocation, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Records successful legacy migrations so deleting a current file does not replay unchanged legacy content.
|
||||
///
|
||||
/// @param sourceSize the legacy source file size recorded for diagnostics, or `null` when omitted
|
||||
/// @param sourceLastModified the legacy source last-modified time recorded for diagnostics, or `null` when omitted
|
||||
/// @param sourceSHA256 the legacy source SHA-256 hash, or `null` when a malformed receipt omits it
|
||||
/// @param migratedAt when the migration receipt was written, or `null` before saving or when omitted
|
||||
@NotNullByDefault
|
||||
record MigrationReceipt(
|
||||
@Nullable Long sourceSize,
|
||||
@Nullable String sourceLastModified,
|
||||
@Nullable String sourceSHA256,
|
||||
@Nullable String migratedAt) {
|
||||
/// Returns whether the receipt records the current legacy source content.
|
||||
static boolean matches(Path receipt, Path source) {
|
||||
Objects.requireNonNull(receipt);
|
||||
Objects.requireNonNull(source);
|
||||
|
||||
if (!Files.exists(receipt) || !Files.exists(source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@Nullable MigrationReceipt object = JsonUtils.fromJsonFile(receipt, MigrationReceipt.class);
|
||||
return object != null && object.matches(create(source));
|
||||
} catch (IOException | JsonParseException | IllegalStateException e) {
|
||||
LOG.warning("Failed to read migration receipt " + receipt, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a receipt for a successful migration from the legacy source file.
|
||||
static void save(Path receipt, Path source) {
|
||||
Objects.requireNonNull(receipt);
|
||||
Objects.requireNonNull(source);
|
||||
|
||||
try {
|
||||
FileUtils.saveSafely(receipt, JsonUtils.GSON.toJson(create(source, Instant.now().toString())));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to write migration receipt " + receipt, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this receipt matches another legacy source content fingerprint.
|
||||
private boolean matches(MigrationReceipt current) {
|
||||
return sourceSHA256 != null && Objects.equals(sourceSHA256, current.sourceSHA256);
|
||||
}
|
||||
|
||||
/// Creates receipt content for the current source file state.
|
||||
private static MigrationReceipt create(Path source) throws IOException {
|
||||
return create(source, null);
|
||||
}
|
||||
|
||||
/// Creates receipt content for the current source file state and migration time.
|
||||
private static MigrationReceipt create(Path source, @Nullable String migratedAt) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(source, BasicFileAttributes.class);
|
||||
return new MigrationReceipt(
|
||||
attributes.size(),
|
||||
attributes.lastModifiedTime().toString(),
|
||||
DigestUtils.digestToString("SHA-256", source),
|
||||
migratedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.EventPriority;
|
||||
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
@JsonAdapter(Profile.Serializer.class)
|
||||
public final class Profile implements Observable {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private final HMCLGameRepository repository;
|
||||
|
||||
private final StringProperty selectedVersion = new SimpleStringProperty();
|
||||
|
||||
public StringProperty selectedVersionProperty() {
|
||||
return selectedVersion;
|
||||
}
|
||||
|
||||
public String getSelectedVersion() {
|
||||
return selectedVersion.get();
|
||||
}
|
||||
|
||||
public void setSelectedVersion(String selectedVersion) {
|
||||
this.selectedVersion.set(selectedVersion);
|
||||
}
|
||||
|
||||
private final ObjectProperty<Path> gameDir;
|
||||
|
||||
public ObjectProperty<Path> gameDirProperty() {
|
||||
return gameDir;
|
||||
}
|
||||
|
||||
public Path getGameDir() {
|
||||
return gameDir.get();
|
||||
}
|
||||
|
||||
public void setGameDir(Path gameDir) {
|
||||
this.gameDir.set(gameDir);
|
||||
}
|
||||
|
||||
private final ReadOnlyObjectWrapper<VersionSetting> global = new ReadOnlyObjectWrapper<>(this, "global");
|
||||
|
||||
public ReadOnlyObjectProperty<VersionSetting> globalProperty() {
|
||||
return global.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public VersionSetting getGlobal() {
|
||||
return global.get();
|
||||
}
|
||||
|
||||
private final SimpleStringProperty name;
|
||||
|
||||
public StringProperty nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name.set(name);
|
||||
}
|
||||
|
||||
private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
|
||||
|
||||
public BooleanProperty useRelativePathProperty() {
|
||||
return useRelativePath;
|
||||
}
|
||||
|
||||
public boolean isUseRelativePath() {
|
||||
return useRelativePath.get();
|
||||
}
|
||||
|
||||
public void setUseRelativePath(boolean useRelativePath) {
|
||||
this.useRelativePath.set(useRelativePath);
|
||||
}
|
||||
|
||||
public Profile(String name) {
|
||||
this(name, Path.of(".minecraft"));
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir) {
|
||||
this(name, initialGameDir, new VersionSetting());
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir, VersionSetting global) {
|
||||
this(name, initialGameDir, global, null, false);
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
|
||||
this.name = new SimpleStringProperty(this, "name", name);
|
||||
gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir);
|
||||
repository = new HMCLGameRepository(this, initialGameDir);
|
||||
this.global.set(global == null ? new VersionSetting() : global);
|
||||
this.selectedVersion.set(selectedVersion);
|
||||
this.useRelativePath.set(useRelativePath);
|
||||
|
||||
gameDir.addListener((a, b, newValue) -> repository.changeDirectory(newValue));
|
||||
this.selectedVersion.addListener(o -> checkSelectedVersion());
|
||||
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST));
|
||||
|
||||
addPropertyChangedListener(onInvalidating(this::invalidate));
|
||||
}
|
||||
|
||||
private void checkSelectedVersion() {
|
||||
runInFX(() -> {
|
||||
if (!repository.isLoaded()) return;
|
||||
String newValue = selectedVersion.get();
|
||||
if (!repository.hasVersion(newValue)) {
|
||||
Optional<String> version = repository.getVersions().stream().findFirst().map(Version::getId);
|
||||
if (version.isPresent())
|
||||
selectedVersion.setValue(version.get());
|
||||
else if (newValue != null)
|
||||
selectedVersion.setValue(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public DefaultDependencyManager getDependency() {
|
||||
return getDependency(DownloadProviders.getDownloadProvider());
|
||||
}
|
||||
|
||||
public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) {
|
||||
return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY);
|
||||
}
|
||||
|
||||
public VersionSetting getVersionSetting(String id) {
|
||||
return repository.getVersionSetting(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("gameDir", getGameDir())
|
||||
.append("name", getName())
|
||||
.append("useRelativePath", isUseRelativePath())
|
||||
.toString();
|
||||
}
|
||||
|
||||
private void addPropertyChangedListener(InvalidationListener listener) {
|
||||
name.addListener(listener);
|
||||
global.addListener(listener);
|
||||
gameDir.addListener(listener);
|
||||
useRelativePath.addListener(listener);
|
||||
global.get().addListener(listener);
|
||||
selectedVersion.addListener(listener);
|
||||
}
|
||||
|
||||
private ObservableHelper observableHelper = new ObservableHelper(this);
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
observableHelper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
observableHelper.removeListener(listener);
|
||||
}
|
||||
|
||||
private void invalidate() {
|
||||
Platform.runLater(observableHelper::invalidate);
|
||||
}
|
||||
|
||||
public static class ProfileVersion {
|
||||
private final Profile profile;
|
||||
private final String version;
|
||||
|
||||
public ProfileVersion(Profile profile, String version) {
|
||||
this.profile = profile;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Serializer implements JsonSerializer<Profile>, JsonDeserializer<Profile> {
|
||||
@Override
|
||||
public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null)
|
||||
return JsonNull.INSTANCE;
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("global", context.serialize(src.getGlobal()));
|
||||
jsonObject.addProperty("gameDir", src.getGameDir().toString());
|
||||
jsonObject.addProperty("useRelativePath", src.isUseRelativePath());
|
||||
jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion());
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject obj)) return null;
|
||||
String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("");
|
||||
|
||||
return new Profile("Default",
|
||||
Path.of(gameDir),
|
||||
context.deserialize(obj.get("global"), VersionSetting.class),
|
||||
Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""),
|
||||
Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class Profiles {
|
||||
|
||||
public static final String DEFAULT_PROFILE = "Default";
|
||||
public static final String HOME_PROFILE = "Home";
|
||||
|
||||
private Profiles() {
|
||||
}
|
||||
|
||||
public static String getProfileDisplayName(Profile profile) {
|
||||
return switch (profile.getName()) {
|
||||
case Profiles.DEFAULT_PROFILE -> i18n("profile.default");
|
||||
case Profiles.HOME_PROFILE -> i18n("profile.home");
|
||||
default -> profile.getName();
|
||||
};
|
||||
}
|
||||
|
||||
private static final ObservableList<Profile> profiles = observableArrayList(profile -> new Observable[] { profile });
|
||||
private static final ReadOnlyListWrapper<Profile> profilesWrapper = new ReadOnlyListWrapper<>(profiles);
|
||||
|
||||
private static final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
|
||||
{
|
||||
profiles.addListener(onInvalidating(this::invalidated));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
if (!initialized)
|
||||
return;
|
||||
|
||||
Profile profile = get();
|
||||
|
||||
if (profiles.isEmpty()) {
|
||||
if (profile != null) {
|
||||
set(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!profiles.contains(profile)) {
|
||||
set(profiles.get(0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
config().setSelectedProfile(profile == null ? "" : profile.getName());
|
||||
if (profile != null) {
|
||||
if (profile.getRepository().isLoaded())
|
||||
selectedVersion.bind(profile.selectedVersionProperty());
|
||||
else {
|
||||
selectedVersion.unbind();
|
||||
selectedVersion.set(null);
|
||||
// bind when repository was reloaded.
|
||||
profile.getRepository().refreshVersionsAsync().start();
|
||||
}
|
||||
} else {
|
||||
selectedVersion.unbind();
|
||||
selectedVersion.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static void checkProfiles() {
|
||||
if (profiles.isEmpty()) {
|
||||
Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true);
|
||||
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY);
|
||||
Platform.runLater(() -> profiles.addAll(current, home));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if {@link #init()} hasn't been called.
|
||||
*/
|
||||
private static boolean initialized = false;
|
||||
|
||||
static {
|
||||
profiles.addListener(onInvalidating(Profiles::updateProfileStorages));
|
||||
profiles.addListener(onInvalidating(Profiles::checkProfiles));
|
||||
|
||||
selectedProfile.addListener((a, b, newValue) -> {
|
||||
if (newValue != null)
|
||||
newValue.getRepository().refreshVersionsAsync().start();
|
||||
});
|
||||
}
|
||||
|
||||
private static void updateProfileStorages() {
|
||||
// don't update the underlying storage before data loading is completed
|
||||
// otherwise it might cause data loss
|
||||
if (!initialized)
|
||||
return;
|
||||
// update storage
|
||||
TreeMap<String, Profile> newConfigurations = new TreeMap<>();
|
||||
for (Profile profile : profiles) {
|
||||
newConfigurations.put(profile.getName(), profile);
|
||||
}
|
||||
config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when it's ready to load profiles from {@link ConfigHolder#config()}.
|
||||
*/
|
||||
static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
HashSet<String> names = new HashSet<>();
|
||||
config().getConfigurations().forEach((name, profile) -> {
|
||||
if (!names.add(name)) return;
|
||||
profiles.add(profile);
|
||||
profile.setName(name);
|
||||
});
|
||||
checkProfiles();
|
||||
|
||||
// Platform.runLater is necessary or profiles will be empty
|
||||
// since checkProfiles adds 2 base profile later.
|
||||
Platform.runLater(() -> {
|
||||
initialized = true;
|
||||
|
||||
selectedProfile.set(
|
||||
profiles.stream()
|
||||
.filter(it -> it.getName().equals(config().getSelectedProfile()))
|
||||
.findFirst()
|
||||
.orElse(profiles.get(0)));
|
||||
});
|
||||
|
||||
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
|
||||
runInFX(() -> {
|
||||
Profile profile = selectedProfile.get();
|
||||
if (profile != null && profile.getRepository() == event.getSource()) {
|
||||
selectedVersion.bind(profile.selectedVersionProperty());
|
||||
for (Consumer<Profile> listener : versionsListeners)
|
||||
listener.accept(profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static ObservableList<Profile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public static ReadOnlyListProperty<Profile> profilesProperty() {
|
||||
return profilesWrapper.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public static Profile getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public static void setSelectedProfile(Profile profile) {
|
||||
selectedProfile.set(profile);
|
||||
}
|
||||
|
||||
public static ObjectProperty<Profile> selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
private static final ReadOnlyStringWrapper selectedVersion = new ReadOnlyStringWrapper();
|
||||
|
||||
public static ReadOnlyStringProperty selectedVersionProperty() {
|
||||
return selectedVersion.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
// Guaranteed that the repository is loaded.
|
||||
public static String getSelectedVersion() {
|
||||
return selectedVersion.get();
|
||||
}
|
||||
|
||||
private static final List<Consumer<Profile>> versionsListeners = new ArrayList<>(4);
|
||||
|
||||
public static void registerVersionsListener(Consumer<Profile> listener) {
|
||||
Profile profile = getSelectedProfile();
|
||||
if (profile != null && profile.getRepository().isLoaded())
|
||||
listener.accept(profile);
|
||||
versionsListeners.add(listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores a JSON payload using HMCL's portable protection envelope.
|
||||
///
|
||||
/// This class is responsible for wrapping payloads with a protection marker and revealing them according to that
|
||||
/// marker. Protection modes define whether and how a payload is stored.
|
||||
@NotNullByDefault
|
||||
final class ProtectedPayload {
|
||||
|
||||
/// The JSON member containing the protection marker.
|
||||
static final String PROPERTY_PROTECTION = "protection";
|
||||
|
||||
/// The JSON member containing the envelope payload.
|
||||
static final String PROPERTY_PAYLOAD = "payload";
|
||||
|
||||
/// The JSON member containing the Base64-encoded encryption nonce.
|
||||
static final String PROPERTY_NONCE = "nonce";
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ProtectedPayload() {
|
||||
}
|
||||
|
||||
/// Selects how a protected payload is stored in its JSON envelope.
|
||||
@NotNullByDefault
|
||||
enum ProtectionMode {
|
||||
/// Stores the payload as plain JSON for development and diagnostics.
|
||||
PLAIN("plain") {
|
||||
/// Writes the payload into the given envelope.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload) {
|
||||
envelope.addProperty(PROPERTY_PROTECTION, id());
|
||||
envelope.add(PROPERTY_PAYLOAD, payload);
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
@Override
|
||||
protected JsonElement readPayload(JsonObject envelope) {
|
||||
JsonElement payload = envelope.get(PROPERTY_PAYLOAD);
|
||||
if (payload == null) {
|
||||
throw new JsonParseException("Missing payload");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
},
|
||||
|
||||
/// Stores the payload as a portable weakly encrypted envelope.
|
||||
///
|
||||
/// The payload is encrypted with a built-in application key, Base64-encoded, and split into padded strings. This
|
||||
/// avoids storing private data as directly readable JSON, but it should not be treated as device-bound secret
|
||||
/// storage. The nonce is stored separately, while the payload lanes store `ciphertext || authentication tag`.
|
||||
OBFUSCATED_V1("hmcl-obfuscated-v1") {
|
||||
/// The number of lanes used by the obfuscated payload.
|
||||
private static final int OBFUSCATED_LANE_COUNT = 4;
|
||||
|
||||
/// The number of padding elements stored before each lane.
|
||||
private static final int LANE_PADDING_COUNT = 63;
|
||||
|
||||
/// The number of elements written in an obfuscated payload array.
|
||||
private static final int WRITTEN_OBFUSCATED_PAYLOAD_SIZE = 256;
|
||||
|
||||
/// The JCA transformation used for payload encryption.
|
||||
private static final String CIPHER_TRANSFORMATION = "ChaCha20-Poly1305";
|
||||
|
||||
/// The ChaCha20-Poly1305 nonce size in bytes.
|
||||
private static final int NONCE_SIZE = 12;
|
||||
|
||||
/// The random source used to create payload nonces.
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
/// The built-in application key used by this weak portable protection mode.
|
||||
static final SecretKeySpec PROTECTION_KEY = new SecretKeySpec(new byte[]{
|
||||
(byte) 0x3c, (byte) 0xd8, (byte) 0xa2, (byte) 0x22,
|
||||
(byte) 0x11, (byte) 0xd2, (byte) 0x8d, (byte) 0x89,
|
||||
(byte) 0xb4, (byte) 0xf7, (byte) 0xd9, (byte) 0xb0,
|
||||
(byte) 0x65, (byte) 0xbc, (byte) 0x14, (byte) 0x8a,
|
||||
(byte) 0x6e, (byte) 0xb0, (byte) 0xa9, (byte) 0x4d,
|
||||
(byte) 0xeb, (byte) 0x93, (byte) 0x99, (byte) 0x6f,
|
||||
(byte) 0x84, (byte) 0x07, (byte) 0x5a, (byte) 0x9e,
|
||||
(byte) 0xbd, (byte) 0xc8, (byte) 0xd1, (byte) 0xeb
|
||||
}, "ChaCha20");
|
||||
|
||||
/// Encrypts the plain payload bytes.
|
||||
///
|
||||
/// @param payload the plain payload bytes
|
||||
/// @param nonce the encryption nonce
|
||||
/// @return the encrypted payload bytes with the authentication tag appended
|
||||
/// @throws JsonParseException if the cipher is not available
|
||||
private byte[] encryptPayload(byte[] payload, byte[] nonce) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
|
||||
return cipher.doFinal(payload);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new JsonParseException("Failed to protect JSON payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypts the protected payload bytes.
|
||||
///
|
||||
/// @param payload the encrypted payload bytes with the authentication tag appended
|
||||
/// @param nonce the encryption nonce
|
||||
/// @return the plain payload bytes
|
||||
/// @throws JsonParseException if the payload cannot be decrypted
|
||||
private byte[] decryptPayload(byte[] payload, byte[] nonce) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
|
||||
return cipher.doFinal(payload);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new JsonParseException("Failed to reveal protected JSON payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the payload array index storing one lane in an effective payload window.
|
||||
///
|
||||
/// @param laneIndex the lane index
|
||||
/// @param payloadSize the effective payload window size
|
||||
/// @return the payload array index
|
||||
private static int lanePayloadIndex(int laneIndex, int payloadSize) {
|
||||
int segmentSize = payloadSize / OBFUSCATED_LANE_COUNT;
|
||||
return (laneIndex + 1) * segmentSize - 1;
|
||||
}
|
||||
|
||||
/// Splits a Base64 payload into padded lanes.
|
||||
///
|
||||
/// @param payload the Base64 payload to split
|
||||
/// @return the padded payload lanes
|
||||
private static JsonArray splitObfuscatedPayload(String payload) {
|
||||
int laneLength = payload.length() / OBFUSCATED_LANE_COUNT;
|
||||
JsonArray result = new JsonArray(WRITTEN_OBFUSCATED_PAYLOAD_SIZE);
|
||||
for (int laneIndex = 0; laneIndex < OBFUSCATED_LANE_COUNT; laneIndex++) {
|
||||
int start = laneIndex * laneLength;
|
||||
String lane = payload.substring(start, start + laneLength);
|
||||
for (int i = 0; i < LANE_PADDING_COUNT; i++) {
|
||||
result.add(JsonNull.INSTANCE);
|
||||
}
|
||||
result.add(lane);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Joins Base64 payload lanes from the envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @return the restored Base64 payload
|
||||
/// @throws JsonParseException if the payload lanes are missing or malformed
|
||||
private static String joinObfuscatedPayload(JsonObject envelope) {
|
||||
if (!(envelope.get(PROPERTY_PAYLOAD) instanceof JsonArray lanes)
|
||||
|| lanes.size() < OBFUSCATED_LANE_COUNT) {
|
||||
throw new JsonParseException("Missing payload or payload array is too small");
|
||||
}
|
||||
|
||||
int effectivePayloadSize = Integer.highestOneBit(lanes.size());
|
||||
String[] laneTexts = new String[OBFUSCATED_LANE_COUNT];
|
||||
int totalLength = 0;
|
||||
for (int i = 0; i < OBFUSCATED_LANE_COUNT; i++) {
|
||||
int payloadIndex = lanePayloadIndex(i, effectivePayloadSize);
|
||||
JsonElement lane = lanes.get(payloadIndex);
|
||||
if (!lane.isJsonPrimitive() || !lane.getAsJsonPrimitive().isString()) {
|
||||
throw new JsonParseException("Protected payload lane is not a string");
|
||||
}
|
||||
|
||||
laneTexts[i] = lane.getAsString();
|
||||
totalLength += laneTexts[i].length();
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder(totalLength);
|
||||
for (String laneText : laneTexts) {
|
||||
result.append(laneText);
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload) {
|
||||
byte[] nonce = new byte[NONCE_SIZE];
|
||||
SECURE_RANDOM.nextBytes(nonce);
|
||||
writePayload(envelope, payload, nonce);
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope with a caller-provided nonce.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
|
||||
if (nonce.length != NONCE_SIZE) {
|
||||
throw new JsonParseException("Protected payload nonce has invalid length");
|
||||
}
|
||||
|
||||
String payloadText = JsonUtils.UGLY_GSON.toJson(payload);
|
||||
byte[] payloadBytes = payloadText.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] encryptedPayload = encryptPayload(payloadBytes, nonce);
|
||||
String actualPayload = Base64.getEncoder().encodeToString(encryptedPayload);
|
||||
|
||||
envelope.addProperty(PROPERTY_PROTECTION, id());
|
||||
envelope.add(PROPERTY_PAYLOAD, splitObfuscatedPayload(actualPayload));
|
||||
envelope.addProperty(PROPERTY_NONCE, Base64.getEncoder().encodeToString(nonce));
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
@Override
|
||||
protected JsonElement readPayload(JsonObject envelope) {
|
||||
try {
|
||||
String encodedNonce = JsonUtils.getString(envelope, PROPERTY_NONCE);
|
||||
if (encodedNonce == null) {
|
||||
throw new JsonParseException("Missing protected payload member: nonce");
|
||||
}
|
||||
|
||||
byte[] nonce = Base64.getDecoder().decode(encodedNonce);
|
||||
if (nonce.length != NONCE_SIZE) {
|
||||
throw new JsonParseException("Protected payload nonce has invalid length");
|
||||
}
|
||||
|
||||
String encodedPayload = joinObfuscatedPayload(envelope);
|
||||
byte[] encryptedPayload = Base64.getDecoder().decode(encodedPayload);
|
||||
byte[] payloadBytes = decryptPayload(encryptedPayload, nonce);
|
||||
return JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException("Failed to reveal protected JSON payload", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// The serialized protection marker.
|
||||
private final String id;
|
||||
|
||||
/// Creates a protection mode.
|
||||
///
|
||||
/// @param id the serialized protection marker
|
||||
ProtectionMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/// Returns the serialized protection marker.
|
||||
///
|
||||
/// @return the serialized protection marker
|
||||
String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to write into
|
||||
/// @param payload the plain JSON payload
|
||||
/// @throws JsonParseException if the payload cannot be protected
|
||||
protected abstract void writePayload(JsonObject envelope, JsonElement payload);
|
||||
|
||||
/// Writes the payload into the given envelope with a caller-provided nonce.
|
||||
///
|
||||
/// @param envelope the envelope object to write into
|
||||
/// @param payload the plain JSON payload
|
||||
/// @param nonce the nonce to use for modes that require one
|
||||
/// @throws JsonParseException if the payload cannot be protected
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
|
||||
writePayload(envelope, payload);
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @return the revealed JSON payload
|
||||
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
|
||||
protected abstract JsonElement readPayload(JsonObject envelope);
|
||||
|
||||
/// Reads the payload from the given envelope and checks its JSON element type.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @param payloadType the expected JSON element type
|
||||
/// @return the revealed JSON payload
|
||||
/// @param <T> the expected JSON element type
|
||||
/// @throws JsonParseException if the envelope is malformed, cannot be revealed, or has the wrong payload type
|
||||
public final <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) {
|
||||
Objects.requireNonNull(payloadType);
|
||||
JsonElement payload = readPayload(envelope);
|
||||
if (!payloadType.isInstance(payload)) {
|
||||
throw new JsonParseException("Protected payload is not a " + payloadType.getSimpleName());
|
||||
}
|
||||
return payloadType.cast(payload);
|
||||
}
|
||||
|
||||
/// Returns the write mode selected by a configuration value.
|
||||
///
|
||||
/// Unknown values intentionally fall back to obfuscation so opt-in plain storage cannot be enabled by typos.
|
||||
///
|
||||
/// @param id the configured protection marker
|
||||
/// @return the selected write mode
|
||||
static ProtectionMode fromConfiguredId(@Nullable String id) {
|
||||
for (ProtectionMode mode : values()) {
|
||||
if (mode.id.equals(id)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return OBFUSCATED_V1;
|
||||
}
|
||||
|
||||
/// Reads the protection mode from an envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to inspect
|
||||
/// @return the protection mode declared by the envelope
|
||||
/// @throws JsonParseException if the declared protection mode is unsupported
|
||||
static ProtectionMode fromEnvelope(JsonObject envelope) {
|
||||
String protection = JsonUtils.getString(envelope, PROPERTY_PROTECTION);
|
||||
if (protection == null) {
|
||||
throw new JsonParseException("Missing protected payload member: protection");
|
||||
}
|
||||
|
||||
for (ProtectionMode mode : values()) {
|
||||
if (mode.id.equals(protection)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
throw new JsonParseException("Unsupported protected payload: " + protection);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and reveals a protected JSON payload from an envelope object.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @param payloadType the expected JSON element type
|
||||
/// @return the revealed JSON payload
|
||||
/// @param <T> the expected JSON element type
|
||||
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
|
||||
static <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) throws JsonParseException {
|
||||
return ProtectionMode.fromEnvelope(envelope).read(envelope, payloadType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import java.net.*;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ProxyManager {
|
||||
@@ -48,28 +48,32 @@ public final class ProxyManager {
|
||||
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
|
||||
|
||||
private static ProxySelector getProxySelector() {
|
||||
if (config().hasProxy()) {
|
||||
Proxy.Type proxyType = config().getProxyType();
|
||||
String host = config().getProxyHost();
|
||||
int port = config().getProxyPort();
|
||||
ProxyType proxyType = settings().proxyTypeProperty().get();
|
||||
return switch (proxyType) {
|
||||
case SYSTEM -> ProxyManager.SYSTEM_DEFAULT;
|
||||
case DIRECT -> NO_PROXY;
|
||||
case HTTP, SOCKS -> {
|
||||
String host = settings().proxyHostProperty().get();
|
||||
int port = settings().proxyPortProperty().get();
|
||||
|
||||
if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) {
|
||||
return NO_PROXY;
|
||||
} else if (port < 0 || port > 0xFFFF) {
|
||||
LOG.warning("Illegal proxy port: " + port);
|
||||
return NO_PROXY;
|
||||
} else {
|
||||
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
|
||||
if (StringUtils.isBlank(host)) {
|
||||
yield NO_PROXY;
|
||||
} else if (port < 0 || port > 0xFFFF) {
|
||||
LOG.warning("Illegal proxy port: " + port);
|
||||
yield NO_PROXY;
|
||||
} else {
|
||||
yield new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(
|
||||
Objects.requireNonNull(proxyType.jdkType()),
|
||||
new InetSocketAddress(host, port))));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return ProxyManager.SYSTEM_DEFAULT;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SimpleAuthenticator getAuthenticator() {
|
||||
if (config().hasProxy() && config().hasProxyAuth()) {
|
||||
String username = config().getProxyUser();
|
||||
String password = config().getProxyPass();
|
||||
if (settings().proxyTypeProperty().get().usesCustomAddress() && settings().hasProxyAuthProperty().get()) {
|
||||
String username = settings().proxyUserProperty().get();
|
||||
String password = settings().proxyPasswordProperty().get();
|
||||
|
||||
if (username != null || password != null)
|
||||
return new SimpleAuthenticator(
|
||||
@@ -82,7 +86,8 @@ public final class ProxyManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Installs proxy and authentication handlers backed by launcher settings.
|
||||
public static void init() {
|
||||
ProxySelector.setDefault(new ProxySelector() {
|
||||
@Override
|
||||
public List<Proxy> select(URI uri) {
|
||||
@@ -104,17 +109,16 @@ public final class ProxyManager {
|
||||
|
||||
defaultProxySelector = getProxySelector();
|
||||
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
|
||||
config().proxyTypeProperty().addListener(updateProxySelector);
|
||||
config().proxyHostProperty().addListener(updateProxySelector);
|
||||
config().proxyPortProperty().addListener(updateProxySelector);
|
||||
config().hasProxyProperty().addListener(updateProxySelector);
|
||||
settings().proxyTypeProperty().addListener(updateProxySelector);
|
||||
settings().proxyHostProperty().addListener(updateProxySelector);
|
||||
settings().proxyPortProperty().addListener(updateProxySelector);
|
||||
|
||||
defaultAuthenticator = getAuthenticator();
|
||||
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
|
||||
config().hasProxyProperty().addListener(updateAuthenticator);
|
||||
config().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
config().proxyUserProperty().addListener(updateAuthenticator);
|
||||
config().proxyPassProperty().addListener(updateAuthenticator);
|
||||
settings().proxyTypeProperty().addListener(updateAuthenticator);
|
||||
settings().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
settings().proxyUserProperty().addListener(updateAuthenticator);
|
||||
settings().proxyPasswordProperty().addListener(updateAuthenticator);
|
||||
|
||||
FetchTask.notifyInitialized();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.Proxy;
|
||||
|
||||
/// Launcher proxy selection mode.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum ProxyType {
|
||||
/// Use the proxy selector provided by the host JVM or operating system.
|
||||
SYSTEM(null),
|
||||
|
||||
/// Disable proxy usage explicitly.
|
||||
DIRECT(Proxy.Type.DIRECT),
|
||||
|
||||
/// Use a manually configured HTTP proxy.
|
||||
HTTP(Proxy.Type.HTTP),
|
||||
|
||||
/// Use a manually configured SOCKS proxy.
|
||||
SOCKS(Proxy.Type.SOCKS);
|
||||
|
||||
/// The matching JDK proxy type, or `null` when this mode does not map to a concrete proxy.
|
||||
private final @Nullable Proxy.Type jdkType;
|
||||
|
||||
/// Creates a proxy selection mode.
|
||||
///
|
||||
/// @param jdkType the matching JDK proxy type, or `null` when this mode does not map to a concrete proxy
|
||||
ProxyType(@Nullable Proxy.Type jdkType) {
|
||||
this.jdkType = jdkType;
|
||||
}
|
||||
|
||||
/// Returns the matching JDK proxy type.
|
||||
///
|
||||
/// @return the JDK proxy type, or `null` when this mode does not map to a concrete proxy
|
||||
public @Nullable Proxy.Type jdkType() {
|
||||
return jdkType;
|
||||
}
|
||||
|
||||
/// Returns whether this mode needs a custom proxy host and port.
|
||||
public boolean usesCustomAddress() {
|
||||
return this == HTTP || this == SOCKS;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
public final class SambaException extends RuntimeException {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Describes whether a settings file can be read and safely overwritten by this HMCL version.
|
||||
@NotNullByDefault
|
||||
public enum SettingFileAccess {
|
||||
/// The file can be read and overwritten safely.
|
||||
READ_WRITE,
|
||||
|
||||
/// The file can be read but must not be overwritten automatically.
|
||||
READ_ONLY,
|
||||
|
||||
/// The file cannot be read safely and must not be overwritten automatically.
|
||||
UNREADABLE;
|
||||
|
||||
/// Returns whether this access mode permits saving the file.
|
||||
public boolean canSave() {
|
||||
return this == READ_WRITE;
|
||||
}
|
||||
|
||||
/// Returns whether this access mode blocks normal editing of the owning feature.
|
||||
public boolean blocksEditing() {
|
||||
return this != READ_WRITE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Utility methods shared by launcher settings files.
|
||||
@NotNullByDefault
|
||||
public final class SettingFileUtils {
|
||||
/// Prevents instantiation.
|
||||
private SettingFileUtils() {
|
||||
}
|
||||
|
||||
/// Moves an invalid config file to a numbered backup path (for example `launcher-settings.json.1`)
|
||||
/// so the original bytes are preserved for diagnosis before the next successful overwrite.
|
||||
public static void backupInvalidConfig(Path location) {
|
||||
try {
|
||||
Path backup = null;
|
||||
for (int i = 1; i < Integer.MAX_VALUE; i++) {
|
||||
Path candidate = location.resolveSibling(location.getFileName() + "." + i);
|
||||
if (!Files.exists(candidate)) {
|
||||
backup = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (backup == null) {
|
||||
LOG.warning("Could not find an available backup path for " + location);
|
||||
return;
|
||||
}
|
||||
Files.move(location, backup, StandardCopyOption.REPLACE_EXISTING);
|
||||
LOG.info("Backed up invalid config to " + backup);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to back up invalid config " + location, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
|
||||
public final class Settings {
|
||||
|
||||
private static Settings instance;
|
||||
|
||||
public static Settings instance() {
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException("Settings hasn't been initialized");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called from {@link ConfigHolder#init()}.
|
||||
*/
|
||||
static void init() {
|
||||
instance = new Settings();
|
||||
}
|
||||
|
||||
private Settings() {
|
||||
DownloadProviders.init();
|
||||
ProxyManager.init();
|
||||
Accounts.init();
|
||||
Profiles.init();
|
||||
AuthlibInjectorServers.init();
|
||||
AnimationUtils.init();
|
||||
|
||||
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
|
||||
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
|
||||
if (FileUtils.canCreateDirectory(getCommonDirectory())) {
|
||||
return getCommonDirectory();
|
||||
} else {
|
||||
return getDefaultCommonDirectory();
|
||||
}
|
||||
}, config().commonDirectoryProperty(), config().commonDirTypeProperty()));
|
||||
}
|
||||
|
||||
public static String getDefaultCommonDirectory() {
|
||||
return Metadata.MINECRAFT_DIRECTORY.toString();
|
||||
}
|
||||
|
||||
public String getCommonDirectory() {
|
||||
switch (config().getCommonDirType()) {
|
||||
case DEFAULT:
|
||||
return getDefaultCommonDirectory();
|
||||
case CUSTOM:
|
||||
return config().getCommonDirectory();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 801 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user