Compare commits
523
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f176fbd1fd | ||
|
|
23811dad7f | ||
|
|
e792bfc554 | ||
|
|
d6b4af2d96 | ||
|
|
2a915460ae | ||
|
|
e9b577f66f | ||
|
|
e12c7e53f6 | ||
|
|
61caf8a925 | ||
|
|
a7123a389e | ||
|
|
3a6e80da0e | ||
|
|
2c8446fa33 | ||
|
|
21fcac1b0c | ||
|
|
095d71fea7 | ||
|
|
07ff244cd2 | ||
|
|
0269e54b95 | ||
|
|
06ef27f0e0 | ||
|
|
b3f649c026 | ||
|
|
0531d0cbc2 | ||
|
|
84b6d50421 | ||
|
|
dfdad64e93 | ||
|
|
323d09b104 | ||
|
|
0f85782312 | ||
|
|
7b79945391 | ||
|
|
441ce7220f | ||
|
|
e8df66dcc0 | ||
|
|
f7420e284d | ||
|
|
a2fa7ed5ab | ||
|
|
3f67b6bda0 | ||
|
|
4cd783d6bb | ||
|
|
4118030d9c | ||
|
|
bcc5cda5c0 | ||
|
|
1147892830 | ||
|
|
a4fff896ea | ||
|
|
5e2da98061 | ||
|
|
2b76d44ab0 | ||
|
|
cf44b2e536 | ||
|
|
895481ce63 | ||
|
|
1e45711006 | ||
|
|
f4893d9f75 | ||
|
|
3e172608bf | ||
|
|
f0fcd28bd0 | ||
|
|
5736ea8a24 | ||
|
|
b510352154 | ||
|
|
4efd3a1e7d | ||
|
|
c892caeba3 | ||
|
|
3b57add252 | ||
|
|
e757b6832f | ||
|
|
a45ccde0a1 | ||
|
|
eb14c66e1a | ||
|
|
6fa48981d5 | ||
|
|
3c89a012b2 | ||
|
|
a543216e67 | ||
|
|
86b0b855a7 | ||
|
|
13d5d6b322 | ||
|
|
cfb0a8ce68 | ||
|
|
fe000fb94f | ||
|
|
29acd01155 | ||
|
|
7cf696e2a0 | ||
|
|
09ccf73285 | ||
|
|
7403c3ff79 | ||
|
|
b46a07cd45 | ||
|
|
a262a95408 | ||
|
|
88222af99a | ||
|
|
f1d90fe0a1 | ||
|
|
52c84ceb29 | ||
|
|
087b2b678d | ||
|
|
3f6e7fcffd | ||
|
|
ed59611772 | ||
|
|
c100f46b9d | ||
|
|
00b1b34f98 | ||
|
|
f411840199 | ||
|
|
8d44411302 | ||
|
|
85540268bd | ||
|
|
e64a7b45b5 | ||
|
|
7d89e87dca | ||
|
|
8f002e433f | ||
|
|
a639fbb1c8 | ||
|
|
70e6b6ade7 | ||
|
|
d3b91d439c | ||
|
|
8912ee2661 | ||
|
|
5dd6ab5908 | ||
|
|
57eca838e2 | ||
|
|
75d070d8c9 | ||
|
|
f81ba320da | ||
|
|
8047775bf8 | ||
|
|
740d3c5d80 | ||
|
|
967c326219 | ||
|
|
fdc3500cc4 | ||
|
|
2f17995955 | ||
|
|
ef311560fd | ||
|
|
440b42a8b7 | ||
|
|
8b706466e7 | ||
|
|
8e16127845 | ||
|
|
bdbf5773a8 | ||
|
|
1ca146c615 | ||
|
|
de8ae88ee4 | ||
|
|
ba62160c76 | ||
|
|
8e7f36c681 | ||
|
|
b08f3869cf | ||
|
|
f05647fc82 | ||
|
|
67e9826309 | ||
|
|
f1c9b77df5 | ||
|
|
c3abfcf1d9 | ||
|
|
6632a91426 | ||
|
|
ac378ad46a | ||
|
|
6bbb1d157c | ||
|
|
efa1012a1c | ||
|
|
5058c68be8 | ||
|
|
3612f04657 | ||
|
|
a2d3bb3b42 | ||
|
|
d3efc81332 | ||
|
|
6b1799838f | ||
|
|
5ad4880182 | ||
|
|
a3781141fe | ||
|
|
ed168e14d6 | ||
|
|
ee81d7cc66 | ||
|
|
299077d1cb | ||
|
|
7448ea90b2 | ||
|
|
ac578c044f | ||
|
|
fc68029adc | ||
|
|
f6a92c1806 | ||
|
|
58eb452e90 | ||
|
|
fb8a96a170 | ||
|
|
ced441a830 | ||
|
|
c5693c3450 | ||
|
|
bc27eda220 | ||
|
|
0c40dcdeed | ||
|
|
4282147042 | ||
|
|
0874c98d21 | ||
|
|
0c3c7dd62a | ||
|
|
f663e5568c | ||
|
|
b8c1738f96 | ||
|
|
41c61645e7 | ||
|
|
9fc697751c | ||
|
|
a4db0532b9 | ||
|
|
8954139b5e | ||
|
|
b2f7e724e4 | ||
|
|
0d20910ea3 | ||
|
|
5c90299073 | ||
|
|
fc66cbcf96 | ||
|
|
e7b9a95df0 | ||
|
|
acc9740fa9 | ||
|
|
5789f82617 | ||
|
|
8827fd06d8 | ||
|
|
583e004d71 | ||
|
|
63207affc6 | ||
|
|
8b147bf20a | ||
|
|
15754119d5 | ||
|
|
8119324a92 | ||
|
|
c0ad7764b0 | ||
|
|
3ea054ae31 | ||
|
|
d03ad38e24 | ||
|
|
93eaee317c | ||
|
|
68b3a9f076 | ||
|
|
c943e4c74f | ||
|
|
4dba96e635 | ||
|
|
e521a1b91a | ||
|
|
a7e0fe7499 | ||
|
|
4e2d5cac9e | ||
|
|
452d030817 | ||
|
|
da9ec2c1e1 | ||
|
|
4ebbb400ab | ||
|
|
0cfc2471a0 | ||
|
|
413a7291ec | ||
|
|
e4893a6df9 | ||
|
|
bb16a0b6c0 | ||
|
|
5768cd446e | ||
|
|
6f406cede8 | ||
|
|
0597282463 | ||
|
|
f91fbe29b4 | ||
|
|
cccff7effd | ||
|
|
101257cae8 | ||
|
|
a6e74e7c19 | ||
|
|
615ebe0311 | ||
|
|
8b3468e2d9 | ||
|
|
7f1a812373 | ||
|
|
22823151c3 | ||
|
|
c0844834cb | ||
|
|
7921c7abb5 | ||
|
|
c610519f87 | ||
|
|
bb1949f919 | ||
|
|
a1e946bba6 | ||
|
|
c1bf66b972 | ||
|
|
25952aa514 | ||
|
|
54d8d877be | ||
|
|
662a82f21f | ||
|
|
3f0fcc1382 | ||
|
|
258556b22c | ||
|
|
d7e3cc4473 | ||
|
|
93d4489b94 | ||
|
|
77eee17d36 | ||
|
|
4118fa9729 | ||
|
|
83ffc91a88 | ||
|
|
fdf883d09d | ||
|
|
342bcc5438 | ||
|
|
59bcc7fe62 | ||
|
|
77b42b6f99 | ||
|
|
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 |
No files matched your search
@@ -6,3 +6,6 @@ contact_links:
|
|||||||
- name: Discord 服务器
|
- name: Discord 服务器
|
||||||
url: https://discord.gg/jVvC7HfM6U
|
url: https://discord.gg/jVvC7HfM6U
|
||||||
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
|
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
|
||||||
|
- name: GitHub Issue
|
||||||
|
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||||
|
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
code_review:
|
|
||||||
pull_request_opened:
|
|
||||||
code_review: false
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
# ─── Explicitly text – source & build ────────────────────────────────────────
|
# ─── Explicitly text – source & build ────────────────────────────────────────
|
||||||
*.java text eol=lf
|
*.java text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
*.kt text eol=lf
|
*.kt text eol=lf
|
||||||
*.kts text eol=lf
|
*.kts text eol=lf
|
||||||
*.groovy text eol=lf
|
*.groovy text eol=lf
|
||||||
@@ -58,6 +59,9 @@ Jenkinsfile text eol=lf
|
|||||||
|
|
||||||
# Minecraft schematic
|
# Minecraft schematic
|
||||||
*.litematic binary
|
*.litematic binary
|
||||||
|
*.nbt binary
|
||||||
|
*.schem binary
|
||||||
|
*.schematic binary
|
||||||
|
|
||||||
# Compiled / intermediate build artefacts
|
# Compiled / intermediate build artefacts
|
||||||
*.bin binary
|
*.bin binary
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ contact_links:
|
|||||||
- name: QQ 群
|
- name: QQ 群
|
||||||
url: https://docs.hmcl.net/groups.html
|
url: https://docs.hmcl.net/groups.html
|
||||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||||
|
- name: GitHub Issue
|
||||||
|
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||||
|
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Check Codes
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.java'
|
|
||||||
- '**.properties'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- '**.java'
|
|
||||||
- '**.properties'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v5
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
java-package: 'jdk+fx'
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/actions/setup-gradle@v5
|
|
||||||
with:
|
|
||||||
cache-cleanup: never
|
|
||||||
- name: Check Codes
|
|
||||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
name: Java CI
|
name: Java CI
|
||||||
|
|
||||||
|
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
|
||||||
|
# (once for the push, once for the pull request), which kept the runners queued.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
|
|
||||||
|
# A newer run for the same pull request or branch replaces the older one.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
||||||
@@ -14,18 +25,37 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'liberica'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
java-package: 'jdk+fx'
|
java-package: 'jdk+fx'
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v5
|
||||||
with:
|
with:
|
||||||
cache-cleanup: never
|
cache-cleanup: never
|
||||||
- name: Build with Gradle
|
# Only main writes the dependency cache; pull requests read it.
|
||||||
run: ./gradlew build --no-daemon --parallel
|
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
# The repository's wrapper downloads Gradle itself, so cache that download too.
|
||||||
|
- name: Cache Gradle distribution
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/wrapper
|
||||||
|
.gradle/wrapper-neo
|
||||||
|
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
|
||||||
|
- name: Install Xvfb
|
||||||
|
run: |
|
||||||
|
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
|
||||||
|
# only refresh the package index when a plain install fails.
|
||||||
|
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|
||||||
|
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
|
||||||
|
# One Gradle run builds, tests and runs the code and translation checks, so the
|
||||||
|
# project is configured and compiled once instead of in two workflows.
|
||||||
|
- name: Build and check with Gradle
|
||||||
|
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
|
||||||
env:
|
env:
|
||||||
|
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
|
||||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||||
- name: Get short SHA
|
- name: Get short SHA
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: PR Size Label
|
||||||
|
|
||||||
|
# Disabled on this Gitea fork: act resolves actions/github-script from
|
||||||
|
# git.nest.potatolab.uk and that mirror 404s. Re-enable after mirroring
|
||||||
|
# actions/github-script or replacing the labeler with a plain shell step.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: false
|
||||||
|
steps:
|
||||||
|
- name: Disabled
|
||||||
|
run: echo "pr-size-label is disabled on this fork"
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
|
*.jfr
|
||||||
|
|
||||||
.gradle
|
.gradle
|
||||||
gradle.properties
|
gradle.properties
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ NVIDIA
|
|||||||
minecraft-exported-crash-info*
|
minecraft-exported-crash-info*
|
||||||
hmcl-exported-logs-*
|
hmcl-exported-logs-*
|
||||||
terracotta-log-*
|
terracotta-log-*
|
||||||
|
hmcl-mod-update-list-*
|
||||||
|
*.hmcl-theme
|
||||||
/.java/
|
/.java/
|
||||||
/.local/
|
/.local/
|
||||||
/.cache/
|
/.cache/
|
||||||
@@ -44,6 +48,7 @@ language-subtag-registry
|
|||||||
# eclipse
|
# eclipse
|
||||||
/bin/
|
/bin/
|
||||||
/HMCL/bin/
|
/HMCL/bin/
|
||||||
|
/HMCLBoot/bin/
|
||||||
/HMCLCore/bin/
|
/HMCLCore/bin/
|
||||||
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
||||||
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
||||||
@@ -72,3 +77,6 @@ language-subtag-registry
|
|||||||
!/.gemini/config.yaml
|
!/.gemini/config.yaml
|
||||||
.codex
|
.codex
|
||||||
/.gradle-*/
|
/.gradle-*/
|
||||||
|
|
||||||
|
# Managed Java downloaded by the launcher when it is run from the repository root
|
||||||
|
/java/
|
||||||
Generated
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
<component name="CopyrightManager">
|
<component name="CopyrightManager">
|
||||||
<copyright>
|
<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="notice" value="SPDX-FileCopyrightText: Copyright (C) &#36;today.year The HMCL Authors SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL" />
|
||||||
<option name="myName" value="HMCL" />
|
<option name="myName" value="HMCL" />
|
||||||
</copyright>
|
</copyright>
|
||||||
</component>
|
</component>
|
||||||
Generated
+1
@@ -3,6 +3,7 @@
|
|||||||
<LanguageOptions name="JAVA">
|
<LanguageOptions name="JAVA">
|
||||||
<option name="fileTypeOverride" value="3" />
|
<option name="fileTypeOverride" value="3" />
|
||||||
<option name="addBlankAfter" value="false" />
|
<option name="addBlankAfter" value="false" />
|
||||||
|
<option name="block" value="false" />
|
||||||
</LanguageOptions>
|
</LanguageOptions>
|
||||||
</settings>
|
</settings>
|
||||||
</component>
|
</component>
|
||||||
@@ -4,9 +4,11 @@ These rules apply to all Java code written or modified in this repository.
|
|||||||
|
|
||||||
## Nullability
|
## Nullability
|
||||||
|
|
||||||
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
|
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||||
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
|
||||||
- Nullability must never be implicit.
|
- 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
|
## Immutability
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ These rules apply to all Java code written or modified in this repository.
|
|||||||
|
|
||||||
## Documentation
|
## 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.
|
- Every class, field, and method must have documentation.
|
||||||
- Documentation must use `///` Markdown-style Javadoc comments.
|
- Documentation must use `///` Markdown-style Javadoc comments.
|
||||||
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
|
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
|
||||||
|
|||||||
+75
-74
@@ -1,14 +1,16 @@
|
|||||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
|
||||||
|
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
|
||||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||||
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
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.l10n.UpsideDownTranslate
|
||||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
|
||||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||||
|
import org.jackhuang.hmcl.gradle.utils.OfficialBuildValueSource
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.nio.file.FileSystems
|
import java.nio.file.FileSystems
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
@@ -52,7 +54,7 @@ if (buildNumber != null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val embedResources by configurations.registering
|
val embedResources = configurations.register("embedResources")
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":HMCLCore"))
|
implementation(project(":HMCLCore"))
|
||||||
@@ -63,6 +65,9 @@ dependencies {
|
|||||||
implementation(libs.java.info)
|
implementation(libs.java.info)
|
||||||
implementation(libs.monet.fx)
|
implementation(libs.monet.fx)
|
||||||
implementation(libs.nayuki.qrcodegen)
|
implementation(libs.nayuki.qrcodegen)
|
||||||
|
implementation(libs.uuid.tools)
|
||||||
|
|
||||||
|
testImplementation(libs.jimfs)
|
||||||
|
|
||||||
if (launcherExe.isBlank()) {
|
if (launcherExe.isBlank()) {
|
||||||
implementation(libs.hmclauncher)
|
implementation(libs.hmclauncher)
|
||||||
@@ -141,6 +146,7 @@ val addOpens = listOf(
|
|||||||
"javafx.controls/com.sun.javafx.scene.control",
|
"javafx.controls/com.sun.javafx.scene.control",
|
||||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||||
"javafx.graphics/com.sun.javafx.tk.quantum",
|
"javafx.graphics/com.sun.javafx.tk.quantum",
|
||||||
|
"javafx.controls/javafx.scene.control",
|
||||||
"javafx.controls/javafx.scene.control.skin",
|
"javafx.controls/javafx.scene.control.skin",
|
||||||
"jdk.attach/sun.tools.attach",
|
"jdk.attach/sun.tools.attach",
|
||||||
)
|
)
|
||||||
@@ -149,33 +155,28 @@ tasks.compileJava {
|
|||||||
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
|
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
|
||||||
}
|
}
|
||||||
|
|
||||||
val hmclProperties = buildList {
|
val createPropertiesFile = tasks.register<WriteProperties>("createPropertiesFile") {
|
||||||
add("hmcl.version" to project.version.toString())
|
destinationFile.set(layout.buildDirectory.file("hmcl.properties"))
|
||||||
add("hmcl.add-opens" to addOpens.joinToString(" "))
|
encoding = "UTF-8"
|
||||||
System.getenv("GITHUB_SHA")?.let {
|
|
||||||
add("hmcl.version.hash" to it)
|
|
||||||
}
|
|
||||||
add("hmcl.version.type" to versionType)
|
|
||||||
add("hmcl.microsoft.auth.id" to microsoftAuthId)
|
|
||||||
add("hmcl.curseforge.apikey" to curseForgeApiKey)
|
|
||||||
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
|
|
||||||
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
property("hmcl.version", project.version.toString())
|
||||||
val createPropertiesFile by tasks.registering {
|
property("hmcl.add-opens", addOpens.joinToString(" "))
|
||||||
outputs.file(hmclPropertiesFile)
|
System.getenv("GITHUB_SHA")?.let { property("hmcl.version.hash", it) }
|
||||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
property("hmcl.version.type", versionType)
|
||||||
|
property("hmcl.microsoft.auth.id", microsoftAuthId)
|
||||||
doLast {
|
property("hmcl.curseforge.apikey", curseForgeApiKey)
|
||||||
val targetFile = hmclPropertiesFile.get().asFile
|
property("hmcl.authlib-injector.version", libs.authlib.injector.get().version!!)
|
||||||
targetFile.parentFile.mkdir()
|
property("hmcl.lwjgl-unsafe-agent.version", libs.lwjgl.unsafe.agent.get().version!!)
|
||||||
targetFile.bufferedWriter().use {
|
property("hmcl.official", providers.of(OfficialBuildValueSource::class) {
|
||||||
for ((k, v) in hmclProperties) {
|
parameters.publicKeyFile.set(layout.projectDirectory.file("src/main/resources/assets/hmcl_signature_publickey.der"))
|
||||||
it.write("$k=$v\n")
|
parameters.privateKeyLocation.set(providers.environmentVariable("HMCL_SIGNATURE_KEY"))
|
||||||
}
|
// Pin the official public key independently of the replaceable resource file.
|
||||||
}
|
// Under HMCL’s license agreement, you **MUST** change the software name or the version number in
|
||||||
}
|
// a reasonable way to distinguish it from the original version.
|
||||||
|
// You may modify this hash if and only if you have changed the software name or version number
|
||||||
|
// in accordance with the license requirements.
|
||||||
|
parameters.expectedSha256.set("36aadcfde8626b9d4c7a74facb3fdfda47e703c205a673fbe6a56b1d419f8b8e")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.jar {
|
tasks.jar {
|
||||||
@@ -209,7 +210,7 @@ tasks.shadowJar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manifest.attributes(
|
manifest.attributes(
|
||||||
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
|
"Created-By" to "Copyright(c) 2013-2026 The HMCL Authors",
|
||||||
"Implementation-Version" to project.version.toString(),
|
"Implementation-Version" to project.version.toString(),
|
||||||
"Main-Class" to "org.jackhuang.hmcl.Main",
|
"Main-Class" to "org.jackhuang.hmcl.Main",
|
||||||
"Multi-Release" to "true",
|
"Multi-Release" to "true",
|
||||||
@@ -231,13 +232,12 @@ tasks.shadowJar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.processResources {
|
tasks.processResources {
|
||||||
dependsOn(createPropertiesFile)
|
|
||||||
dependsOn(upsideDownTranslate)
|
dependsOn(upsideDownTranslate)
|
||||||
dependsOn(createLocaleNamesResourceBundle)
|
dependsOn(createLocaleNamesResourceBundle)
|
||||||
dependsOn(createLanguageList)
|
dependsOn(createLanguageList)
|
||||||
|
|
||||||
into("assets/") {
|
into("assets/") {
|
||||||
from(hmclPropertiesFile)
|
from(createPropertiesFile.flatMap { it.destinationFile })
|
||||||
from(embedResources)
|
from(embedResources)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,15 +247,11 @@ tasks.processResources {
|
|||||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs.property("terracotta_version", libs.versions.terracotta)
|
|
||||||
doLast {
|
|
||||||
upgradeTerracottaConfig.get().checkValid()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||||
|
|
||||||
val makeExecutables by tasks.registering {
|
val makeExecutables = tasks.register("makeExecutables") {
|
||||||
val extensions = listOf("exe", "sh")
|
val extensions = listOf("exe", "sh")
|
||||||
|
|
||||||
dependsOn(tasks.jar)
|
dependsOn(tasks.jar)
|
||||||
@@ -283,7 +279,7 @@ val makeExecutables by tasks.registering {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val makeDeb by tasks.registering(CreateDeb::class) {
|
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
|
||||||
dependsOn(makeExecutables)
|
dependsOn(makeExecutables)
|
||||||
|
|
||||||
val debFile = layout.file(provider { artifactFile("deb") })
|
val debFile = layout.file(provider { artifactFile("deb") })
|
||||||
@@ -296,6 +292,7 @@ val makeDeb by tasks.registering(CreateDeb::class) {
|
|||||||
|
|
||||||
version.set(project.version.toString())
|
version.set(project.version.toString())
|
||||||
releaseType.set(debChannel)
|
releaseType.set(debChannel)
|
||||||
|
launcherClassName.set("org.jackhuang.hmcl.Launcher")
|
||||||
appShFile.set(layout.file(provider { artifactFile("sh") }))
|
appShFile.set(layout.file(provider { artifactFile("sh") }))
|
||||||
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
|
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
|
||||||
outputFile.set(debFile)
|
outputFile.set(debFile)
|
||||||
@@ -360,6 +357,11 @@ fun parseToolOptions(options: String?): MutableList<String> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tests use the same JavaFX reflective access as the packaged application.
|
||||||
|
tasks.withType<Test> {
|
||||||
|
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
|
||||||
|
}
|
||||||
|
|
||||||
// For IntelliJ IDEA
|
// For IntelliJ IDEA
|
||||||
tasks.withType<JavaExec> {
|
tasks.withType<JavaExec> {
|
||||||
if (name != "run") {
|
if (name != "run") {
|
||||||
@@ -398,27 +400,6 @@ tasks.register<JavaExec>("run") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// terracotta
|
|
||||||
|
|
||||||
val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgradeTerracottaConfig") {
|
|
||||||
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
|
|
||||||
val source = layout.projectDirectory.file("terracotta-template.json");
|
|
||||||
|
|
||||||
classifiers.set(
|
|
||||||
listOf(
|
|
||||||
"windows-x86_64", "windows-arm64",
|
|
||||||
"macos-x86_64", "macos-arm64",
|
|
||||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
|
||||||
"freebsd-x86_64"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
version.set(libs.versions.terracotta)
|
|
||||||
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
|
|
||||||
|
|
||||||
templateFile.set(source)
|
|
||||||
outputFile.set(destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Translations
|
// Check Translations
|
||||||
|
|
||||||
@@ -426,42 +407,62 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
|||||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||||
|
|
||||||
englishFile.set(dir.file("I18N.properties"))
|
englishFile.set(dir.file("I18N.properties"))
|
||||||
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
|
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
|
||||||
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
|
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
|
||||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// l10n
|
// 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 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"))
|
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.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"))
|
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||||
resourceBundleBaseName.set("I18N")
|
resourceBundleBaseName.set("I18N")
|
||||||
additionalLanguages.set(listOf("en-Qabs"))
|
additionalLanguages.set(listOf("en-Qabs"))
|
||||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||||
}
|
}
|
||||||
|
|
||||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
val createLocaleNamesResourceBundle =
|
||||||
dependsOn(createLanguageList)
|
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||||
|
dependsOn(createLanguageList)
|
||||||
|
|
||||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||||
}
|
}
|
||||||
|
|
||||||
// mcmod data
|
// mcmod data
|
||||||
|
|
||||||
tasks.register<ParseModDataTask>("parseModData") {
|
tasks.register<FormatJsonFile>("formatModJson") {
|
||||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
file.set(layout.projectDirectory.file("mod.json"))
|
||||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<ParseModDataTask>("parseModPackData") {
|
tasks.register<FormatJsonFile>("formatModpackJson") {
|
||||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
file.set(layout.projectDirectory.file("modpack.json"))
|
||||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
|
}
|
||||||
|
|
||||||
|
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
|
||||||
|
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||||
|
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
|
||||||
|
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
|
||||||
|
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||||
|
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
|
||||||
|
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package com.jfoenix.controls;
|
||||||
|
|
||||||
|
import com.jfoenix.skins.JFXCheckBoxSkin;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.CheckBoxTreeItem;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
|
||||||
|
///
|
||||||
|
/// The checkbox's selected and indeterminate states are bidirectionally bound to
|
||||||
|
/// the current tree item. Reusing or clearing the cell removes the old bindings.
|
||||||
|
/// Installing a checkbox skin or switching tree items applies the selection state
|
||||||
|
/// immediately, without playing a selection animation.
|
||||||
|
/// Ordinary tree items retain the display provided by [JFXTreeCell].
|
||||||
|
/// Empty cells and `null` values display neither content nor a checkbox.
|
||||||
|
///
|
||||||
|
/// @param <T> the type of value stored in each tree item
|
||||||
|
@NotNullByDefault
|
||||||
|
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
|
||||||
|
|
||||||
|
/// The checkbox bound to the currently displayed checkable tree item.
|
||||||
|
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||||
|
|
||||||
|
/// Places the checkbox before the graphic supplied by the superclass.
|
||||||
|
private final HBox graphic = new HBox();
|
||||||
|
|
||||||
|
/// The tree item whose selection properties are currently bound, if any.
|
||||||
|
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
|
||||||
|
|
||||||
|
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
|
||||||
|
public JFXCheckTreeCell() {
|
||||||
|
checkBox.setAllowIndeterminate(false);
|
||||||
|
checkBox.setFocusTraversable(false);
|
||||||
|
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
graphic.setPickOnBounds(false);
|
||||||
|
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
|
||||||
|
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finishes animations from the previous item and immediately displays the current state.
|
||||||
|
private void finishSelectionAnimation() {
|
||||||
|
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
|
||||||
|
skin.finishSelectionAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the checkbox to the inherited display and updates its selection bindings.
|
||||||
|
@Override
|
||||||
|
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||||
|
// Release graphics before the superclass reuses or reparents them.
|
||||||
|
graphic.getChildren().clear();
|
||||||
|
super.updateDisplay(item, empty);
|
||||||
|
|
||||||
|
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
|
||||||
|
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
|
||||||
|
if (boundTreeItem != treeItem) {
|
||||||
|
if (boundTreeItem != null) {
|
||||||
|
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
|
||||||
|
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
|
||||||
|
}
|
||||||
|
boundTreeItem = treeItem;
|
||||||
|
if (treeItem != null) {
|
||||||
|
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
|
||||||
|
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
|
||||||
|
} else {
|
||||||
|
checkBox.setSelected(false);
|
||||||
|
checkBox.setIndeterminate(false);
|
||||||
|
}
|
||||||
|
finishSelectionAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (treeItem != null) {
|
||||||
|
@Nullable Node itemGraphic = getGraphic();
|
||||||
|
graphic.getChildren().add(checkBox);
|
||||||
|
if (itemGraphic != null) {
|
||||||
|
graphic.getChildren().add(itemGraphic);
|
||||||
|
}
|
||||||
|
setGraphic(graphic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
|||||||
|
|
||||||
private void initialize() {
|
private void initialize() {
|
||||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||||
this.setCellFactory(listView -> new JFXListCell<T>() {
|
this.setCellFactory(listView -> new JFXListCell<>() {
|
||||||
@Override
|
@Override
|
||||||
public void updateItem(T item, boolean empty) {
|
public void updateItem(T item, boolean empty) {
|
||||||
super.updateItem(item, 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
|
// had to refactor the code out of the skin class to allow
|
||||||
// customization of the button cell
|
// customization of the button cell
|
||||||
this.setButtonCell(new ListCell<T>() {
|
this.setButtonCell(new ListCell<>() {
|
||||||
{
|
{
|
||||||
// fixed clearing the combo box value is causing
|
// fixed clearing the combo box value is causing
|
||||||
// java prompt text to be shown because the button cell is not updated
|
// java prompt text to be shown because the button cell is not updated
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void updateItem(T item, boolean empty) {
|
protected void updateItem(T item, boolean empty) {
|
||||||
|
T oldItem = getItem();
|
||||||
|
boolean oldEmpty = isEmpty();
|
||||||
|
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
|
||||||
super.updateItem(item, empty);
|
super.updateItem(item, empty);
|
||||||
if (empty) {
|
if (empty) {
|
||||||
setText(null);
|
setText(null);
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import javafx.scene.paint.Paint;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||||
|
|
||||||
@@ -68,14 +67,18 @@ public class JFXPasswordField extends PasswordField {
|
|||||||
|
|
||||||
private void initialize() {
|
private void initialize() {
|
||||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
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);
|
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'.
|
* Initialize the style class to 'jfx-password-field'.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -294,4 +297,3 @@ public class JFXPasswordField extends PasswordField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +278,10 @@ public class JFXRippler extends StackPane {
|
|||||||
rippler.releaseRipple();
|
rippler.releaseRipple();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void releaseRippleImmediately() {
|
||||||
|
rippler.releaseRippleImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* creates Ripple effect in the center of the control
|
* creates Ripple effect in the center of the control
|
||||||
*
|
*
|
||||||
@@ -405,6 +409,21 @@ public class JFXRippler extends StackPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void releaseRippleImmediately() {
|
||||||
|
Ripple ripple = ripplesQueue.poll();
|
||||||
|
if (ripple != null) {
|
||||||
|
getChildren().remove(ripple);
|
||||||
|
if (generating.getAndSet(false)) {
|
||||||
|
if (overlayRect != null) {
|
||||||
|
overlayRect.inAnimation.stop();
|
||||||
|
if (!forceOverlay) {
|
||||||
|
overlayRect.setOpacity(0D);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void cacheRippleClip(boolean cached) {
|
void cacheRippleClip(boolean cached) {
|
||||||
cacheRipplerClip = cached;
|
cacheRipplerClip = cached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ public class JFXSnackbarLayout extends BorderPane {
|
|||||||
StackPane toastContainer = new StackPane(toast);
|
StackPane toastContainer = new StackPane(toast);
|
||||||
toastContainer.setPadding(new Insets(20));
|
toastContainer.setPadding(new Insets(20));
|
||||||
actionContainer = new StackPane();
|
actionContainer = new StackPane();
|
||||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
actionContainer.setVisible(false);
|
||||||
|
actionContainer.setManaged(false);
|
||||||
|
|
||||||
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
||||||
if (getPrefWidth() == -1) {
|
if (getPrefWidth() == -1) {
|
||||||
@@ -78,6 +79,7 @@ public class JFXSnackbarLayout extends BorderPane {
|
|||||||
actionContainer.getChildren().add(action);
|
actionContainer.getChildren().add(action);
|
||||||
|
|
||||||
if (!actionText.isEmpty()) {
|
if (!actionText.isEmpty()) {
|
||||||
|
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||||
action.setVisible(true);
|
action.setVisible(true);
|
||||||
actionContainer.setVisible(true);
|
actionContainer.setVisible(true);
|
||||||
actionContainer.setManaged(true);
|
actionContainer.setManaged(true);
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package com.jfoenix.controls;
|
||||||
|
|
||||||
|
import com.jfoenix.skins.JFXSwitchSkin;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.css.CssMetaData;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
|
import javafx.css.SimpleStyleableDoubleProperty;
|
||||||
|
import javafx.css.Styleable;
|
||||||
|
import javafx.css.StyleableDoubleProperty;
|
||||||
|
import javafx.css.StyleablePropertyFactory;
|
||||||
|
import javafx.event.ActionEvent;
|
||||||
|
import javafx.event.EventHandler;
|
||||||
|
import javafx.scene.AccessibleAction;
|
||||||
|
import javafx.scene.AccessibleAttribute;
|
||||||
|
import javafx.scene.AccessibleRole;
|
||||||
|
import javafx.scene.control.Control;
|
||||||
|
import javafx.scene.control.Skin;
|
||||||
|
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/// A Material Design 3 switch for a boolean setting.
|
||||||
|
///
|
||||||
|
/// Supports clicking, dragging, and Space-key activation. Labels belong to the surrounding layout;
|
||||||
|
/// supply an accessible text description. Size and colors are configurable through CSS.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class JFXSwitch extends Control {
|
||||||
|
|
||||||
|
/// Creates an unselected switch.
|
||||||
|
public JFXSwitch() {
|
||||||
|
getStyleClass().setAll("jfx-switch");
|
||||||
|
setFocusTraversable(true);
|
||||||
|
setAccessibleRole(AccessibleRole.TOGGLE_BUTTON);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection state.
|
||||||
|
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected") {
|
||||||
|
/// Updates visual and accessible selection without firing an action.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
pseudoClassStateChanged(SELECTED, get());
|
||||||
|
notifyAccessibleAttributeChanged(AccessibleAttribute.SELECTED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Changes selection without firing an action event.
|
||||||
|
public final void setSelected(boolean value) {
|
||||||
|
selected.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the switch is selected.
|
||||||
|
public final boolean isSelected() {
|
||||||
|
return selected.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bindable selection state; the default is false.
|
||||||
|
public final BooleanProperty selectedProperty() {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only activation state.
|
||||||
|
private final ReadOnlyBooleanWrapper armed = new ReadOnlyBooleanWrapper(this, "armed") {
|
||||||
|
/// Reflects input feedback in CSS.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
pseudoClassStateChanged(ARMED, get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Returns whether the switch is armed.
|
||||||
|
public final boolean isArmed() {
|
||||||
|
return armed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the read-only state indicating a pending activation.
|
||||||
|
public final ReadOnlyBooleanProperty armedProperty() {
|
||||||
|
return armed.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional action handler.
|
||||||
|
private @Nullable ObjectProperty<@Nullable EventHandler<ActionEvent>> onAction;
|
||||||
|
|
||||||
|
/// Sets or clears the action handler.
|
||||||
|
public final void setOnAction(@Nullable EventHandler<ActionEvent> handler) {
|
||||||
|
if (onAction != null || handler != null)
|
||||||
|
onActionProperty().set(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the action handler, or null when none is installed.
|
||||||
|
public final @Nullable EventHandler<ActionEvent> getOnAction() {
|
||||||
|
return onAction == null ? null : onAction.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the handler property for activation events.
|
||||||
|
public final ObjectProperty<@Nullable EventHandler<ActionEvent>> onActionProperty() {
|
||||||
|
if (onAction == null) {
|
||||||
|
onAction = new SimpleObjectProperty<>(this, "onAction") {
|
||||||
|
/// Updates the action handler.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
setEventHandler(ActionEvent.ACTION, get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return onAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The requested track height in logical pixels.
|
||||||
|
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(SIZE, this, "size", 32.0) {
|
||||||
|
/// Validates the size and requests layout.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
get();
|
||||||
|
requestLayout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Sets the track height; see [#sizeProperty()].
|
||||||
|
public final void setSize(double value) {
|
||||||
|
size.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the requested track height in logical pixels.
|
||||||
|
public final double getSize() {
|
||||||
|
return size.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track height in logical pixels, styled by `-jfx-size`; the default is 32.
|
||||||
|
/// Scales the artwork and interaction target proportionally; nonpositive or nonfinite sizes render at the default size.
|
||||||
|
public final StyleableDoubleProperty sizeProperty() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional selected-icon property.
|
||||||
|
private @Nullable BooleanProperty showSelectedIcon;
|
||||||
|
|
||||||
|
/// Sets the value of [#showSelectedIconProperty()].
|
||||||
|
public final void setShowSelectedIcon(boolean value) {
|
||||||
|
if (showSelectedIcon != null || value)
|
||||||
|
showSelectedIconProperty().set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the selected icon is shown.
|
||||||
|
public final boolean isShowSelectedIcon() {
|
||||||
|
return showSelectedIcon != null && showSelectedIcon.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to show a check mark when selected; defaults to false.
|
||||||
|
public final BooleanProperty showSelectedIconProperty() {
|
||||||
|
if (showSelectedIcon == null)
|
||||||
|
showSelectedIcon = createOptionProperty("showSelectedIcon", false, false);
|
||||||
|
return showSelectedIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional unselected-icon property.
|
||||||
|
private @Nullable BooleanProperty showUnselectedIcon;
|
||||||
|
|
||||||
|
/// Sets the value of [#showUnselectedIconProperty()].
|
||||||
|
public final void setShowUnselectedIcon(boolean value) {
|
||||||
|
if (showUnselectedIcon != null || value)
|
||||||
|
showUnselectedIconProperty().set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the unselected icon is shown.
|
||||||
|
public final boolean isShowUnselectedIcon() {
|
||||||
|
return showUnselectedIcon != null && showUnselectedIcon.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to show a cross when unselected; defaults to false.
|
||||||
|
public final BooleanProperty showUnselectedIconProperty() {
|
||||||
|
if (showUnselectedIcon == null)
|
||||||
|
showUnselectedIcon = createOptionProperty("showUnselectedIcon", false, false);
|
||||||
|
return showUnselectedIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional animation policy override.
|
||||||
|
private @Nullable BooleanProperty disableAnimation;
|
||||||
|
|
||||||
|
/// Sets the value of [#disableAnimationProperty()].
|
||||||
|
public final void setDisableAnimation(boolean value) {
|
||||||
|
if (disableAnimation != null || value == AnimationUtils.isAnimationEnabled())
|
||||||
|
disableAnimationProperty().set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether animation is disabled.
|
||||||
|
public final boolean isDisableAnimation() {
|
||||||
|
return disableAnimation == null ? !AnimationUtils.isAnimationEnabled() : disableAnimation.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables animation, defaulting to the inverse of the global animation-enabled setting.
|
||||||
|
public final BooleanProperty disableAnimationProperty() {
|
||||||
|
if (disableAnimation == null)
|
||||||
|
disableAnimation = createOptionProperty("disableAnimation", !AnimationUtils.isAnimationEnabled(), true);
|
||||||
|
return disableAnimation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arms input feedback unless the switch is disabled or its selection is bound.
|
||||||
|
public final void arm() {
|
||||||
|
if (!isDisabled() && !selected.isBound())
|
||||||
|
armed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears input feedback without firing an action.
|
||||||
|
public final void disarm() {
|
||||||
|
armed.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles selection and fires one action; disabled or bound switches ignore activation.
|
||||||
|
public void fire() {
|
||||||
|
if (!isDisabled() && !selected.isBound()) {
|
||||||
|
setSelected(!isSelected());
|
||||||
|
fireEvent(new ActionEvent(this, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the default switch skin.
|
||||||
|
@Override
|
||||||
|
protected Skin<?> createDefaultSkin() {
|
||||||
|
return new JFXSwitchSkin(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a property that updates the skin, optionally settling active animations.
|
||||||
|
private BooleanProperty createOptionProperty(String name, boolean initialValue, boolean finishAnimation) {
|
||||||
|
return new SimpleBooleanProperty(this, name, initialValue) {
|
||||||
|
/// Validates the value even without a skin, so subsequent changes also reach this callback.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
get();
|
||||||
|
if (getSkin() instanceof JFXSwitchSkin skin)
|
||||||
|
skin.updateOptions(finishAnimation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indicates that the switch is selected.
|
||||||
|
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||||
|
|
||||||
|
/// Indicates a pending activation.
|
||||||
|
private static final PseudoClass ARMED = PseudoClass.getPseudoClass("armed");
|
||||||
|
|
||||||
|
/// Factory for switch CSS metadata.
|
||||||
|
private static final StyleablePropertyFactory<JFXSwitch> STYLEABLES =
|
||||||
|
new StyleablePropertyFactory<>(Control.getClassCssMetaData());
|
||||||
|
|
||||||
|
/// CSS metadata for track height.
|
||||||
|
private static final CssMetaData<JFXSwitch, Number> SIZE =
|
||||||
|
STYLEABLES.createSizeCssMetaData("-jfx-size", control -> control.size, 32.0);
|
||||||
|
|
||||||
|
/// Switch and inherited CSS metadata.
|
||||||
|
private static final @Unmodifiable List<CssMetaData<? extends Styleable, ?>> CSS_META_DATA =
|
||||||
|
List.copyOf(STYLEABLES.getCssMetaData());
|
||||||
|
|
||||||
|
/// Returns immutable CSS metadata, including inherited control properties.
|
||||||
|
public static @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||||
|
return CSS_META_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the metadata used to style this switch.
|
||||||
|
@Override
|
||||||
|
public @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||||
|
return getClassCssMetaData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exposes selection to assistive technology.
|
||||||
|
@Override
|
||||||
|
public @Nullable Object queryAccessibleAttribute(AccessibleAttribute attribute, @Nullable Object... parameters) {
|
||||||
|
return attribute == AccessibleAttribute.SELECTED ? isSelected() : super.queryAccessibleAttribute(attribute, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles accessible activation.
|
||||||
|
@Override
|
||||||
|
public void executeAccessibleAction(AccessibleAction action, @Nullable Object... parameters) {
|
||||||
|
if (action == AccessibleAction.FIRE)
|
||||||
|
fire();
|
||||||
|
else
|
||||||
|
super.executeAccessibleAction(action, parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
/*
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.jfoenix.controls;
|
|
||||||
|
|
||||||
import com.jfoenix.skins.JFXToggleButtonSkin;
|
|
||||||
import javafx.css.*;
|
|
||||||
import javafx.css.converter.BooleanConverter;
|
|
||||||
import javafx.css.converter.PaintConverter;
|
|
||||||
import javafx.scene.control.Skin;
|
|
||||||
import javafx.scene.control.ToggleButton;
|
|
||||||
import javafx.scene.paint.Color;
|
|
||||||
import javafx.scene.paint.Paint;
|
|
||||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JFXToggleButton is the material design implementation of a toggle button.
|
|
||||||
* important CSS Selectors:
|
|
||||||
* <p>
|
|
||||||
* .jfx-toggle-button{
|
|
||||||
* -fx-toggle-color: color-value;
|
|
||||||
* -fx-untoggle-color: color-value;
|
|
||||||
* -fx-toggle-line-color: color-value;
|
|
||||||
* -fx-untoggle-line-color: color-value;
|
|
||||||
* }
|
|
||||||
* <p>
|
|
||||||
* To change the rippler color when toggled:
|
|
||||||
* <p>
|
|
||||||
* .jfx-toggle-button .jfx-rippler{
|
|
||||||
* -fx-rippler-fill: color-value;
|
|
||||||
* }
|
|
||||||
* <p>
|
|
||||||
* .jfx-toggle-button:selected .jfx-rippler{
|
|
||||||
* -fx-rippler-fill: color-value;
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @author Shadi Shaheen
|
|
||||||
* @version 1.0
|
|
||||||
* @since 2016-03-09
|
|
||||||
*/
|
|
||||||
public class JFXToggleButton extends ToggleButton {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritDoc}
|
|
||||||
*/
|
|
||||||
public JFXToggleButton() {
|
|
||||||
initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritDoc}
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
protected Skin<?> createDefaultSkin() {
|
|
||||||
return new JFXToggleButtonSkin(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initialize() {
|
|
||||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
|
||||||
// it's up for the user to add this behavior
|
|
||||||
// toggleColor.addListener((o, oldVal, newVal) -> {
|
|
||||||
// // update line color in case not set by the user
|
|
||||||
// if(newVal instanceof Color)
|
|
||||||
// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter());
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
/***************************************************************************
|
|
||||||
* *
|
|
||||||
* styleable Properties *
|
|
||||||
* *
|
|
||||||
**************************************************************************/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the style class to 'jfx-toggle-button'.
|
|
||||||
* <p>
|
|
||||||
* This is the selector class from which CSS can be used to style
|
|
||||||
* this control.
|
|
||||||
*/
|
|
||||||
private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* default color used when the button is toggled
|
|
||||||
*/
|
|
||||||
private final StyleableObjectProperty<Paint> toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"toggleColor",
|
|
||||||
Color.valueOf(
|
|
||||||
"#009688"));
|
|
||||||
|
|
||||||
public Paint getToggleColor() {
|
|
||||||
return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StyleableObjectProperty<Paint> toggleColorProperty() {
|
|
||||||
return this.toggleColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setToggleColor(Paint color) {
|
|
||||||
this.toggleColor.set(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* default color used when the button is not toggled
|
|
||||||
*/
|
|
||||||
private StyleableObjectProperty<Paint> untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"unToggleColor",
|
|
||||||
Color.valueOf(
|
|
||||||
"#FAFAFA"));
|
|
||||||
|
|
||||||
public Paint getUnToggleColor() {
|
|
||||||
return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StyleableObjectProperty<Paint> unToggleColorProperty() {
|
|
||||||
return this.untoggleColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUnToggleColor(Paint color) {
|
|
||||||
this.untoggleColor.set(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* default line color used when the button is toggled
|
|
||||||
*/
|
|
||||||
private final StyleableObjectProperty<Paint> toggleLineColor = new SimpleStyleableObjectProperty<>(
|
|
||||||
StyleableProperties.TOGGLE_LINE_COLOR,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"toggleLineColor",
|
|
||||||
Color.valueOf("#77C2BB"));
|
|
||||||
|
|
||||||
public Paint getToggleLineColor() {
|
|
||||||
return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StyleableObjectProperty<Paint> toggleLineColorProperty() {
|
|
||||||
return this.toggleLineColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setToggleLineColor(Paint color) {
|
|
||||||
this.toggleLineColor.set(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* default line color used when the button is not toggled
|
|
||||||
*/
|
|
||||||
private final StyleableObjectProperty<Paint> untoggleLineColor = new SimpleStyleableObjectProperty<>(
|
|
||||||
StyleableProperties.UNTOGGLE_LINE_COLOR,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"unToggleLineColor",
|
|
||||||
Color.valueOf("#999999"));
|
|
||||||
|
|
||||||
public Paint getUnToggleLineColor() {
|
|
||||||
return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StyleableObjectProperty<Paint> unToggleLineColorProperty() {
|
|
||||||
return this.untoggleLineColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUnToggleLineColor(Paint color) {
|
|
||||||
this.untoggleLineColor.set(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default size of the toggle button.
|
|
||||||
*/
|
|
||||||
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(
|
|
||||||
StyleableProperties.SIZE,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"size",
|
|
||||||
10.0);
|
|
||||||
|
|
||||||
public double getSize() {
|
|
||||||
return size.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StyleableDoubleProperty sizeProperty() {
|
|
||||||
return this.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSize(double size) {
|
|
||||||
this.size.set(size);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable the visual indicator for focus
|
|
||||||
*/
|
|
||||||
private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"disableVisualFocus",
|
|
||||||
false);
|
|
||||||
|
|
||||||
public final StyleableBooleanProperty disableVisualFocusProperty() {
|
|
||||||
return this.disableVisualFocus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public final Boolean isDisableVisualFocus() {
|
|
||||||
return disableVisualFocus != null && this.disableVisualFocusProperty().get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public final void setDisableVisualFocus(final Boolean disabled) {
|
|
||||||
this.disableVisualFocusProperty().set(disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* disable animation on button action
|
|
||||||
*/
|
|
||||||
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
|
|
||||||
JFXToggleButton.this,
|
|
||||||
"disableAnimation",
|
|
||||||
!AnimationUtils.isAnimationEnabled());
|
|
||||||
|
|
||||||
public final StyleableBooleanProperty disableAnimationProperty() {
|
|
||||||
return this.disableAnimation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public final Boolean isDisableAnimation() {
|
|
||||||
return disableAnimation != null && this.disableAnimationProperty().get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public final void setDisableAnimation(final Boolean disabled) {
|
|
||||||
this.disableAnimationProperty().set(disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class StyleableProperties {
|
|
||||||
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_COLOR =
|
|
||||||
new CssMetaData<>("-jfx-toggle-color",
|
|
||||||
PaintConverter.getInstance(), Color.valueOf("#009688")) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.toggleColor == null || !control.toggleColor.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.toggleColorProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_COLOR =
|
|
||||||
new CssMetaData<>("-jfx-untoggle-color",
|
|
||||||
PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.untoggleColor == null || !control.untoggleColor.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.unToggleColorProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_LINE_COLOR =
|
|
||||||
new CssMetaData<>("-jfx-toggle-line-color",
|
|
||||||
PaintConverter.getInstance(), Color.valueOf("#77C2BB")) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.toggleLineColor == null || !control.toggleLineColor.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.toggleLineColorProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_LINE_COLOR =
|
|
||||||
new CssMetaData<>("-jfx-untoggle-line-color",
|
|
||||||
PaintConverter.getInstance(), Color.valueOf("#999999")) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.untoggleLineColor == null || !control.untoggleLineColor.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.unToggleLineColorProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final CssMetaData<JFXToggleButton, Number> SIZE =
|
|
||||||
new CssMetaData<>("-jfx-size",
|
|
||||||
StyleConverter.getSizeConverter(), 10.0) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return !control.size.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableProperty<Number> getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.sizeProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_VISUAL_FOCUS =
|
|
||||||
new CssMetaData<>("-jfx-disable-visual-focus",
|
|
||||||
BooleanConverter.getInstance(), false) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.disableVisualFocus == null || !control.disableVisualFocus.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.disableVisualFocusProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_ANIMATION =
|
|
||||||
new CssMetaData<>("-jfx-disable-animation",
|
|
||||||
BooleanConverter.getInstance(), false) {
|
|
||||||
@Override
|
|
||||||
public boolean isSettable(JFXToggleButton control) {
|
|
||||||
return control.disableAnimation == null || !control.disableAnimation.isBound();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
|
|
||||||
return control.disableAnimationProperty();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
|
||||||
|
|
||||||
static {
|
|
||||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
|
||||||
new ArrayList<>(ToggleButton.getClassCssMetaData());
|
|
||||||
Collections.addAll(styleables,
|
|
||||||
SIZE,
|
|
||||||
TOGGLE_COLOR,
|
|
||||||
UNTOGGLE_COLOR,
|
|
||||||
TOGGLE_LINE_COLOR,
|
|
||||||
UNTOGGLE_LINE_COLOR,
|
|
||||||
DISABLE_VISUAL_FOCUS,
|
|
||||||
DISABLE_ANIMATION
|
|
||||||
);
|
|
||||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
|
||||||
return getClassCssMetaData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
|
||||||
return StyleableProperties.CHILD_STYLEABLES;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
|
|||||||
import javafx.scene.control.TreeItem;
|
import javafx.scene.control.TreeItem;
|
||||||
import javafx.scene.layout.*;
|
import javafx.scene.layout.*;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
|
||||||
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
|||||||
treeItemRef = new WeakReference<>(newTreeItem);
|
treeItemRef = new WeakReference<>(newTreeItem);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
|
treeItemProperty().addListener(treeItemInvalidationListener);
|
||||||
treeItemProperty().addListener(weakTreeItemListener);
|
|
||||||
if (getTreeItem() != null) {
|
if (getTreeItem() != null) {
|
||||||
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||||
}
|
}
|
||||||
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
|||||||
selectedPane.setVisible(isSelected());
|
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) {
|
if (item == null || empty) {
|
||||||
hbox = null;
|
hbox = null;
|
||||||
setText(null);
|
setText(null);
|
||||||
setGraphic(null);
|
setGraphic(null);
|
||||||
} else {
|
} else {
|
||||||
TreeItem<T> treeItem = getTreeItem();
|
@Nullable TreeItem<T> treeItem = getTreeItem();
|
||||||
if (treeItem != null && treeItem.getGraphic() != null) {
|
if (treeItem != null && treeItem.getGraphic() != null) {
|
||||||
if (item instanceof Node) {
|
if (item instanceof Node) {
|
||||||
setText(null);
|
setText(null);
|
||||||
|
|||||||
@@ -12,35 +12,25 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
|
|||||||
import com.jfoenix.transitions.CachedTransition;
|
import com.jfoenix.transitions.CachedTransition;
|
||||||
import com.jfoenix.transitions.JFXFillTransition;
|
import com.jfoenix.transitions.JFXFillTransition;
|
||||||
import com.jfoenix.utils.JFXNodeUtils;
|
import com.jfoenix.utils.JFXNodeUtils;
|
||||||
import javafx.animation.Interpolator;
|
import javafx.animation.*;
|
||||||
import javafx.animation.KeyFrame;
|
|
||||||
import javafx.animation.KeyValue;
|
|
||||||
import javafx.animation.Timeline;
|
|
||||||
import javafx.animation.Transition;
|
|
||||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
import javafx.geometry.HPos;
|
import javafx.geometry.HPos;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.VPos;
|
import javafx.geometry.VPos;
|
||||||
import javafx.scene.control.CheckBox;
|
import javafx.scene.control.CheckBox;
|
||||||
import javafx.scene.control.skin.CheckBoxSkin;
|
import javafx.scene.control.skin.CheckBoxSkin;
|
||||||
import javafx.scene.layout.AnchorPane;
|
import javafx.scene.layout.*;
|
||||||
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.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.scene.shape.SVGPath;
|
import javafx.scene.shape.SVGPath;
|
||||||
import javafx.util.Duration;
|
import javafx.util.Duration;
|
||||||
import org.jackhuang.hmcl.theme.Themes;
|
import org.jackhuang.hmcl.theme.Themes;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||||
|
|
||||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||||
private final StackPane box = new StackPane();
|
private final StackPane box = new StackPane();
|
||||||
private final StackPane mark = new StackPane();
|
private final StackPane mark = new StackPane();
|
||||||
|
private final SVGPath markShape = new SVGPath();
|
||||||
private final double lineThick = 2.0;
|
private final double lineThick = 2.0;
|
||||||
private final double padding = 10.0;
|
private final double padding = 10.0;
|
||||||
private final JFXRippler rippler;
|
private final JFXRippler rippler;
|
||||||
@@ -48,7 +38,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
|||||||
private final double labelOffset = -8.0;
|
private final double labelOffset = -8.0;
|
||||||
private final Transition transition;
|
private final Transition transition;
|
||||||
private boolean invalid = true;
|
private boolean invalid = true;
|
||||||
private JFXFillTransition select;
|
private boolean selected;
|
||||||
|
private boolean indeterminate;
|
||||||
|
|
||||||
|
/// The checkbox fill animation, initialized with the configured checked color.
|
||||||
|
private JFXFillTransition fillTransition;
|
||||||
|
|
||||||
public JFXCheckBoxSkin(JFXCheckBox control) {
|
public JFXCheckBoxSkin(JFXCheckBox control) {
|
||||||
super(control);
|
super(control);
|
||||||
@@ -61,22 +55,17 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
|||||||
boxContainer.getChildren().add(this.box);
|
boxContainer.getChildren().add(this.box);
|
||||||
boxContainer.setPadding(new Insets(this.padding));
|
boxContainer.setPadding(new Insets(this.padding));
|
||||||
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
|
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
|
||||||
this.updateRippleColor();
|
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
|
||||||
SVGPath shape = new SVGPath();
|
this.mark.setShape(this.markShape);
|
||||||
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
this.updateMarkShape(true, false);
|
||||||
this.mark.setShape(shape);
|
|
||||||
this.mark.setMaxSize(15.0, 12.0);
|
|
||||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
|
||||||
this.mark.setVisible(false);
|
this.mark.setVisible(false);
|
||||||
this.mark.setScaleX(0.0);
|
this.mark.setScaleX(0.0);
|
||||||
this.mark.setScaleY(0.0);
|
this.mark.setScaleY(0.0);
|
||||||
boxContainer.getChildren().add(this.mark);
|
boxContainer.getChildren().add(this.mark);
|
||||||
this.container.getChildren().add(this.rippler);
|
this.container.getChildren().add(this.rippler);
|
||||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||||
control.selectedProperty().addListener((o, oldVal, newVal) -> {
|
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
|
||||||
this.updateRippleColor();
|
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
|
||||||
this.playSelectAnimation(newVal);
|
|
||||||
});
|
|
||||||
|
|
||||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||||
if (focusVisibleProperty == null)
|
if (focusVisibleProperty == null)
|
||||||
@@ -93,37 +82,63 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
|||||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||||
this.updateChildren();
|
this.updateChildren();
|
||||||
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
|
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
|
||||||
if (select != null) {
|
if (fillTransition != null) {
|
||||||
select.stop();
|
fillTransition.stop();
|
||||||
}
|
}
|
||||||
this.createFillTransition();
|
boolean selected = control.isSelected();
|
||||||
updateColors();
|
boolean indeterminate = control.isIndeterminate();
|
||||||
|
JFXFillTransition fillTransition = this.createFillTransition();
|
||||||
|
this.fillTransition = fillTransition;
|
||||||
|
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||||
|
updateColors(selected, indeterminate);
|
||||||
});
|
});
|
||||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
|
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
|
||||||
this.transition = new CheckBoxTransition();
|
this.transition = new CheckBoxTransition();
|
||||||
this.createFillTransition();
|
this.fillTransition = this.createFillTransition();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateRippleColor() {
|
private void requestSelectionUpdate() {
|
||||||
var control = (JFXCheckBox) this.getSkinnable();
|
this.invalid = true;
|
||||||
this.rippler.setRipplerFill(control.isSelected()
|
this.getSkinnable().requestLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateRippleColor(boolean selected, boolean indeterminate) {
|
||||||
|
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||||
|
this.rippler.setRipplerFill(selected || indeterminate
|
||||||
? control.getCheckedColor()
|
? control.getCheckedColor()
|
||||||
: control.getUnCheckedColor());
|
: control.getUnCheckedColor());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateColors() {
|
private void updateColors(boolean selected, boolean indeterminate) {
|
||||||
var control = (JFXCheckBox) getSkinnable();
|
var control = (JFXCheckBox) getSkinnable();
|
||||||
boolean isSelected = control.isSelected();
|
boolean marked = selected || indeterminate;
|
||||||
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
|
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||||
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
|
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||||
|
this.updateBorder(selected, indeterminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateBorder(boolean selected, boolean indeterminate) {
|
||||||
|
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||||
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
|
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
|
||||||
box.setBorder(new Border(new BorderStroke(
|
box.setBorder(new Border(new BorderStroke(
|
||||||
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||||
borderStroke.getTopStyle(),
|
borderStroke.getTopStyle(),
|
||||||
borderStroke.getRadii(),
|
borderStroke.getRadii(),
|
||||||
borderStroke.getWidths())));
|
borderStroke.getWidths())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateMarkShape(boolean selected, boolean indeterminate) {
|
||||||
|
if (indeterminate) {
|
||||||
|
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
|
||||||
|
this.mark.setMaxSize(10.0, 2.0);
|
||||||
|
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
|
||||||
|
} else if (selected) {
|
||||||
|
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||||
|
this.mark.setMaxSize(15.0, 12.0);
|
||||||
|
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected void updateChildren() {
|
protected void updateChildren() {
|
||||||
super.updateChildren();
|
super.updateChildren();
|
||||||
if (this.container != null) {
|
if (this.container != null) {
|
||||||
@@ -151,9 +166,10 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
|||||||
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
|
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
|
||||||
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
|
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
|
||||||
if (this.invalid) {
|
if (this.invalid) {
|
||||||
if (this.getSkinnable().isSelected()) {
|
boolean selected = checkBox.isSelected();
|
||||||
this.playSelectAnimation(true);
|
boolean indeterminate = checkBox.isIndeterminate();
|
||||||
}
|
this.updateRippleColor(selected, indeterminate);
|
||||||
|
this.playSelectAnimation(selected, indeterminate);
|
||||||
|
|
||||||
this.invalid = false;
|
this.invalid = false;
|
||||||
}
|
}
|
||||||
@@ -180,27 +196,85 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void playSelectAnimation(Boolean selection) {
|
private void playSelectAnimation(boolean selected, boolean indeterminate) {
|
||||||
if (selection == null) {
|
boolean previousSelected = this.selected;
|
||||||
selection = false;
|
boolean previousIndeterminate = this.indeterminate;
|
||||||
|
this.selected = selected;
|
||||||
|
this.indeterminate = indeterminate;
|
||||||
|
boolean previousMarked = previousSelected || previousIndeterminate;
|
||||||
|
boolean marked = selected || indeterminate;
|
||||||
|
boolean sameVisualState = previousIndeterminate == indeterminate
|
||||||
|
&& (indeterminate || previousSelected == selected);
|
||||||
|
this.updateBorder(selected, indeterminate);
|
||||||
|
|
||||||
|
if (sameVisualState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.invalid = false;
|
||||||
|
|
||||||
|
if (!AnimationUtils.isAnimationEnabled()) {
|
||||||
|
finishSelectionAnimation(selected, indeterminate);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
if (previousMarked && marked) {
|
||||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
this.transition.stop();
|
||||||
this.select.setRate(selection ? 1.0 : -1.0);
|
this.transition.setOnFinished(null);
|
||||||
|
this.transition.jumpTo(this.transition.getCycleDuration());
|
||||||
|
this.transition.setRate(-1.0);
|
||||||
|
this.transition.setOnFinished(event -> {
|
||||||
|
this.transition.setOnFinished(null);
|
||||||
|
this.updateMarkShape(selected, indeterminate);
|
||||||
|
this.transition.jumpTo(Duration.ZERO);
|
||||||
|
this.transition.setRate(1.0);
|
||||||
|
this.transition.play();
|
||||||
|
});
|
||||||
|
this.transition.play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transition.setOnFinished(null);
|
||||||
|
if (marked) {
|
||||||
|
this.updateMarkShape(selected, indeterminate);
|
||||||
|
}
|
||||||
|
this.transition.setRate(marked ? 1.0 : -1.0);
|
||||||
|
this.fillTransition.setRate(marked ? 1.0 : -1.0);
|
||||||
|
this.fillTransition.play();
|
||||||
this.transition.play();
|
this.transition.play();
|
||||||
this.select.play();
|
|
||||||
this.box.setBorder(new Border(new BorderStroke(
|
|
||||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
|
||||||
BorderStrokeStyle.SOLID,
|
|
||||||
new CornerRadii(2.0),
|
|
||||||
new BorderWidths(this.lineThick))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createFillTransition() {
|
/// Stops selection animations and immediately displays the checkbox's current state.
|
||||||
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
///
|
||||||
|
/// The state indicator, fill, and border are updated even if no animation is running.
|
||||||
|
/// This also suppresses pending selection updates and the initial selection animation.
|
||||||
|
/// The skin must not have been disposed.
|
||||||
|
public void finishSelectionAnimation() {
|
||||||
|
CheckBox checkBox = this.getSkinnable();
|
||||||
|
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
|
||||||
|
this.selected = selected;
|
||||||
|
this.indeterminate = indeterminate;
|
||||||
|
boolean marked = selected || indeterminate;
|
||||||
|
transition.stop();
|
||||||
|
transition.setOnFinished(null);
|
||||||
|
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
|
||||||
|
fillTransition.stop();
|
||||||
|
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||||
|
invalid = false;
|
||||||
|
updateMarkShape(selected, indeterminate);
|
||||||
|
mark.setVisible(marked);
|
||||||
|
mark.setScaleX(marked ? 1.0 : 0.0);
|
||||||
|
mark.setScaleY(marked ? 1.0 : 0.0);
|
||||||
|
updateColors(selected, indeterminate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JFXFillTransition createFillTransition() {
|
||||||
|
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||||
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
|
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
|
||||||
this.select.setInterpolator(Interpolator.EASE_OUT);
|
fillTransition.setInterpolator(Interpolator.EASE_OUT);
|
||||||
|
return fillTransition;
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class CheckBoxTransition extends CachedTransition {
|
private final class CheckBoxTransition extends CachedTransition {
|
||||||
|
|||||||
@@ -15,15 +15,23 @@ import javafx.beans.binding.Bindings;
|
|||||||
import javafx.beans.binding.BooleanBinding;
|
import javafx.beans.binding.BooleanBinding;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.event.EventTarget;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.ComboBoxBase;
|
import javafx.scene.control.ComboBoxBase;
|
||||||
|
import javafx.scene.control.ListView;
|
||||||
|
import javafx.scene.control.skin.VirtualFlow;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
import javafx.scene.layout.*;
|
import javafx.scene.layout.*;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.scene.paint.Paint;
|
import javafx.scene.paint.Paint;
|
||||||
import javafx.scene.text.Text;
|
import javafx.scene.text.Text;
|
||||||
import javafx.scene.transform.Scale;
|
import javafx.scene.transform.Scale;
|
||||||
import javafx.util.Duration;
|
import javafx.util.Duration;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||||
protected final ObjectProperty<Paint> promptTextFill;
|
protected final ObjectProperty<Paint> promptTextFill;
|
||||||
@@ -150,6 +158,35 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
|
||||||
|
FXUtils.smoothScrolling(virtualFlow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void layoutChildren(double x, double y, double w, double h) {
|
protected void layoutChildren(double x, double y, double w, double h) {
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
//
|
||||||
|
// Source code recreated from a .class file by IntelliJ IDEA
|
||||||
|
// (powered by Fernflower decompiler)
|
||||||
|
//
|
||||||
|
|
||||||
|
package com.jfoenix.skins;
|
||||||
|
|
||||||
|
import com.jfoenix.controls.JFXButton;
|
||||||
|
import com.jfoenix.controls.JFXDatePicker;
|
||||||
|
import com.jfoenix.controls.JFXListView;
|
||||||
|
import com.jfoenix.svg.SVGGlyph;
|
||||||
|
import com.jfoenix.transitions.CachedTransition;
|
||||||
|
import javafx.animation.*;
|
||||||
|
import javafx.animation.Animation.Status;
|
||||||
|
import javafx.beans.InvalidationListener;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.SnapshotParameters;
|
||||||
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.image.WritableImage;
|
||||||
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.input.MouseButton;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.layout.*;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.FontWeight;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.ui.SVG;
|
||||||
|
import org.jackhuang.hmcl.ui.SVGContainer;
|
||||||
|
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||||
|
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||||
|
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||||
|
|
||||||
|
import java.time.DateTimeException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Year;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.chrono.ChronoLocalDate;
|
||||||
|
import java.time.chrono.Chronology;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DecimalStyle;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.time.temporal.WeekFields;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
public class JFXDatePickerContent extends VBox {
|
||||||
|
private static final String SPINNER_LABEL = "spinner-label";
|
||||||
|
private static final String ROBOTO = "Roboto";
|
||||||
|
protected JFXDatePicker datePicker;
|
||||||
|
private JFXButton backMonthButton;
|
||||||
|
private JFXButton forwardMonthButton;
|
||||||
|
private Label selectedDateLabel;
|
||||||
|
private Label selectedYearLabel;
|
||||||
|
private Label monthYearLabel;
|
||||||
|
protected GridPane contentGrid;
|
||||||
|
private final StackPane calendarPlaceHolder = new StackPane();
|
||||||
|
private final CachedTransition showTransition;
|
||||||
|
private final CachedTransition hideTransition;
|
||||||
|
private ParallelTransition tempImageTransition;
|
||||||
|
private final int daysPerWeek = 7;
|
||||||
|
private final List<DateCell> weekDaysCells = new ArrayList<>();
|
||||||
|
private final List<DateCell> weekNumberCells = new ArrayList<>();
|
||||||
|
protected List<DateCell> dayCells = new ArrayList<>();
|
||||||
|
private LocalDate[] dayCellDates;
|
||||||
|
private DateCell currentFocusedDayCell = null;
|
||||||
|
private final ListView<String> yearsListView = new JFXListView<>() {
|
||||||
|
{
|
||||||
|
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
|
||||||
|
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||||
|
this.setCellFactory((listView) -> new ListCell<>() {
|
||||||
|
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||||
|
|
||||||
|
final Label label = new Label();
|
||||||
|
final StackPane root;
|
||||||
|
final RipplerContainer ripplerContainer;
|
||||||
|
|
||||||
|
final WeakListenerHolder holder = new WeakListenerHolder();
|
||||||
|
|
||||||
|
{
|
||||||
|
label.getStyleClass().add(SPINNER_LABEL);
|
||||||
|
label.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
label.textProperty().bind(itemProperty());
|
||||||
|
StackPane pane = new StackPane(label);
|
||||||
|
pane.setPadding(new Insets(7));
|
||||||
|
ripplerContainer = new RipplerContainer(pane);
|
||||||
|
root = new StackPane(ripplerContainer);
|
||||||
|
root.getStyleClass().add("data-picker-list-cell");
|
||||||
|
this.setGraphic(root);
|
||||||
|
|
||||||
|
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
|
||||||
|
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
|
||||||
|
if (newVal != null && getItem() != null && getItem().equals(newVal))
|
||||||
|
updateSelected(true);
|
||||||
|
});
|
||||||
|
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
|
||||||
|
itemProperty().addListener(selectedListenerWeak);
|
||||||
|
|
||||||
|
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
|
||||||
|
if (getListView() != null) {
|
||||||
|
if (selected) {
|
||||||
|
getListView().getSelectionModel().select(getIndex());
|
||||||
|
} else {
|
||||||
|
getListView().getSelectionModel().clearSelection(getIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.pseudoClassStateChanged(SELECTED, selected);
|
||||||
|
if (selected) {
|
||||||
|
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
|
||||||
|
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateItem(String item, boolean empty) {
|
||||||
|
String oldItem = getItem();
|
||||||
|
boolean oldEmpty = isEmpty();
|
||||||
|
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
setText(null);
|
||||||
|
|
||||||
|
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
|
||||||
|
ripplerContainer.resetFeedback();
|
||||||
|
|
||||||
|
if (!empty && item != null) {
|
||||||
|
setGraphic(root);
|
||||||
|
} else {
|
||||||
|
setGraphic(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
|
||||||
|
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
|
||||||
|
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
|
||||||
|
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
|
||||||
|
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
|
||||||
|
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
|
||||||
|
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
|
||||||
|
|
||||||
|
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
|
||||||
|
|
||||||
|
JFXDatePickerContent(final DatePicker datePicker) {
|
||||||
|
this.datePicker = (JFXDatePicker) datePicker;
|
||||||
|
this.getStyleClass().add("date-picker-popup");
|
||||||
|
LocalDate date = datePicker.getValue();
|
||||||
|
this.valueProperty.set(date);
|
||||||
|
this.valueProperty.addListener((o, old, n) -> {
|
||||||
|
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
|
||||||
|
this.updateValues();
|
||||||
|
});
|
||||||
|
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||||
|
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
|
||||||
|
this.getChildren().add(this.createHeaderPane());
|
||||||
|
this.getChildren().add(new Separator());
|
||||||
|
this.contentGrid = new GridPane() {
|
||||||
|
protected double computePrefWidth(double height) {
|
||||||
|
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||||
|
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
|
||||||
|
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
|
||||||
|
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
|
||||||
|
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
|
||||||
|
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void layoutChildren() {
|
||||||
|
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
|
||||||
|
super.layoutChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.contentGrid.setFocusTraversable(true);
|
||||||
|
this.contentGrid.getStyleClass().add("calendar-grid");
|
||||||
|
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||||
|
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
|
||||||
|
this.contentGrid.setVgap(4);
|
||||||
|
this.contentGrid.setHgap(4);
|
||||||
|
this.createWeekDaysCells();
|
||||||
|
this.createDayCells();
|
||||||
|
|
||||||
|
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
|
||||||
|
|
||||||
|
StackPane main = new StackPane();
|
||||||
|
this.yearsListView.setVisible(false);
|
||||||
|
this.yearsListView.setOpacity(0.0F);
|
||||||
|
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
|
||||||
|
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
|
||||||
|
main.getChildren().setAll(this.contentGrid, this.yearsListView);
|
||||||
|
|
||||||
|
VBox contentHolder = new VBox();
|
||||||
|
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
|
||||||
|
this.calendarPlaceHolder.getStyleClass().add("content");
|
||||||
|
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
|
||||||
|
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
|
||||||
|
|
||||||
|
this.getChildren().add(this.calendarPlaceHolder);
|
||||||
|
|
||||||
|
this.getChildren().add(this.createActions());
|
||||||
|
|
||||||
|
this.refresh();
|
||||||
|
this.addEventHandler(KeyEvent.ANY, (event) -> {
|
||||||
|
Node node = this.getScene().getFocusOwner();
|
||||||
|
if (node instanceof DateCell) {
|
||||||
|
this.currentFocusedDayCell = (DateCell) node;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.getCode()) {
|
||||||
|
case HOME -> {
|
||||||
|
this.init();
|
||||||
|
this.goToDate(LocalDate.now(), true);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
case PAGE_UP -> {
|
||||||
|
if (!this.backMonthButton.isDisabled()) {
|
||||||
|
this.forward(-1, ChronoUnit.MONTHS, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
case PAGE_DOWN -> {
|
||||||
|
if (!this.forwardMonthButton.isDisabled()) {
|
||||||
|
this.forward(1, ChronoUnit.MONTHS, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
case ESCAPE -> {
|
||||||
|
fireEvent(new DialogCloseEvent());
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
default -> event.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
|
||||||
|
{
|
||||||
|
this.setCycleDuration(Duration.millis(320.0F));
|
||||||
|
this.setDelay(Duration.seconds(0.0F));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void starting() {
|
||||||
|
super.starting();
|
||||||
|
JFXDatePickerContent.this.yearsListView.setVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||||
|
{
|
||||||
|
this.setCycleDuration(Duration.millis(320.0F));
|
||||||
|
this.setDelay(Duration.seconds(0.0F));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void stopping() {
|
||||||
|
super.stopping();
|
||||||
|
JFXDatePickerContent.this.yearsListView.setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectProperty<YearMonth> displayedYearMonthProperty() {
|
||||||
|
return this.selectedYearMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createWeekDaysCells() {
|
||||||
|
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||||
|
DateCell cell = new DateCell();
|
||||||
|
cell.getStyleClass().add("day-name-cell");
|
||||||
|
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||||
|
cell.setAlignment(Pos.BASELINE_CENTER);
|
||||||
|
this.weekDaysCells.add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
DateCell cell = new DateCell();
|
||||||
|
cell.getStyleClass().add("week-number-cell");
|
||||||
|
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||||
|
this.weekNumberCells.add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected VBox createHeaderPane() {
|
||||||
|
Label title = new Label(I18n.i18n("button.select_date"));
|
||||||
|
title.getStyleClass().add("title");
|
||||||
|
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
|
||||||
|
HBox titleContainer = new HBox(title);
|
||||||
|
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
titleContainer.setPadding(new Insets(0, 0, 5, 0));
|
||||||
|
this.selectedYearLabel = new Label();
|
||||||
|
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||||
|
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
|
||||||
|
HBox yearLabelContainer = new HBox();
|
||||||
|
yearLabelContainer.getStyleClass().add("spinner");
|
||||||
|
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
|
||||||
|
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
yearLabelContainer.setFillHeight(false);
|
||||||
|
yearLabelContainer.setOnMouseClicked((click) -> {
|
||||||
|
if (!this.yearsListView.isVisible()) {
|
||||||
|
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||||
|
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||||
|
this.hideTransition.stop();
|
||||||
|
this.showTransition.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
this.selectedDateLabel = new Label();
|
||||||
|
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
|
||||||
|
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
|
||||||
|
HBox selectedDateContainer = new HBox();
|
||||||
|
selectedDateContainer.getStyleClass().add("spinner");
|
||||||
|
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
|
||||||
|
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
selectedDateContainer.setOnMouseClicked((click) -> {
|
||||||
|
if (this.yearsListView.isVisible()) {
|
||||||
|
this.showTransition.stop();
|
||||||
|
this.hideTransition.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
VBox headerPanel = new VBox();
|
||||||
|
headerPanel.getStyleClass().add("month-year-pane");
|
||||||
|
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
|
||||||
|
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
|
||||||
|
return headerPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BorderPane createCalendarArrowsPane() {
|
||||||
|
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
|
||||||
|
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
|
||||||
|
leftChevron.setSize(6.0F, 11.0F);
|
||||||
|
rightChevron.setSize(6.0F, 11.0F);
|
||||||
|
this.backMonthButton = new JFXButton();
|
||||||
|
this.backMonthButton.setMinSize(40.0F, 40.0F);
|
||||||
|
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||||
|
this.backMonthButton.getStyleClass().add("left-button");
|
||||||
|
this.backMonthButton.setGraphic(leftChevron);
|
||||||
|
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||||
|
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
|
||||||
|
this.forwardMonthButton = new JFXButton();
|
||||||
|
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
|
||||||
|
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||||
|
this.forwardMonthButton.getStyleClass().add("right-button");
|
||||||
|
this.forwardMonthButton.setGraphic(rightChevron);
|
||||||
|
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||||
|
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
|
||||||
|
BorderPane arrowsContainer = new BorderPane();
|
||||||
|
arrowsContainer.setLeft(this.backMonthButton);
|
||||||
|
arrowsContainer.setRight(this.forwardMonthButton);
|
||||||
|
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
|
||||||
|
arrowsContainer.setPickOnBounds(false);
|
||||||
|
return arrowsContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BorderPane createCalendarMonthLabelPane() {
|
||||||
|
this.monthYearLabel = new Label();
|
||||||
|
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||||
|
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
|
||||||
|
SVGContainer svgContainer = new SVGContainer(13);
|
||||||
|
HBox box = new HBox(monthYearLabel, svgContainer);
|
||||||
|
box.setSpacing(3);
|
||||||
|
box.setAlignment(Pos.CENTER);
|
||||||
|
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
|
||||||
|
FXUtils.onClicked(box, () -> {
|
||||||
|
if (!this.yearsListView.isVisible()) {
|
||||||
|
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||||
|
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||||
|
this.hideTransition.stop();
|
||||||
|
this.showTransition.play();
|
||||||
|
} else {
|
||||||
|
this.showTransition.stop();
|
||||||
|
this.hideTransition.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BorderPane monthContainer = new BorderPane();
|
||||||
|
monthContainer.setMinHeight(50.0F);
|
||||||
|
monthContainer.setCenter(box);
|
||||||
|
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
|
||||||
|
return monthContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected HBox createActions() {
|
||||||
|
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
|
||||||
|
cancelButton.getStyleClass().add("dialog-cancel");
|
||||||
|
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
|
||||||
|
|
||||||
|
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
|
||||||
|
acceptButton.getStyleClass().add("dialog-accept");
|
||||||
|
FXUtils.onClicked(acceptButton, () -> {
|
||||||
|
datePicker.setValue(valueProperty.get());
|
||||||
|
fireEvent(new DialogCloseEvent());
|
||||||
|
});
|
||||||
|
|
||||||
|
HBox hBox = new HBox(cancelButton, acceptButton);
|
||||||
|
hBox.setPadding(new Insets(0, 5, 3, 0));
|
||||||
|
hBox.setSpacing(5);
|
||||||
|
hBox.setAlignment(Pos.CENTER_RIGHT);
|
||||||
|
return hBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateContentGrid() {
|
||||||
|
this.contentGrid.getColumnConstraints().clear();
|
||||||
|
this.contentGrid.getChildren().clear();
|
||||||
|
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||||
|
ColumnConstraints columnConstraints = new ColumnConstraints();
|
||||||
|
columnConstraints.setPercentWidth(100.0F);
|
||||||
|
|
||||||
|
for (int i = 0; i < colsNumber; ++i) {
|
||||||
|
this.contentGrid.getColumnConstraints().add(columnConstraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||||
|
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.datePicker.isShowWeekNumbers()) {
|
||||||
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < 6; ++row) {
|
||||||
|
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||||
|
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
this.updateDayNameCells();
|
||||||
|
this.updateValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDayNameCells() {
|
||||||
|
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||||
|
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
|
||||||
|
|
||||||
|
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||||
|
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
|
||||||
|
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
|
||||||
|
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.weekDaysCells.get(i).setText(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateValues() {
|
||||||
|
this.updateWeekNumberDateCells();
|
||||||
|
this.updateDayCells();
|
||||||
|
this.updateMonthYearPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateWeekNumberDateCells() {
|
||||||
|
if (this.datePicker.isShowWeekNumbers()) {
|
||||||
|
Locale locale = this.getLocale();
|
||||||
|
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
|
||||||
|
|
||||||
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
LocalDate date = firstDayOfMonth.plusWeeks(i);
|
||||||
|
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
|
||||||
|
this.weekNumberCells.get(i).setText(weekNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDayCells() {
|
||||||
|
Locale locale = this.getLocale();
|
||||||
|
Chronology chrono = this.getPrimaryChronology();
|
||||||
|
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||||
|
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
|
||||||
|
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
|
||||||
|
YearMonth currentYearMonth = this.selectedYearMonth.get();
|
||||||
|
int daysInCurMonth = -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
|
||||||
|
DateCell dayCell = this.dayCells.get(i);
|
||||||
|
try {
|
||||||
|
if (daysInCurMonth == -1) {
|
||||||
|
daysInCurMonth = currentYearMonth.lengthOfMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
int dayIndex = i - firstOfMonthIndex + 1;
|
||||||
|
LocalDate date;
|
||||||
|
if (dayIndex <= 0) {
|
||||||
|
YearMonth previous = currentYearMonth.minusMonths(1);
|
||||||
|
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
|
||||||
|
} else if (dayIndex > daysInCurMonth) {
|
||||||
|
YearMonth next = currentYearMonth.plusMonths(1);
|
||||||
|
date = next.atDay(dayIndex - daysInCurMonth);
|
||||||
|
} else {
|
||||||
|
date = currentYearMonth.atDay(dayIndex);
|
||||||
|
}
|
||||||
|
this.dayCellDates[i] = date;
|
||||||
|
if (date.equals(LocalDate.now())) {
|
||||||
|
dayCell.getStyleClass().add("today");
|
||||||
|
} else {
|
||||||
|
dayCell.getStyleClass().removeAll("today");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.equals(valueProperty.get())) {
|
||||||
|
dayCell.getStyleClass().add("selected");
|
||||||
|
this.currentFocusedDayCell = dayCell;
|
||||||
|
} else {
|
||||||
|
dayCell.getStyleClass().removeAll("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
ChronoLocalDate cDate = chrono.date(date);
|
||||||
|
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
|
||||||
|
dayCell.setText(cellText);
|
||||||
|
if (i < firstOfMonthIndex) {
|
||||||
|
dayCell.getStyleClass().add("previous-month");
|
||||||
|
dayCell.getStyleClass().removeAll("next-month");
|
||||||
|
dayCell.setDisable(true);
|
||||||
|
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
|
||||||
|
dayCell.getStyleClass().add("next-month");
|
||||||
|
dayCell.getStyleClass().removeAll("previous-month");
|
||||||
|
dayCell.setDisable(true);
|
||||||
|
} else {
|
||||||
|
dayCell.getStyleClass().removeAll("previous-month", "next-month");
|
||||||
|
dayCell.setDisable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
dayCell.updateItem(date, false);
|
||||||
|
} catch (DateTimeException var13) {
|
||||||
|
dayCell.setText("");
|
||||||
|
dayCell.setDisable(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void updateMonthYearPane() {
|
||||||
|
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||||
|
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
|
||||||
|
|
||||||
|
this.selectedYearLabel.setText(this.formatYear(yearMonth));
|
||||||
|
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
|
||||||
|
Chronology chrono = this.datePicker.getChronology();
|
||||||
|
LocalDate firstDayOfMonth = yearMonth.atDay(1);
|
||||||
|
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
|
||||||
|
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatMonth(YearMonth yearMonth) {
|
||||||
|
try {
|
||||||
|
Chronology chrono = this.getPrimaryChronology();
|
||||||
|
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||||
|
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
|
||||||
|
} catch (DateTimeException var4) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatYear(YearMonth yearMonth) {
|
||||||
|
try {
|
||||||
|
Chronology chrono = this.getPrimaryChronology();
|
||||||
|
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||||
|
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
|
||||||
|
} catch (DateTimeException var4) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected LocalDate dayCellDate(DateCell dateCell) {
|
||||||
|
assert this.dayCellDates != null;
|
||||||
|
|
||||||
|
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
|
||||||
|
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
|
||||||
|
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
|
||||||
|
this.getParent().setManaged(false);
|
||||||
|
SnapshotParameters snapShotparams = new SnapshotParameters();
|
||||||
|
snapShotparams.setFill(Color.TRANSPARENT);
|
||||||
|
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
|
||||||
|
ImageView tempImage = new ImageView(temp);
|
||||||
|
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
|
||||||
|
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
|
||||||
|
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
|
||||||
|
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
|
||||||
|
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
|
||||||
|
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
|
||||||
|
contentTransition.setToX(0.0F);
|
||||||
|
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
|
||||||
|
this.tempImageTransition.setOnFinished((finish) -> {
|
||||||
|
this.calendarPlaceHolder.getChildren().remove(tempImage);
|
||||||
|
this.getParent().setManaged(true);
|
||||||
|
});
|
||||||
|
this.tempImageTransition.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||||
|
DateCell dateCell = this.currentFocusedDayCell;
|
||||||
|
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
|
||||||
|
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.goToDayCell(dateCell, offset, unit, focusDayCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
|
||||||
|
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void goToDate(LocalDate date, boolean focusDayCell) {
|
||||||
|
if (this.isValidDate(this.datePicker.getChronology(), date)) {
|
||||||
|
this.valueProperty.set(date);
|
||||||
|
this.selectedYearMonth.set(YearMonth.from(date));
|
||||||
|
if (focusDayCell) {
|
||||||
|
this.findDayCellOfDate(date).requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectDayCell(DateCell dateCell) {
|
||||||
|
this.valueProperty.set(this.dayCellDate(dateCell));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateCell findDayCellOfDate(LocalDate date) {
|
||||||
|
for (int i = 0; i < this.dayCellDates.length; ++i) {
|
||||||
|
if (date.equals(this.dayCellDates[i])) {
|
||||||
|
return this.dayCells.get(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dayCells.get(this.dayCells.size() / 2 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
this.contentGrid.setOpacity(1.0F);
|
||||||
|
this.yearsListView.setOpacity(0.0F);
|
||||||
|
this.yearsListView.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFocus() {
|
||||||
|
LocalDate focusDate = this.valueProperty.get();
|
||||||
|
if (focusDate == null) {
|
||||||
|
focusDate = LocalDate.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
|
||||||
|
this.goToDate(focusDate, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void createDayCells() {
|
||||||
|
for (int row = 0; row < 6; ++row) {
|
||||||
|
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||||
|
DateCell dayCell = this.createDayCell();
|
||||||
|
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
|
||||||
|
if (click.getButton() == MouseButton.PRIMARY) {
|
||||||
|
DateCell selectedDayCell = (DateCell) click.getSource();
|
||||||
|
this.selectDayCell(selectedDayCell);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
|
||||||
|
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
|
||||||
|
dayCell.setAlignment(Pos.BASELINE_CENTER);
|
||||||
|
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
|
||||||
|
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||||
|
this.dayCells.add(dayCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
|
||||||
|
this.updateContentGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateCell createDayCell() {
|
||||||
|
DateCell dayCell = null;
|
||||||
|
if (this.datePicker.getDayCellFactory() != null) {
|
||||||
|
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayCell == null) {
|
||||||
|
dayCell = new DateCell();
|
||||||
|
}
|
||||||
|
dayCell.getStyleClass().add("day-cell");
|
||||||
|
dayCell.setPrefSize(36.0F, 36.0F);
|
||||||
|
dayCell.setDisable(false);
|
||||||
|
dayCell.setStyle(null);
|
||||||
|
dayCell.setGraphic(null);
|
||||||
|
dayCell.setTooltip(null);
|
||||||
|
|
||||||
|
return dayCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Chronology getPrimaryChronology() {
|
||||||
|
return this.datePicker.getChronology();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Locale getLocale() {
|
||||||
|
return SettingsManager.settings().languageProperty().get().getLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
|
||||||
|
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean isValidDate(Chronology chrono, LocalDate date) {
|
||||||
|
try {
|
||||||
|
if (date != null) {
|
||||||
|
chrono.date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (DateTimeException var4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//
|
||||||
|
// Source code recreated from a .class file by IntelliJ IDEA
|
||||||
|
// (powered by Fernflower decompiler)
|
||||||
|
//
|
||||||
|
|
||||||
|
package com.jfoenix.skins;
|
||||||
|
|
||||||
|
import com.jfoenix.adapters.ReflectionHelper;
|
||||||
|
import com.jfoenix.adapters.skins.DatePickerSkin;
|
||||||
|
import com.jfoenix.controls.JFXDatePicker;
|
||||||
|
import com.jfoenix.controls.JFXDialog;
|
||||||
|
import com.jfoenix.controls.JFXDialog.DialogTransition;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.event.ActionEvent;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.layout.*;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import org.jackhuang.hmcl.ui.SVG;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.Stack;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public class JFXDatePickerSkin extends DatePickerSkin {
|
||||||
|
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
|
||||||
|
|
||||||
|
private final JFXDatePicker jfxDatePicker;
|
||||||
|
private TextField displayNode;
|
||||||
|
private JFXDatePickerContent content;
|
||||||
|
private JFXDialog dialog;
|
||||||
|
|
||||||
|
public JFXDatePickerSkin(JFXDatePicker datePicker) {
|
||||||
|
super(datePicker);
|
||||||
|
this.jfxDatePicker = datePicker;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
|
||||||
|
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
|
||||||
|
|
||||||
|
int i = changeListeners.length - 1;
|
||||||
|
if (i >= 0) {
|
||||||
|
while (i >= 0 && changeListeners[i] == null) {
|
||||||
|
--i;
|
||||||
|
}
|
||||||
|
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
|
||||||
|
}
|
||||||
|
} catch (NullPointerException e) {
|
||||||
|
LOG.warning("Cannot remove focusedProperty listener", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
|
||||||
|
if (this.getEditor() != null && !newVal) {
|
||||||
|
this.setTextFromTextFieldIntoComboBoxValue2();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
|
||||||
|
arrowBox.setAlignment(Pos.CENTER);
|
||||||
|
this.setArrow(arrowBox);
|
||||||
|
this.getArrowButton().getChildren().setAll(arrowBox);
|
||||||
|
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||||
|
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
|
||||||
|
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
|
||||||
|
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
|
||||||
|
this.updateDisplayNode2();
|
||||||
|
this.content = null;
|
||||||
|
this.setPopup2(null);
|
||||||
|
});
|
||||||
|
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
|
||||||
|
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
|
||||||
|
if (this.jfxDatePicker.isShowing()) {
|
||||||
|
if (this.content != null) {
|
||||||
|
LocalDate date = this.jfxDatePicker.getValue();
|
||||||
|
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||||
|
this.content.updateValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
|
||||||
|
if (this.content != null) {
|
||||||
|
this.content.updateContentGrid();
|
||||||
|
this.content.updateWeekNumberDateCells();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
|
||||||
|
this.updateDisplayNode2();
|
||||||
|
this.jfxDatePicker.fireEvent(new ActionEvent());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JFXDatePickerContent getPopupContent() {
|
||||||
|
if (this.content == null) {
|
||||||
|
this.content = new JFXDatePickerContent(this.jfxDatePicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void show() {
|
||||||
|
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||||
|
super.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.content != null) {
|
||||||
|
this.content.init();
|
||||||
|
this.content.clearFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
|
||||||
|
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
|
||||||
|
if (dialogParent == null) {
|
||||||
|
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
super.close();
|
||||||
|
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
|
||||||
|
if (o instanceof Stack<?> stack) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
jfxDatePicker.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
|
||||||
|
getArrowButton().setOnMouseClicked((click) -> {
|
||||||
|
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||||
|
StackPane parent = this.jfxDatePicker.getDialogParent();
|
||||||
|
if (parent == null) {
|
||||||
|
parent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||||
|
}
|
||||||
|
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
|
||||||
|
contentStack.push(this.getPopupContent());
|
||||||
|
|
||||||
|
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
|
||||||
|
this.dialog.show(parent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
JFXDialog getDialog() {
|
||||||
|
return this.dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Node getDisplayNode() {
|
||||||
|
if (this.displayNode == null) {
|
||||||
|
this.displayNode = this.getEditableInputNode2();
|
||||||
|
this.displayNode.getStyleClass().add("date-picker-display-node");
|
||||||
|
this.updateDisplayNode2();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
|
||||||
|
return this.displayNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void syncWithAutoUpdate() {
|
||||||
|
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
|
||||||
|
this.jfxDatePicker.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
|||||||
|
|
||||||
arc = new Arc();
|
arc = new Arc();
|
||||||
arc.setManaged(false);
|
arc.setManaged(false);
|
||||||
arc.setLength(180);
|
arc.setLength(-180);
|
||||||
arc.getStyleClass().setAll("arc");
|
arc.getStyleClass().setAll("arc");
|
||||||
arc.setFill(Color.TRANSPARENT);
|
arc.setFill(Color.TRANSPARENT);
|
||||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||||
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
|||||||
|
|
||||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||||
new KeyValue(arc.startAngleProperty(),
|
new KeyValue(arc.startAngleProperty(),
|
||||||
angle + 45 + startingAngle,
|
-(angle + 45 + startingAngle),
|
||||||
Interpolator.LINEAR)));
|
Interpolator.LINEAR)));
|
||||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||||
new KeyValue(arc.startAngleProperty(),
|
new KeyValue(arc.startAngleProperty(),
|
||||||
angle + 90 + startingAngle,
|
-(angle + 90 + startingAngle),
|
||||||
Interpolator.LINEAR)));
|
Interpolator.LINEAR)));
|
||||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||||
new KeyValue(arc.startAngleProperty(),
|
new KeyValue(arc.startAngleProperty(),
|
||||||
angle + 135 + startingAngle,
|
-(angle + 135 + startingAngle),
|
||||||
Interpolator.LINEAR)));
|
Interpolator.LINEAR)));
|
||||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||||
new KeyValue(arc.startAngleProperty(),
|
new KeyValue(arc.startAngleProperty(),
|
||||||
angle + 435 + startingAngle,
|
-(angle + 435 + startingAngle),
|
||||||
Interpolator.LINEAR)));
|
Interpolator.LINEAR)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
|||||||
addKeyFrames(keyFrames, 900, 2.8);
|
addKeyFrames(keyFrames, 900, 2.8);
|
||||||
addKeyFrames(keyFrames, 1350, 4.2);
|
addKeyFrames(keyFrames, 1350, 4.2);
|
||||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||||
new KeyValue(arc.startAngleProperty(),
|
new KeyValue(arc.startAngleProperty(),
|
||||||
1845 + startingAngle,
|
-(1845 + startingAngle),
|
||||||
Interpolator.LINEAR)));
|
Interpolator.LINEAR)));
|
||||||
|
|
||||||
timeline = new Timeline();
|
timeline = new Timeline();
|
||||||
timeline.getKeyFrames().setAll(keyFrames);
|
timeline.getKeyFrames().setAll(keyFrames);
|
||||||
} else {
|
} else {
|
||||||
final double arcLength = 250;
|
final double arcLength = -250;
|
||||||
timeline = new Timeline(
|
timeline = new Timeline(
|
||||||
new KeyFrame(Duration.ZERO,
|
new KeyFrame(Duration.ZERO,
|
||||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
|
||||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||||
new KeyFrame(Duration.seconds(1.2),
|
new KeyFrame(Duration.seconds(1.2),
|
||||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
|
||||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package com.jfoenix.skins;
|
||||||
|
|
||||||
|
import com.jfoenix.controls.JFXSwitch;
|
||||||
|
import javafx.animation.KeyFrame;
|
||||||
|
import javafx.animation.KeyValue;
|
||||||
|
import javafx.animation.Timeline;
|
||||||
|
import javafx.animation.Transition;
|
||||||
|
import javafx.beans.property.DoubleProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleDoubleProperty;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.css.PseudoClass;
|
||||||
|
import javafx.css.StyleOrigin;
|
||||||
|
import javafx.css.StyleableProperty;
|
||||||
|
import javafx.event.EventHandler;
|
||||||
|
import javafx.geometry.Point2D;
|
||||||
|
import javafx.scene.control.SkinBase;
|
||||||
|
import javafx.scene.input.KeyCode;
|
||||||
|
import javafx.scene.input.KeyEvent;
|
||||||
|
import javafx.scene.input.MouseButton;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.layout.Pane;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
import javafx.scene.paint.Paint;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
|
import javafx.scene.shape.Rectangle;
|
||||||
|
import javafx.scene.shape.SVGPath;
|
||||||
|
import javafx.scene.transform.Scale;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/// Renders a Material Design 3 switch and handles input.
|
||||||
|
/// Geometry uses unscaled MD3 units and is mirrored in RTL layouts.
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class JFXSwitchSkin extends SkinBase<JFXSwitch> {
|
||||||
|
/// Track width in unscaled MD3 artwork coordinates.
|
||||||
|
private static final double TRACK_WIDTH = 52;
|
||||||
|
|
||||||
|
/// Track height corresponding to the control's default `-jfx-size`.
|
||||||
|
private static final double TRACK_HEIGHT = 32;
|
||||||
|
|
||||||
|
/// Unscaled interaction target height.
|
||||||
|
private static final double TARGET_HEIGHT = 48;
|
||||||
|
|
||||||
|
/// Vertical center shared by the track, handle, state layer, and icon.
|
||||||
|
private static final double CENTER_Y = TARGET_HEIGHT / 2;
|
||||||
|
|
||||||
|
/// Handle center at the leading end of the track.
|
||||||
|
private static final double OFF_CENTER = TRACK_HEIGHT / 2;
|
||||||
|
|
||||||
|
/// Handle center at the trailing end of the track.
|
||||||
|
private static final double ON_CENTER = TRACK_WIDTH - OFF_CENTER;
|
||||||
|
|
||||||
|
/// Diameter of the unselected handle without an icon.
|
||||||
|
private static final double OFF_DIAMETER = 16;
|
||||||
|
|
||||||
|
/// Diameter of a selected handle or a handle containing an icon.
|
||||||
|
private static final double ON_DIAMETER = 24;
|
||||||
|
|
||||||
|
/// Handle diameter while pressed or dragged.
|
||||||
|
private static final double PRESSED_DIAMETER = 28;
|
||||||
|
|
||||||
|
/// Radius of the hover, focus, and pressed overlay.
|
||||||
|
private static final double STATE_LAYER_RADIUS = 20;
|
||||||
|
|
||||||
|
/// Minimum pointer movement in control pixels before a press becomes a drag.
|
||||||
|
private static final double DRAG_THRESHOLD = 3;
|
||||||
|
|
||||||
|
/// Indicates visible keyboard focus.
|
||||||
|
private static final PseudoClass VISUAL_FOCUSED = PseudoClass.getPseudoClass("visual-focused");
|
||||||
|
|
||||||
|
/// The rendered switch.
|
||||||
|
private final JFXSwitch control;
|
||||||
|
|
||||||
|
/// Unmanaged switch artwork.
|
||||||
|
private final Pane artwork = new Pane();
|
||||||
|
|
||||||
|
/// Scales artwork from its leading top corner.
|
||||||
|
private final Scale artworkScale = new Scale();
|
||||||
|
|
||||||
|
/// Rounded track with its optional outline.
|
||||||
|
private final Rectangle track = new Rectangle(0, (TARGET_HEIGHT - TRACK_HEIGHT) / 2, TRACK_WIDTH, TRACK_HEIGHT);
|
||||||
|
|
||||||
|
/// Interaction feedback behind the handle.
|
||||||
|
private final Circle stateLayer = new Circle(OFF_CENTER, CENTER_Y, STATE_LAYER_RADIUS);
|
||||||
|
|
||||||
|
/// Switch handle.
|
||||||
|
private final Circle handle = new Circle(OFF_CENTER, CENTER_Y, OFF_DIAMETER / 2);
|
||||||
|
|
||||||
|
/// Optional handle icon.
|
||||||
|
private @Nullable SVGPath icon;
|
||||||
|
|
||||||
|
/// Current logical handle center measured from the track's leading edge.
|
||||||
|
private final DoubleProperty center = new SimpleDoubleProperty(OFF_CENTER) {
|
||||||
|
/// Updates handle position.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
layoutHandle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unscaled handle diameter.
|
||||||
|
private final DoubleProperty diameter = new SimpleDoubleProperty(OFF_DIAMETER) {
|
||||||
|
/// Updates handle size.
|
||||||
|
@Override
|
||||||
|
protected void invalidated() {
|
||||||
|
layoutHandle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Optional movement and resize animation.
|
||||||
|
private @Nullable Timeline geometryTransition;
|
||||||
|
|
||||||
|
/// Animated CSS paints.
|
||||||
|
private final List<CssColor> colors = new ArrayList<>();
|
||||||
|
|
||||||
|
/// Optional shared color animation.
|
||||||
|
private @Nullable Transition colorTransition;
|
||||||
|
|
||||||
|
/// Whether a selection color update is pending.
|
||||||
|
private boolean animateColors;
|
||||||
|
|
||||||
|
/// Prevents animation writes from being interpreted as new CSS targets.
|
||||||
|
private boolean applyingColors;
|
||||||
|
|
||||||
|
/// Pointer event handler.
|
||||||
|
private final EventHandler<MouseEvent> mouseHandler = this::handleMouse;
|
||||||
|
|
||||||
|
/// Keyboard event handler.
|
||||||
|
private final EventHandler<KeyEvent> keyHandler = this::handleKey;
|
||||||
|
|
||||||
|
/// Whether a primary press is awaiting click or drag completion.
|
||||||
|
private boolean mouseDown;
|
||||||
|
|
||||||
|
/// Whether the handle is following the pointer instead of an animation.
|
||||||
|
private boolean dragging;
|
||||||
|
|
||||||
|
/// Whether a Space press is awaiting release.
|
||||||
|
private boolean keyDown;
|
||||||
|
|
||||||
|
/// Pointer position in unscaled artwork coordinates at press time.
|
||||||
|
private double pressX;
|
||||||
|
|
||||||
|
/// Handle center at press time, preserving the pointer's grab offset.
|
||||||
|
private double pressCenter;
|
||||||
|
|
||||||
|
/// Creates a skin for the switch.
|
||||||
|
public JFXSwitchSkin(JFXSwitch control) {
|
||||||
|
super(control);
|
||||||
|
this.control = control;
|
||||||
|
artwork.getStyleClass().setAll("switch-artwork");
|
||||||
|
track.getStyleClass().setAll("switch-track");
|
||||||
|
stateLayer.getStyleClass().setAll("switch-state-layer");
|
||||||
|
handle.getStyleClass().setAll("switch-handle");
|
||||||
|
track.setArcWidth(TRACK_HEIGHT);
|
||||||
|
track.setArcHeight(TRACK_HEIGHT);
|
||||||
|
artwork.setManaged(false);
|
||||||
|
artwork.setMouseTransparent(true);
|
||||||
|
artwork.getTransforms().add(artworkScale);
|
||||||
|
artwork.getChildren().setAll(track, stateLayer, handle);
|
||||||
|
for (var node : artwork.getChildren())
|
||||||
|
node.setManaged(false);
|
||||||
|
getChildren().add(artwork);
|
||||||
|
registerChangeListener(control.selectedProperty(), value -> updateSelection());
|
||||||
|
registerChangeListener(control.armedProperty(), value -> update(false));
|
||||||
|
@Nullable ReadOnlyBooleanProperty focusVisible = FXUtils.focusVisibleProperty(control);
|
||||||
|
ReadOnlyBooleanProperty focusIndicator = focusVisible != null ? focusVisible : control.focusedProperty();
|
||||||
|
registerChangeListener(focusIndicator, value ->
|
||||||
|
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed()));
|
||||||
|
// Match the previous toggle skin: pointer press and release both dismiss focus feedback.
|
||||||
|
registerChangeListener(control.pressedProperty(), value ->
|
||||||
|
control.pseudoClassStateChanged(VISUAL_FOCUSED, false));
|
||||||
|
Consumer<ObservableValue<?>> cancelGesture = value -> cancelGesture();
|
||||||
|
registerChangeListener(control.disabledProperty(), cancelGesture);
|
||||||
|
registerChangeListener(control.sceneProperty(), cancelGesture);
|
||||||
|
registerChangeListener(control.focusedProperty(), value -> {
|
||||||
|
if (!control.isFocused())
|
||||||
|
cancelGesture();
|
||||||
|
});
|
||||||
|
control.addEventFilter(MouseEvent.ANY, mouseHandler);
|
||||||
|
control.addEventFilter(KeyEvent.ANY, keyHandler);
|
||||||
|
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed());
|
||||||
|
update(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates icon and animation options, optionally settling active animations.
|
||||||
|
public void updateOptions(boolean finishAnimation) {
|
||||||
|
if (finishAnimation)
|
||||||
|
finishColors();
|
||||||
|
update(finishAnimation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops animations and removes artwork, event handlers, and listeners.
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
control.removeEventFilter(MouseEvent.ANY, mouseHandler);
|
||||||
|
control.removeEventFilter(KeyEvent.ANY, keyHandler);
|
||||||
|
cancelGesture();
|
||||||
|
for (CssColor color : colors)
|
||||||
|
color.dispose();
|
||||||
|
control.pseudoClassStateChanged(VISUAL_FOCUSED, false);
|
||||||
|
getChildren().remove(artwork);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centers the scaled artwork within the control and starts pending color transitions.
|
||||||
|
@Override
|
||||||
|
protected void layoutChildren(double x, double y, double w, double h) {
|
||||||
|
double scale = sizeScale();
|
||||||
|
artworkScale.setX(scale);
|
||||||
|
artworkScale.setY(scale);
|
||||||
|
// Keep animation and CSS geometry in MD3 units; scale the entire artwork consistently.
|
||||||
|
artwork.resizeRelocate(x + (w - TRACK_WIDTH * scale) / 2,
|
||||||
|
y + (h - TARGET_HEIGHT * scale) / 2, TRACK_WIDTH, TARGET_HEIGHT);
|
||||||
|
layoutHandle();
|
||||||
|
// JavaFX applies CSS before layout, so all changed paints now share the same start time.
|
||||||
|
if (animateColors) {
|
||||||
|
animateColors = false;
|
||||||
|
for (CssColor color : colors) {
|
||||||
|
if (color.target != null) {
|
||||||
|
colorTransition().playFromStart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves the scaled track width and control insets.
|
||||||
|
@Override
|
||||||
|
protected double computeMinWidth(double h, double top, double right, double bottom, double left) {
|
||||||
|
return left + TRACK_WIDTH * sizeScale() + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves the scaled interaction target height and control insets.
|
||||||
|
@Override
|
||||||
|
protected double computeMinHeight(double w, double top, double right, double bottom, double left) {
|
||||||
|
return top + TARGET_HEIGHT * sizeScale() + bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the same fixed width as the minimum size.
|
||||||
|
@Override
|
||||||
|
protected double computePrefWidth(double h, double top, double right, double bottom, double left) {
|
||||||
|
return computeMinWidth(h, top, right, bottom, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the same fixed height as the minimum size.
|
||||||
|
@Override
|
||||||
|
protected double computePrefHeight(double w, double top, double right, double bottom, double left) {
|
||||||
|
return computeMinHeight(w, top, right, bottom, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limits width to the preferred size.
|
||||||
|
@Override
|
||||||
|
protected double computeMaxWidth(double h, double top, double right, double bottom, double left) {
|
||||||
|
return computePrefWidth(h, top, right, bottom, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limits height to the preferred size.
|
||||||
|
@Override
|
||||||
|
protected double computeMaxHeight(double w, double top, double right, double bottom, double left) {
|
||||||
|
return computePrefHeight(w, top, right, bottom, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles clicking and dragging.
|
||||||
|
private void handleMouse(MouseEvent event) {
|
||||||
|
if (event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) {
|
||||||
|
// Activation happens on release; do not also activate the surrounding settings row.
|
||||||
|
event.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
|
||||||
|
if (event.getButton() != MouseButton.PRIMARY || keyDown
|
||||||
|
|| event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown()
|
||||||
|
|| event.isMiddleButtonDown() || event.isSecondaryButtonDown())
|
||||||
|
return;
|
||||||
|
if (control.isDisabled() || control.selectedProperty().isBound())
|
||||||
|
return;
|
||||||
|
if (control.isFocusTraversable())
|
||||||
|
control.requestFocus();
|
||||||
|
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||||
|
if (point != null) {
|
||||||
|
mouseDown = true;
|
||||||
|
pressX = point.getX();
|
||||||
|
pressCenter = center.get();
|
||||||
|
control.arm();
|
||||||
|
}
|
||||||
|
event.consume();
|
||||||
|
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED && mouseDown && event.isPrimaryButtonDown()) {
|
||||||
|
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||||
|
if (point == null)
|
||||||
|
return;
|
||||||
|
if (!dragging && Math.abs(point.getX() - pressX) * sizeScale() < DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
|
dragging = true;
|
||||||
|
if (geometryTransition != null)
|
||||||
|
geometryTransition.stop();
|
||||||
|
control.arm();
|
||||||
|
diameter.set(PRESSED_DIAMETER);
|
||||||
|
center.set(dragCenter(point.getX()));
|
||||||
|
event.consume();
|
||||||
|
} else if (event.getEventType() == MouseEvent.MOUSE_RELEASED && event.getButton() == MouseButton.PRIMARY) {
|
||||||
|
if (!mouseDown) {
|
||||||
|
event.consume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@Nullable Point2D point = dragging
|
||||||
|
? artwork.sceneToLocal(event.getSceneX(), event.getSceneY())
|
||||||
|
: control.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||||
|
if (dragging && point != null)
|
||||||
|
center.set(dragCenter(point.getX()));
|
||||||
|
boolean activate = point != null && (dragging
|
||||||
|
? (center.get() >= (OFF_CENTER + ON_CENTER) / 2) != control.isSelected()
|
||||||
|
: control.isArmed() && control.contains(point));
|
||||||
|
mouseDown = false;
|
||||||
|
dragging = false;
|
||||||
|
control.disarm();
|
||||||
|
event.consume();
|
||||||
|
if (activate)
|
||||||
|
control.fire();
|
||||||
|
// Action handlers may replace or remove the skin synchronously.
|
||||||
|
if (control.getSkin() == this)
|
||||||
|
update(false);
|
||||||
|
} else if (mouseDown && !dragging) {
|
||||||
|
if (event.getEventType() == MouseEvent.MOUSE_ENTERED)
|
||||||
|
control.arm();
|
||||||
|
else if (event.getEventType() == MouseEvent.MOUSE_EXITED)
|
||||||
|
control.disarm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps pointer displacement to the track while preserving the initial grab offset.
|
||||||
|
private double dragCenter(double pointerX) {
|
||||||
|
return Math.max(OFF_CENTER, Math.min(ON_CENTER, pressCenter + pointerX - pressX));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles Space activation and Escape cancellation.
|
||||||
|
private void handleKey(KeyEvent event) {
|
||||||
|
if (event.getCode() == KeyCode.ESCAPE && event.getEventType() == KeyEvent.KEY_PRESSED) {
|
||||||
|
if (mouseDown || keyDown) {
|
||||||
|
boolean awaitingRelease = keyDown;
|
||||||
|
cancelGesture();
|
||||||
|
// Ignore auto-repeat until the canceled Space press is released.
|
||||||
|
keyDown = awaitingRelease;
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
} else if (event.getCode() == KeyCode.SPACE) {
|
||||||
|
if (event.getEventType() == KeyEvent.KEY_PRESSED) {
|
||||||
|
if (event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown())
|
||||||
|
return;
|
||||||
|
if (!keyDown) {
|
||||||
|
if (mouseDown || control.isDisabled() || control.selectedProperty().isBound())
|
||||||
|
return;
|
||||||
|
keyDown = true;
|
||||||
|
control.arm();
|
||||||
|
}
|
||||||
|
event.consume();
|
||||||
|
} else if (event.getEventType() == KeyEvent.KEY_RELEASED && keyDown) {
|
||||||
|
keyDown = false;
|
||||||
|
boolean activate = control.isArmed();
|
||||||
|
control.disarm();
|
||||||
|
event.consume();
|
||||||
|
if (activate)
|
||||||
|
control.fire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels input without changing selection.
|
||||||
|
private void cancelGesture() {
|
||||||
|
finishColors();
|
||||||
|
mouseDown = false;
|
||||||
|
keyDown = false;
|
||||||
|
dragging = false;
|
||||||
|
control.disarm();
|
||||||
|
update(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates geometry and schedules selection colors for the next CSS pass.
|
||||||
|
private void updateSelection() {
|
||||||
|
update(false);
|
||||||
|
if (control.isDisableAnimation() || control.isDisabled() || control.getScene() == null)
|
||||||
|
return;
|
||||||
|
if (colors.isEmpty()) {
|
||||||
|
colors.add(new CssColor(track.fillProperty()));
|
||||||
|
colors.add(new CssColor(handle.fillProperty()));
|
||||||
|
colors.add(new CssColor(stateLayer.fillProperty()));
|
||||||
|
colors.add(new CssColor(track.strokeProperty()));
|
||||||
|
if (icon != null)
|
||||||
|
colors.add(new CssColor(icon.fillProperty()));
|
||||||
|
}
|
||||||
|
if (colorTransition != null)
|
||||||
|
colorTransition.stop();
|
||||||
|
// A reversal before the first frame may reapply the visible paint without emitting a change event.
|
||||||
|
for (CssColor color : colors)
|
||||||
|
color.target = null;
|
||||||
|
animateColors = true;
|
||||||
|
control.requestLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates handle geometry, with optional animation.
|
||||||
|
private void update(boolean immediate) {
|
||||||
|
if (dragging)
|
||||||
|
return;
|
||||||
|
double currentCenter = center.get();
|
||||||
|
double currentDiameter = diameter.get();
|
||||||
|
if (geometryTransition != null)
|
||||||
|
geometryTransition.stop();
|
||||||
|
boolean selected = control.isSelected();
|
||||||
|
boolean pressed = control.isArmed() && !control.isDisabled();
|
||||||
|
boolean hasIcon = selected ? control.isShowSelectedIcon() : control.isShowUnselectedIcon();
|
||||||
|
double targetDiameter = pressed ? PRESSED_DIAMETER : selected || hasIcon ? ON_DIAMETER : OFF_DIAMETER;
|
||||||
|
double targetCenter = selected ? ON_CENTER : OFF_CENTER;
|
||||||
|
updateIcon(selected, hasIcon);
|
||||||
|
if (immediate || control.isDisableAnimation() || control.getScene() == null
|
||||||
|
|| currentCenter == targetCenter && currentDiameter == targetDiameter) {
|
||||||
|
center.set(targetCenter);
|
||||||
|
diameter.set(targetDiameter);
|
||||||
|
} else {
|
||||||
|
if (geometryTransition == null)
|
||||||
|
geometryTransition = new Timeline();
|
||||||
|
// Preserve the visible geometry when a press or reversal interrupts a transition.
|
||||||
|
center.set(currentCenter);
|
||||||
|
diameter.set(currentDiameter);
|
||||||
|
geometryTransition.getKeyFrames().setAll(
|
||||||
|
new KeyFrame(Duration.ZERO,
|
||||||
|
new KeyValue(center, currentCenter),
|
||||||
|
new KeyValue(diameter, currentDiameter)),
|
||||||
|
new KeyFrame(pressed ? Motion.SHORT2 : Motion.MEDIUM1,
|
||||||
|
new KeyValue(diameter, targetDiameter, pressed ? Motion.LINEAR : Motion.STANDARD)),
|
||||||
|
new KeyFrame(Motion.MEDIUM2,
|
||||||
|
new KeyValue(center, targetCenter, Motion.EASE_OUT_BACK)));
|
||||||
|
geometryTransition.play();
|
||||||
|
}
|
||||||
|
layoutHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the handle icon, creating it if needed.
|
||||||
|
private void updateIcon(boolean selected, boolean visible) {
|
||||||
|
if (icon == null && visible) {
|
||||||
|
icon = new SVGPath();
|
||||||
|
icon.getStyleClass().setAll("switch-icon");
|
||||||
|
icon.setManaged(false);
|
||||||
|
// The first CSS fill should appear immediately, without a transition from default black.
|
||||||
|
icon.setFill(null);
|
||||||
|
if (!colors.isEmpty())
|
||||||
|
colors.add(new CssColor(icon.fillProperty()));
|
||||||
|
artwork.getChildren().add(icon);
|
||||||
|
}
|
||||||
|
if (icon != null) {
|
||||||
|
icon.setVisible(visible);
|
||||||
|
if (visible)
|
||||||
|
icon.setContent(selected
|
||||||
|
? "M2 8l4 4 8-8-1.4-1.4L6 9.2 3.4 6.6Z"
|
||||||
|
: "M4 2.6l4 4 4-4L13.4 4l-4 4 4 4-1.4 1.4-4-4-4 4L2.6 12l4-4-4-4Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positions and sizes the handle and its overlays.
|
||||||
|
private void layoutHandle() {
|
||||||
|
double cx = center.get();
|
||||||
|
stateLayer.setCenterX(cx);
|
||||||
|
handle.setCenterX(cx);
|
||||||
|
handle.setRadius(diameter.get() / 2);
|
||||||
|
if (icon != null && icon.isVisible())
|
||||||
|
icon.relocate(cx - icon.getLayoutBounds().getWidth() / 2,
|
||||||
|
CENTER_Y - icon.getLayoutBounds().getHeight() / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the size scale, or 1 for an invalid size.
|
||||||
|
private double sizeScale() {
|
||||||
|
double size = control.getSize();
|
||||||
|
return Double.isFinite(size) && size > 0 ? size / TRACK_HEIGHT : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the shared color animation, creating it if needed.
|
||||||
|
private Transition colorTransition() {
|
||||||
|
if (colorTransition == null) {
|
||||||
|
colorTransition = new Transition() {
|
||||||
|
{
|
||||||
|
setCycleDuration(Motion.SHORT4);
|
||||||
|
setInterpolator(Motion.STANDARD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates only paints with a pending CSS target.
|
||||||
|
@Override
|
||||||
|
protected void interpolate(double fraction) {
|
||||||
|
for (CssColor color : colors)
|
||||||
|
color.interpolate(fraction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
colorTransition.setOnFinished(event -> finishColors());
|
||||||
|
}
|
||||||
|
return colorTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops color animation and applies pending targets.
|
||||||
|
private void finishColors() {
|
||||||
|
animateColors = false;
|
||||||
|
if (colorTransition != null)
|
||||||
|
colorTransition.stop();
|
||||||
|
for (CssColor color : colors)
|
||||||
|
color.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animates a solid paint while preserving CSS precedence.
|
||||||
|
private final class CssColor implements ChangeListener<@Nullable Paint> {
|
||||||
|
|
||||||
|
/// The animated paint property.
|
||||||
|
private final ObjectProperty<@Nullable Paint> property;
|
||||||
|
|
||||||
|
/// Most recent solid CSS target; null means no transition is pending.
|
||||||
|
private @Nullable Color target;
|
||||||
|
|
||||||
|
/// CSS origin of the target paint.
|
||||||
|
private @Nullable StyleOrigin origin;
|
||||||
|
|
||||||
|
/// Starting color.
|
||||||
|
private Color from = Color.TRANSPARENT;
|
||||||
|
|
||||||
|
/// Observes a styleable paint property.
|
||||||
|
private CssColor(ObjectProperty<@Nullable Paint> property) {
|
||||||
|
this.property = property;
|
||||||
|
property.addListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animates selection colors and applies other paint changes immediately.
|
||||||
|
@Override
|
||||||
|
public void changed(ObservableValue<? extends @Nullable Paint> observable,
|
||||||
|
@Nullable Paint previous, @Nullable Paint next) {
|
||||||
|
if (applyingColors)
|
||||||
|
return;
|
||||||
|
// CSS may reapply the same target while the displayed value is still interpolating.
|
||||||
|
if (target != null && target.equals(next) && previous != null) {
|
||||||
|
apply(previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target = null;
|
||||||
|
if (!animateColors || !(previous instanceof Color start) || !(next instanceof Color end) || start.equals(end))
|
||||||
|
return;
|
||||||
|
from = start;
|
||||||
|
target = end;
|
||||||
|
origin = ((StyleableProperty<?>) property).getStyleOrigin();
|
||||||
|
interpolate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an animation frame.
|
||||||
|
private void interpolate(double fraction) {
|
||||||
|
if (target != null)
|
||||||
|
apply(fraction == 1 ? target : from.interpolate(target, fraction));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a CSS value while suppressing this transition's own listener.
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void apply(Paint value) {
|
||||||
|
applyingColors = true;
|
||||||
|
try {
|
||||||
|
((StyleableProperty<Paint>) property).applyStyle(origin, value);
|
||||||
|
} finally {
|
||||||
|
applyingColors = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies and clears the pending target.
|
||||||
|
private void finish() {
|
||||||
|
if (target != null) {
|
||||||
|
apply(target);
|
||||||
|
target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the paint listener.
|
||||||
|
private void dispose() {
|
||||||
|
property.removeListener(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/*
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.jfoenix.skins;
|
|
||||||
|
|
||||||
import com.jfoenix.controls.JFXRippler;
|
|
||||||
import com.jfoenix.controls.JFXRippler.RipplerMask;
|
|
||||||
import com.jfoenix.controls.JFXRippler.RipplerPos;
|
|
||||||
import com.jfoenix.controls.JFXToggleButton;
|
|
||||||
import com.jfoenix.effects.JFXDepthManager;
|
|
||||||
import com.jfoenix.transitions.JFXAnimationTimer;
|
|
||||||
import com.jfoenix.transitions.JFXKeyFrame;
|
|
||||||
import com.jfoenix.transitions.JFXKeyValue;
|
|
||||||
import javafx.animation.Interpolator;
|
|
||||||
import javafx.geometry.Insets;
|
|
||||||
import javafx.scene.Cursor;
|
|
||||||
import javafx.scene.control.skin.ToggleButtonSkin;
|
|
||||||
import javafx.scene.layout.StackPane;
|
|
||||||
import javafx.scene.shape.Circle;
|
|
||||||
import javafx.scene.shape.Line;
|
|
||||||
import javafx.scene.shape.StrokeLineCap;
|
|
||||||
import javafx.util.Duration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <h1>Material Design ToggleButton Skin</h1>
|
|
||||||
*
|
|
||||||
* @author Shadi Shaheen
|
|
||||||
* @version 1.0
|
|
||||||
* @since 2016-03-09
|
|
||||||
*/
|
|
||||||
public class JFXToggleButtonSkin extends ToggleButtonSkin {
|
|
||||||
|
|
||||||
|
|
||||||
private Runnable releaseManualRippler = null;
|
|
||||||
|
|
||||||
private JFXAnimationTimer timer;
|
|
||||||
private final Circle circle;
|
|
||||||
private final Line line;
|
|
||||||
|
|
||||||
public JFXToggleButtonSkin(JFXToggleButton toggleButton) {
|
|
||||||
super(toggleButton);
|
|
||||||
|
|
||||||
double circleRadius = toggleButton.getSize();
|
|
||||||
|
|
||||||
line = new Line();
|
|
||||||
line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
|
||||||
line.setStartX(0);
|
|
||||||
line.setStartY(0);
|
|
||||||
line.setEndX(circleRadius * 2 + 2);
|
|
||||||
line.setEndY(0);
|
|
||||||
line.setStrokeWidth(circleRadius * 1.5);
|
|
||||||
line.setStrokeLineCap(StrokeLineCap.ROUND);
|
|
||||||
line.setSmooth(true);
|
|
||||||
|
|
||||||
circle = new Circle();
|
|
||||||
circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor());
|
|
||||||
circle.setCenterX(-circleRadius);
|
|
||||||
circle.setCenterY(0);
|
|
||||||
circle.setRadius(circleRadius);
|
|
||||||
circle.setSmooth(true);
|
|
||||||
JFXDepthManager.setDepth(circle, 1);
|
|
||||||
|
|
||||||
StackPane circlePane = new StackPane();
|
|
||||||
circlePane.getChildren().add(circle);
|
|
||||||
circlePane.setPadding(new Insets(circleRadius * 1.5));
|
|
||||||
|
|
||||||
JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK);
|
|
||||||
rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
|
||||||
rippler.setTranslateX(computeTranslation(circleRadius, line));
|
|
||||||
|
|
||||||
final StackPane main = new StackPane();
|
|
||||||
main.getChildren().setAll(line, rippler);
|
|
||||||
main.setCursor(Cursor.HAND);
|
|
||||||
|
|
||||||
// show focus traversal effect
|
|
||||||
getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
|
|
||||||
if (newVal) {
|
|
||||||
releaseManualRippler = rippler.createManualRipple();
|
|
||||||
} else if (releaseManualRippler != null) {
|
|
||||||
releaseManualRippler.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> {
|
|
||||||
if (!toggleButton.isDisableVisualFocus()) {
|
|
||||||
if (newVal) {
|
|
||||||
if (!getSkinnable().isPressed()) {
|
|
||||||
rippler.setOverlayVisible(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rippler.setOverlayVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false));
|
|
||||||
|
|
||||||
// add change listener to selected property
|
|
||||||
getSkinnable().selectedProperty().addListener(observable -> {
|
|
||||||
rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
|
||||||
if (!toggleButton.isDisableAnimation()) {
|
|
||||||
timer.reverseAndContinue();
|
|
||||||
} else {
|
|
||||||
rippler.setTranslateX(computeTranslation(circleRadius, line));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
getSkinnable().setGraphic(main);
|
|
||||||
|
|
||||||
timer = new JFXAnimationTimer(
|
|
||||||
new JFXKeyFrame(Duration.millis(100),
|
|
||||||
JFXKeyValue.builder()
|
|
||||||
.setTarget(rippler.translateXProperty())
|
|
||||||
.setEndValueSupplier(() -> computeTranslation(circleRadius, line))
|
|
||||||
.setInterpolator(Interpolator.EASE_BOTH)
|
|
||||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
|
||||||
.build(),
|
|
||||||
|
|
||||||
JFXKeyValue.builder()
|
|
||||||
.setTarget(line.strokeProperty())
|
|
||||||
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
|
|
||||||
((JFXToggleButton) getSkinnable()).getToggleLineColor()
|
|
||||||
: ((JFXToggleButton) getSkinnable()).getUnToggleLineColor())
|
|
||||||
.setInterpolator(Interpolator.EASE_BOTH)
|
|
||||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
|
||||||
.build(),
|
|
||||||
|
|
||||||
JFXKeyValue.builder()
|
|
||||||
.setTarget(circle.fillProperty())
|
|
||||||
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
|
|
||||||
((JFXToggleButton) getSkinnable()).getToggleColor()
|
|
||||||
: ((JFXToggleButton) getSkinnable()).getUnToggleColor())
|
|
||||||
.setInterpolator(Interpolator.EASE_BOTH)
|
|
||||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
timer.setCacheNodes(circle, line);
|
|
||||||
|
|
||||||
registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> {
|
|
||||||
if (getSkinnable().isSelected()) {
|
|
||||||
circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
|
|
||||||
if (!getSkinnable().isSelected()) {
|
|
||||||
circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> {
|
|
||||||
if (getSkinnable().isSelected()) {
|
|
||||||
line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
|
|
||||||
if (!getSkinnable().isSelected()) {
|
|
||||||
line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private double computeTranslation(double circleRadius, Line line) {
|
|
||||||
return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
timer.dispose();
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.FileSaver;
|
import org.jackhuang.hmcl.util.FileSaver;
|
||||||
@@ -23,6 +8,7 @@ import org.jackhuang.hmcl.util.SwingUtils;
|
|||||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.TLSSecurity;
|
||||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
|
||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
@@ -42,18 +28,18 @@ public final class EntryPoint {
|
|||||||
private EntryPoint() {
|
private EntryPoint() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initializes TLS protection before downloading dependencies and starting the launcher.
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
|
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
|
||||||
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
|
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
|
||||||
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
|
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
|
||||||
|
|
||||||
createHMCLDirectories();
|
createHMCLDirectories();
|
||||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
|
||||||
|
TLSSecurity.install();
|
||||||
|
|
||||||
checkWine();
|
checkWine();
|
||||||
|
|
||||||
setupJavaFXVMOptions();
|
|
||||||
|
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||||
if (!isInsideMacAppBundle())
|
if (!isInsideMacAppBundle())
|
||||||
@@ -74,95 +60,30 @@ public final class EntryPoint {
|
|||||||
System.exit(exitCode);
|
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() {
|
private static void createHMCLDirectories() {
|
||||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
|
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||||
try {
|
try {
|
||||||
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
|
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
|
||||||
} catch (IOException e) {
|
} 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) {
|
} catch (IOException e) {
|
||||||
// Logger has not been started yet, so print directly to System.err
|
// 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);
|
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 {
|
try {
|
||||||
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
|
Files.createDirectories(Metadata.HMCL_USER_HOME);
|
||||||
} catch (IOException e) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,15 +146,8 @@ public final class EntryPoint {
|
|||||||
|
|
||||||
private static void checkWine() {
|
private static void checkWine() {
|
||||||
if (OperatingSystem.isRunningUnderWine()) {
|
if (OperatingSystem.isRunningUnderWine()) {
|
||||||
SwingUtils.initLookAndFeel();
|
|
||||||
LOG.warning("HMCL is running under Wine or its distributions!");
|
LOG.warning("HMCL is running under Wine or its distributions!");
|
||||||
|
showWarning(i18n("fatal.wine_warning"));
|
||||||
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
|
||||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
|
||||||
|
|
||||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,10 +187,21 @@ public final class EntryPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void showWarning(String message) {
|
||||||
|
SwingUtils.initLookAndFeel();
|
||||||
|
|
||||||
|
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||||
|
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||||
|
|
||||||
|
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
* 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);
|
SwingUtils.showErrorDialog(message);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,49 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl;
|
||||||
|
|
||||||
|
import com.sun.jna.Pointer;
|
||||||
|
import com.sun.jna.WString;
|
||||||
import javafx.animation.KeyFrame;
|
import javafx.animation.KeyFrame;
|
||||||
import javafx.animation.KeyValue;
|
import javafx.animation.KeyValue;
|
||||||
import javafx.animation.Timeline;
|
import javafx.animation.Timeline;
|
||||||
import javafx.application.Application;
|
import javafx.application.Application;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.binding.Bindings;
|
||||||
import javafx.beans.value.ObservableBooleanValue;
|
import javafx.beans.value.ObservableBooleanValue;
|
||||||
import javafx.geometry.Rectangle2D;
|
import javafx.geometry.Rectangle2D;
|
||||||
import javafx.scene.control.Alert;
|
import javafx.scene.control.Alert;
|
||||||
import javafx.scene.control.Alert.AlertType;
|
import javafx.scene.control.Alert.AlertType;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.ButtonType;
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.scene.input.Clipboard;
|
|
||||||
import javafx.scene.input.DataFormat;
|
|
||||||
import javafx.stage.Screen;
|
import javafx.stage.Screen;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
import javafx.util.Duration;
|
import javafx.util.Duration;
|
||||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||||
import org.jackhuang.hmcl.setting.SambaException;
|
import org.jackhuang.hmcl.setting.*;
|
||||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.ui.Controllers;
|
import org.jackhuang.hmcl.ui.Controllers;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
import org.jackhuang.hmcl.theme.Themes;
|
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
|
||||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
|
||||||
import org.jackhuang.hmcl.util.*;
|
import org.jackhuang.hmcl.util.*;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||||
import org.jackhuang.hmcl.util.platform.*;
|
import org.jackhuang.hmcl.util.platform.*;
|
||||||
|
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
|
||||||
|
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||||
|
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||||
|
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||||
|
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import javax.swing.*;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.datatransfer.StringSelection;
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.lang.management.ManagementFactory;
|
import java.lang.management.ManagementFactory;
|
||||||
import java.lang.management.MemoryPoolMXBean;
|
import java.lang.management.MemoryPoolMXBean;
|
||||||
import java.net.CookieHandler;
|
import java.net.CookieHandler;
|
||||||
@@ -57,16 +53,19 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.text.DecimalFormat;
|
import java.text.DecimalFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
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.ui.FXUtils.runInFX;
|
||||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
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.LOG;
|
||||||
|
|
||||||
|
/// The main JavaFX application entry point.
|
||||||
public final class Launcher extends Application {
|
public final class Launcher extends Application {
|
||||||
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
||||||
|
|
||||||
@@ -94,21 +93,11 @@ public final class Launcher extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
initializeSettingsRuntime();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||||
&& ConfigHolder.isNewlyCreated()
|
&& SettingsManager.isNewlyCreated()
|
||||||
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
||||||
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
||||||
return;
|
return;
|
||||||
@@ -116,30 +105,26 @@ public final class Launcher extends Application {
|
|||||||
checkConfigInTempDir();
|
checkConfigInTempDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ConfigHolder.isOwnerChanged()) {
|
if (SettingsManager.isOwnerChanged()) {
|
||||||
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ConfigHolder.isUnsupportedVersion()) {
|
if (SettingsManager.hasReadOnlyCoreSettings()) {
|
||||||
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
|
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"));
|
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLater to ensure ConfigHolder.init() finished initialization
|
// runLater to ensure SettingsManager.init() finished initialization
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
// 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.
|
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
|
||||||
Platform.setImplicitExit(false);
|
Platform.setImplicitExit(false);
|
||||||
Controllers.initialize(primaryStage);
|
Controllers.initialize(primaryStage);
|
||||||
|
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
|
||||||
Themes.applyNativeDarkMode(primaryStage);
|
|
||||||
|
|
||||||
UpdateChecker.init();
|
|
||||||
|
|
||||||
primaryStage.show();
|
primaryStage.show();
|
||||||
});
|
});
|
||||||
@@ -148,6 +133,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) {
|
private static void appendScreen(StringBuilder builder, Screen screen) {
|
||||||
Rectangle2D bounds = screen.getBounds();
|
Rectangle2D bounds = screen.getBounds();
|
||||||
double scale = screen.getOutputScaleX();
|
double scale = screen.getOutputScaleX();
|
||||||
@@ -206,7 +211,7 @@ public final class Launcher extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isConfigInTempDir() {
|
private static boolean isConfigInTempDir() {
|
||||||
String configPath = ConfigHolder.configLocation().toString();
|
String configPath = SettingsManager.localConfigDirectory().toString();
|
||||||
|
|
||||||
String tmpdir = System.getProperty("java.io.tmpdir");
|
String tmpdir = System.getProperty("java.io.tmpdir");
|
||||||
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
||||||
@@ -243,64 +248,20 @@ public final class Launcher extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void checkConfigInTempDir() {
|
private static void checkConfigInTempDir() {
|
||||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||||
EntryPoint.exit(0);
|
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
|
@Override
|
||||||
public void stop() throws Exception {
|
public void stop() throws Exception {
|
||||||
Controllers.onApplicationStop();
|
Controllers.onApplicationStop();
|
||||||
FileSaver.shutdown();
|
SettingsManager.shutdown();
|
||||||
LOG.shutdown();
|
LOG.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
if (UpdateHandler.processArguments(args)) {
|
|
||||||
LOG.shutdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
|
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
|
||||||
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
|
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
|
||||||
|
|
||||||
@@ -313,6 +274,10 @@ public final class Launcher extends Application {
|
|||||||
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
|
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
|
||||||
}
|
}
|
||||||
LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName());
|
LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName());
|
||||||
|
try {
|
||||||
|
LOG.info("Process PID: " + ProcessHandle.current().pid());
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
}
|
||||||
LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET);
|
LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET);
|
||||||
LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding"));
|
LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding"));
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||||
@@ -323,10 +288,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 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("Java Home: " + System.getProperty("java.home"));
|
||||||
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
||||||
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
|
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
|
||||||
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||||
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
||||||
try {
|
try {
|
||||||
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
|
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
|
||||||
@@ -344,10 +309,27 @@ public final class Launcher extends Application {
|
|||||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable Boolean isRoot = isRootUser();
|
||||||
|
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
|
||||||
|
|
||||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||||
|
|
||||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
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);
|
launch(Launcher.class, args);
|
||||||
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
||||||
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
||||||
@@ -380,5 +362,283 @@ public final class Launcher extends Application {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
|
||||||
|
private static void setupWindowsAppUserModelID() {
|
||||||
|
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
|
||||||
|
return;
|
||||||
|
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||||
|
return;
|
||||||
|
@Nullable Path thisJar = JarUtils.thisJarPath();
|
||||||
|
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||||
|
if (hr < 0) {
|
||||||
|
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
|
||||||
|
} else {
|
||||||
|
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOG.warning("Failed to set AppUserModelID", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets JavaFX VM options before the toolkit starts.
|
||||||
|
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
|
||||||
|
private static void setupJavaFXVMOptions() {
|
||||||
|
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||||
|
LOG.info("HMCL_FORCE_GPU: true");
|
||||||
|
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpAnimationFrameRate:
|
||||||
|
{
|
||||||
|
if (System.getProperty("javafx.animation.pulse") != null) {
|
||||||
|
break setUpAnimationFrameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||||
|
if (animationFrameRate != null) {
|
||||||
|
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(animationFrameRate);
|
||||||
|
if (value <= 0)
|
||||||
|
throw new NumberFormatException(animationFrameRate);
|
||||||
|
|
||||||
|
if (value != 60)
|
||||||
|
System.setProperty("javafx.animation.pulse", animationFrameRate);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||||
|
}
|
||||||
|
break setUpAnimationFrameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
|
||||||
|
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
|
||||||
|
break setUpAnimationFrameRate;
|
||||||
|
|
||||||
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||||
|
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
|
||||||
|
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
|
||||||
|
if (pointer != null) {
|
||||||
|
try {
|
||||||
|
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
|
||||||
|
|
||||||
|
if (refreshRate > 0) {
|
||||||
|
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
|
||||||
|
|
||||||
|
if (refreshRate >= 90) {
|
||||||
|
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float uiScaleLowerBound;
|
||||||
|
float uiScaleUpperBound;
|
||||||
|
|
||||||
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||||
|
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||||
|
uiScaleLowerBound = 0.25f;
|
||||||
|
uiScaleUpperBound = 4f;
|
||||||
|
} else {
|
||||||
|
uiScaleLowerBound = 0.01f;
|
||||||
|
uiScaleUpperBound = 10f;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||||
|
|
||||||
|
float scaleValue = Float.NaN;
|
||||||
|
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
|
||||||
|
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (uiScale.endsWith("%")) {
|
||||||
|
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||||
|
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||||
|
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||||
|
} else {
|
||||||
|
scaleValue = Float.parseFloat(uiScale);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOG.warning("Invalid UI scale: " + uiScale);
|
||||||
|
}
|
||||||
|
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||||
|
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
|
||||||
|
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
|
||||||
|
|
||||||
|
if ("wayland".equals(xdgSessionType)
|
||||||
|
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
|
||||||
|
try {
|
||||||
|
@Nullable Path xrdb = SystemUtils.which("xrdb");
|
||||||
|
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
|
||||||
|
@Nullable String dpiLine;
|
||||||
|
|
||||||
|
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
|
||||||
|
BufferedReader bufferedReader = new BufferedReader(reader);
|
||||||
|
Stream<String> lines = bufferedReader.lines()) {
|
||||||
|
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dpiLine != null
|
||||||
|
? dpiLine.substring("Xft.dpi:".length()).trim()
|
||||||
|
: null;
|
||||||
|
}, java.time.Duration.ofSeconds(1)) : null;
|
||||||
|
|
||||||
|
if (xftDpi != null) {
|
||||||
|
float dpiValue = Float.parseFloat(xftDpi);
|
||||||
|
float scale = dpiValue / 96f;
|
||||||
|
|
||||||
|
if (scale > 1 && scale <= 10) {
|
||||||
|
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
|
||||||
|
scaleValue = scale;
|
||||||
|
uiScale = Float.toString(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to read Xft.dpi from xrdb", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Float.isFinite(scaleValue)) {
|
||||||
|
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
|
||||||
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||||
|
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||||
|
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||||
|
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||||
|
} else {
|
||||||
|
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG.warning("UI scale out of range: " + uiScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void checkConfigOwner() {
|
||||||
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||||
|
return;
|
||||||
|
|
||||||
|
String userName = System.getProperty("user.name");
|
||||||
|
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||||
|
if (!Files.exists(configDirectory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String owner;
|
||||||
|
try {
|
||||||
|
owner = Files.getOwner(configDirectory).getName();
|
||||||
|
} catch (IOException ioe) {
|
||||||
|
LOG.warning("Failed to get file owner", ioe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ArrayList<String> files = new ArrayList<>();
|
||||||
|
files.add(configDirectory.toString());
|
||||||
|
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||||
|
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||||
|
|
||||||
|
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||||
|
if (Files.exists(mcDir))
|
||||||
|
files.add(mcDir.toString());
|
||||||
|
|
||||||
|
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||||
|
SwingUtils.initLookAndFeel();
|
||||||
|
|
||||||
|
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
|
||||||
|
int result = JOptionPane.showOptionDialog(null,
|
||||||
|
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||||
|
i18n("message.error"),
|
||||||
|
JOptionPane.DEFAULT_OPTION,
|
||||||
|
JOptionPane.ERROR_MESSAGE,
|
||||||
|
null,
|
||||||
|
options,
|
||||||
|
options[0]);
|
||||||
|
|
||||||
|
if (result == 0) {
|
||||||
|
try {
|
||||||
|
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOG.warning("Failed to copy command to clipboard", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntryPoint.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @Nullable Boolean isRootUser() {
|
||||||
|
switch (OperatingSystem.CURRENT_OS) {
|
||||||
|
case WINDOWS -> {
|
||||||
|
WinReg reg = WinReg.INSTANCE;
|
||||||
|
|
||||||
|
if (reg == null)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to check Windows administrator privileges", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case LINUX -> {
|
||||||
|
Path selfStatus = Paths.get("/proc/self/status");
|
||||||
|
if (Files.exists(selfStatus)) {
|
||||||
|
try (var reader = Files.newBufferedReader(selfStatus)) {
|
||||||
|
@Nullable String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.startsWith("Uid:")) {
|
||||||
|
String[] parts = line.split("\t");
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
String uid = parts[2].trim();
|
||||||
|
return "0".equals(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to read " + selfStatus, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case MACOS -> {
|
||||||
|
if (NativeUtils.USE_JNA) {
|
||||||
|
LibSystem libSystem = LibSystem.INSTANCE;
|
||||||
|
if (libSystem != null) {
|
||||||
|
try {
|
||||||
|
return libSystem.geteuid() == 0;
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOG.warning("Failed to check macOS root privileges", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
|
||||||
|
@Nullable Path id = SystemUtils.which("id");
|
||||||
|
if (id != null) {
|
||||||
|
try {
|
||||||
|
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to run id -u", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.StringUtils;
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
@@ -23,6 +8,7 @@ import org.jackhuang.hmcl.util.platform.Architecture;
|
|||||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
|
||||||
@@ -37,31 +23,28 @@ public final class Metadata {
|
|||||||
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
||||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
|
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 TITLE = NAME + " " + VERSION;
|
||||||
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
|
|
||||||
|
/// Whether the build metadata identifies this launcher as an official build.
|
||||||
|
public static final boolean OFFICIAL_BUILD = Boolean.parseBoolean(JarUtils.getAttribute("hmcl.official", "false"));
|
||||||
|
|
||||||
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
|
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
|
||||||
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
|
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
|
||||||
public static final int RECOMMENDED_JAVA_VERSION = 21;
|
public static final int RECOMMENDED_JAVA_VERSION = 25;
|
||||||
|
|
||||||
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
|
|
||||||
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
|
|
||||||
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
|
|
||||||
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
|
|
||||||
|
|
||||||
public static final String DOCS_URL = "https://docs.hmcl.net";
|
public static final String DOCS_URL = "https://docs.hmcl.net";
|
||||||
public static final String CONTACT_URL = DOCS_URL + "/help.html";
|
|
||||||
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
|
|
||||||
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
|
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
|
||||||
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
|
|
||||||
|
|
||||||
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
||||||
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
||||||
|
|
||||||
public static final Path CURRENT_DIRECTORY = Path.of(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 MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||||
public static final Path HMCL_GLOBAL_DIRECTORY;
|
public static final Path HMCL_USER_HOME;
|
||||||
public static final Path HMCL_CURRENT_DIRECTORY;
|
public static final Path HMCL_LOCAL_HOME;
|
||||||
public static final Path DEPENDENCIES_DIRECTORY;
|
public static final Path DEPENDENCIES_DIRECTORY;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -70,26 +53,26 @@ public final class Metadata {
|
|||||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||||
if (StringUtils.isNotBlank(xdgData)) {
|
if (StringUtils.isNotBlank(xdgData)) {
|
||||||
HMCL_GLOBAL_DIRECTORY = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||||
} else {
|
} else {
|
||||||
HMCL_GLOBAL_DIRECTORY = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HMCL_GLOBAL_DIRECTORY = Path.of(hmclHome).toAbsolutePath().normalize();
|
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||||
HMCL_CURRENT_DIRECTORY = StringUtils.isNotBlank(hmclCurrentDir)
|
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
|
||||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||||
|
|
||||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||||
: HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
: HMCL_LOCAL_HOME.resolve("dependencies");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isStable() {
|
public static boolean isStable() {
|
||||||
@@ -106,7 +89,7 @@ public final class Metadata {
|
|||||||
|
|
||||||
public static @Nullable String getSuggestedJavaDownloadLink() {
|
public static @Nullable String getSuggestedJavaDownloadLink() {
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
|
||||||
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
|
return "https://www.loongnix.cn/zh/api/java/downloads-jdk25/index.html";
|
||||||
else {
|
else {
|
||||||
EnumSet<Architecture> supportedArchitectures;
|
EnumSet<Architecture> supportedArchitectures;
|
||||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||||
@@ -130,4 +113,46 @@ public final class Metadata {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
|
||||||
|
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
|
||||||
|
|
||||||
|
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
|
||||||
|
public static Path getBundledModpackDirectory() {
|
||||||
|
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
|
||||||
|
///
|
||||||
|
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
|
||||||
|
/// signal to offer automatic install; the file is removed after a successful install.
|
||||||
|
///
|
||||||
|
/// @return the modpack path, or `null` when no package is present
|
||||||
|
public static @Nullable Path findBundledModpackFile() {
|
||||||
|
Path directory = getBundledModpackDirectory();
|
||||||
|
Path zipModpack = directory.resolve("modpack.zip");
|
||||||
|
if (Files.isRegularFile(zipModpack)) {
|
||||||
|
return zipModpack;
|
||||||
|
}
|
||||||
|
Path mrpackModpack = directory.resolve("modpack.mrpack");
|
||||||
|
if (Files.isRegularFile(mrpackModpack)) {
|
||||||
|
return mrpackModpack;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server address used when the installed modpack manifest does not provide one.
|
||||||
|
///
|
||||||
|
/// Can be set at build or run time with the `hmcl.server.address` system property; empty by default.
|
||||||
|
public static String getDefaultServerAddress() {
|
||||||
|
return System.getProperty("hmcl.server.address", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server name shown in the title bar.
|
||||||
|
///
|
||||||
|
/// Can be set with the `hmcl.server.name` system property; empty by default.
|
||||||
|
public static String getServerName() {
|
||||||
|
return System.getProperty("hmcl.server.name", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.addon;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||||
|
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
|
||||||
|
import org.jackhuang.hmcl.util.Pair;
|
||||||
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.lang.ref.SoftReference;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
/// @author Glavo
|
||||||
|
@NotNullByDefault
|
||||||
|
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
|
||||||
|
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
|
||||||
|
"/assets/addon/mod.csv",
|
||||||
|
32768
|
||||||
|
) {
|
||||||
|
@Override
|
||||||
|
protected int getFieldsCount() {
|
||||||
|
return MOD_IDS_INDEX + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
|
||||||
|
return new ModProjectInfo(
|
||||||
|
Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||||
|
fields[CURSEFORGE_ID_INDEX],
|
||||||
|
fields[MODRINTH_ID_INDEX],
|
||||||
|
fields[MAIN_NAME_INDEX],
|
||||||
|
fields[SUB_NAME_INDEX],
|
||||||
|
fields[ABBR_NAME_INDEX],
|
||||||
|
fields[MOD_IDS_INDEX]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Provides metadata for modpacks.
|
||||||
|
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
|
||||||
|
"/assets/addon/modpack.csv", 2048
|
||||||
|
) {
|
||||||
|
@Override
|
||||||
|
protected int getFieldsCount() {
|
||||||
|
return ABBR_NAME_INDEX + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
|
||||||
|
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||||
|
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
|
||||||
|
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Returns the registry for the addon type, or `null` for null or unsupported types.
|
||||||
|
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
|
||||||
|
if (type == null) return null;
|
||||||
|
return switch (type) {
|
||||||
|
case MOD -> MOD;
|
||||||
|
case MODPACK -> MODPACK;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static final int MCMOD_ID_INDEX = 0;
|
||||||
|
protected static final int CURSEFORGE_ID_INDEX = 1;
|
||||||
|
protected static final int MODRINTH_ID_INDEX = 2;
|
||||||
|
protected static final int MAIN_NAME_INDEX = 3;
|
||||||
|
protected static final int SUB_NAME_INDEX = 4;
|
||||||
|
protected static final int ABBR_NAME_INDEX = 5;
|
||||||
|
protected static final int MOD_IDS_INDEX = 6;
|
||||||
|
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private final String assetsPath;
|
||||||
|
private final int initialCapacity;
|
||||||
|
|
||||||
|
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
|
||||||
|
|
||||||
|
/// Softly caches projects indexed by mod ID.
|
||||||
|
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
|
||||||
|
|
||||||
|
/// Softly caches projects indexed by cleaned secondary name.
|
||||||
|
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
|
||||||
|
|
||||||
|
/// Softly caches projects indexed by CurseForge identifier.
|
||||||
|
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
|
||||||
|
|
||||||
|
/// Softly caches searchable names in resource and field order.
|
||||||
|
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
|
||||||
|
|
||||||
|
/// Creates a registry reading records from the given classpath resource.
|
||||||
|
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
|
||||||
|
this.assetsPath = assetsPath;
|
||||||
|
this.initialCapacity = initialCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract int getFieldsCount();
|
||||||
|
|
||||||
|
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
|
||||||
|
|
||||||
|
private @Unmodifiable List<I> loadInfos() throws IOException {
|
||||||
|
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
|
||||||
|
if (inputStream == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||||
|
ArrayList<I> infos = new ArrayList<>(initialCapacity);
|
||||||
|
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
|
||||||
|
|
||||||
|
int fieldsCount = getFieldsCount();
|
||||||
|
String[] fields = new String[fieldsCount];
|
||||||
|
|
||||||
|
@Nullable StringBuilder fieldBuilder = null;
|
||||||
|
|
||||||
|
@Nullable String line;
|
||||||
|
loop:
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.isEmpty() || line.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
|
||||||
|
if (offset > line.length()) {
|
||||||
|
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
int end = line.indexOf(',', offset);
|
||||||
|
if (end < 0)
|
||||||
|
end = line.length();
|
||||||
|
|
||||||
|
|
||||||
|
String field;
|
||||||
|
if (offset == end) {
|
||||||
|
field = "";
|
||||||
|
} else {
|
||||||
|
int cursor = offset;
|
||||||
|
while (cursor < end) {
|
||||||
|
if (line.charAt(cursor) == '\\') {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor == end) {
|
||||||
|
field = line.substring(offset, end);
|
||||||
|
} else {
|
||||||
|
if (fieldBuilder == null) {
|
||||||
|
fieldBuilder = new StringBuilder(32);
|
||||||
|
} else {
|
||||||
|
fieldBuilder.setLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldBuilder.append(line, offset, cursor);
|
||||||
|
while (cursor < end) {
|
||||||
|
char c = line.charAt(cursor);
|
||||||
|
if (c == '\\') {
|
||||||
|
if (cursor < end - 1) {
|
||||||
|
char next = line.charAt(cursor + 1);
|
||||||
|
switch (next) {
|
||||||
|
case '0' -> {
|
||||||
|
fieldBuilder.append('\0');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case 'n' -> {
|
||||||
|
fieldBuilder.append('\n');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case 'r' -> {
|
||||||
|
fieldBuilder.append('\r');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case 't' -> {
|
||||||
|
fieldBuilder.append('\t');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case '\\' -> {
|
||||||
|
fieldBuilder.append('\\');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case 'c' -> {
|
||||||
|
fieldBuilder.append(',');
|
||||||
|
cursor += 2;
|
||||||
|
}
|
||||||
|
case 'u' -> {
|
||||||
|
if (cursor < end - 5) {
|
||||||
|
try {
|
||||||
|
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
|
||||||
|
fieldBuilder.append((char) codePoint);
|
||||||
|
cursor += 6;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fieldBuilder.append(c);
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fieldBuilder.append(c);
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
fieldBuilder.append(c);
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field = fieldBuilder.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields[fieldIndex] = field;
|
||||||
|
offset = end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
I info = parseAddonProjectInfo(fields);
|
||||||
|
info.setKeepAlive(result);
|
||||||
|
infos.add(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
infos.trimToSize();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||||
|
/// Duplicate keys retain the first project in resource order.
|
||||||
|
private @Unmodifiable Map<String, I> getModIdMap() {
|
||||||
|
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
|
||||||
|
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
reference = this.modIdMapRef;
|
||||||
|
cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
Map<String, I> map = new HashMap<>();
|
||||||
|
for (I info : getInfos()) {
|
||||||
|
if (info instanceof ModProjectInfo mod) {
|
||||||
|
for (String id : mod.getModIds()) {
|
||||||
|
if (!id.isEmpty())
|
||||||
|
map.putIfAbsent(id, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached = Collections.unmodifiableMap(map);
|
||||||
|
this.modIdMapRef = new SoftReference<>(cached);
|
||||||
|
return cached;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||||
|
/// Duplicate keys retain the first project in resource order.
|
||||||
|
private @Unmodifiable Map<String, I> getSubnameMap() {
|
||||||
|
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
|
||||||
|
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
reference = this.subnameMapRef;
|
||||||
|
cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
Map<String, I> map = new HashMap<>();
|
||||||
|
for (I info : getInfos()) {
|
||||||
|
String name = cleanSubname(info.subName());
|
||||||
|
if (!name.isEmpty())
|
||||||
|
map.putIfAbsent(name, info);
|
||||||
|
}
|
||||||
|
cached = Collections.unmodifiableMap(map);
|
||||||
|
this.subnameMapRef = new SoftReference<>(cached);
|
||||||
|
return cached;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||||
|
/// Duplicate keys retain the first project in resource order.
|
||||||
|
private @Unmodifiable Map<String, I> getCurseForgeMap() {
|
||||||
|
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
|
||||||
|
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
reference = this.curseForgeMapRef;
|
||||||
|
cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
Map<String, I> map = new HashMap<>();
|
||||||
|
for (I info : getInfos()) {
|
||||||
|
String id = info.curseforgeId();
|
||||||
|
if (StringUtils.isNotBlank(id))
|
||||||
|
map.putIfAbsent(id, info);
|
||||||
|
}
|
||||||
|
cached = Collections.unmodifiableMap(map);
|
||||||
|
this.curseForgeMapRef = new SoftReference<>(cached);
|
||||||
|
return cached;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first project with the exact CurseForge identifier, or `null` if the
|
||||||
|
/// identifier is null, blank or unknown.
|
||||||
|
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
|
||||||
|
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
|
||||||
|
/// A name match is accepted only when the ID is blank or belongs to that mod project.
|
||||||
|
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
|
||||||
|
/// Duplicate keys resolve to the first project in resource order.
|
||||||
|
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
|
||||||
|
String name = cleanSubname(subname);
|
||||||
|
if (StringUtils.isNotBlank(name)) {
|
||||||
|
@Nullable I info = getSubnameMap().get(name);
|
||||||
|
if (info != null && (StringUtils.isBlank(id)
|
||||||
|
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSupportedSubnameChar(int ch) {
|
||||||
|
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||||
|
|| ".+\\".indexOf(ch) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes supported separators from a secondary name.
|
||||||
|
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
|
||||||
|
private static String cleanSubname(@Nullable String subname) {
|
||||||
|
if (StringUtils.isBlank(subname))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
while (offset < subname.length()) {
|
||||||
|
int ch = subname.charAt(offset);
|
||||||
|
if (!isSupportedSubnameChar(ch))
|
||||||
|
break;
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset == subname.length())
|
||||||
|
return subname;
|
||||||
|
|
||||||
|
StringBuilder builder = new StringBuilder(subname.length());
|
||||||
|
builder.append(subname, 0, offset);
|
||||||
|
|
||||||
|
while (offset < subname.length()) {
|
||||||
|
int ch = subname.codePointAt(offset);
|
||||||
|
if (isSupportedSubnameChar(ch)) {
|
||||||
|
builder.appendCodePoint(ch);
|
||||||
|
} else if (!(Character.isWhitespace(ch)
|
||||||
|
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
offset += Character.charCount(ch);
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
|
||||||
|
private @Unmodifiable List<Pair<String, I>> getKeywords() {
|
||||||
|
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
|
||||||
|
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
reference = keywordsRef;
|
||||||
|
cached = reference != null ? reference.get() : null;
|
||||||
|
if (cached != null)
|
||||||
|
return cached;
|
||||||
|
List<Pair<String, I>> keywords = new ArrayList<>();
|
||||||
|
for (I info : getInfos()) {
|
||||||
|
if (!info.mainName().isEmpty())
|
||||||
|
keywords.add(Pair.pair(info.mainName(), info));
|
||||||
|
if (!info.subName().isEmpty())
|
||||||
|
keywords.add(Pair.pair(info.subName(), info));
|
||||||
|
if (!info.abbrName().isEmpty())
|
||||||
|
keywords.add(Pair.pair(info.abbrName(), info));
|
||||||
|
}
|
||||||
|
cached = List.copyOf(keywords);
|
||||||
|
keywordsRef = new SoftReference<>(cached);
|
||||||
|
return cached;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns projects ranked by descending longest common subsequence length after removing
|
||||||
|
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
|
||||||
|
/// Matches require at least one unit and at least query length minus three units.
|
||||||
|
/// Each matching name contributes a result, so projects may occur more than once.
|
||||||
|
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
|
||||||
|
public @Unmodifiable List<I> search(String query) {
|
||||||
|
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
|
||||||
|
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
|
||||||
|
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
|
||||||
|
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
|
||||||
|
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
|
||||||
|
List<Pair<Integer, I>> matches = new ArrayList<>();
|
||||||
|
for (Pair<String, I> keyword : keywords) {
|
||||||
|
int score = lcs.calc(query, keyword.getKey());
|
||||||
|
if (score >= Math.max(1, query.length() - 3))
|
||||||
|
matches.add(Pair.pair(score, keyword.getValue()));
|
||||||
|
}
|
||||||
|
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
|
||||||
|
.map(Pair::getValue).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
|
||||||
|
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
|
||||||
|
public @Unmodifiable List<I> getInfos() {
|
||||||
|
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
|
||||||
|
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
|
||||||
|
if (infos != null)
|
||||||
|
return infos;
|
||||||
|
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
infosRef = this.infosRef;
|
||||||
|
infos = infosRef != null ? infosRef.get() : null;
|
||||||
|
if (infos != null)
|
||||||
|
return infos;
|
||||||
|
|
||||||
|
try {
|
||||||
|
infos = loadInfos();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to load addon project info from " + assetsPath, e);
|
||||||
|
infos = List.of();
|
||||||
|
}
|
||||||
|
this.infosRef = new SoftReference<>(infos);
|
||||||
|
return infos;
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.addon.update;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
|
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||||
|
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||||
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.util.Pair;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
|
||||||
|
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
|
||||||
|
|
||||||
|
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
|
||||||
|
dependents = addons.stream().map(addon ->
|
||||||
|
Task.supplyAsync(Schedulers.io(), () -> {
|
||||||
|
AddonUpdate candidate = null;
|
||||||
|
AddonUpdate candidateRelease = null;
|
||||||
|
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
|
||||||
|
AddonUpdate update = null;
|
||||||
|
AddonUpdate updateRelease = null;
|
||||||
|
try {
|
||||||
|
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||||
|
if (u != null) {
|
||||||
|
update = u.key();
|
||||||
|
updateRelease = u.value();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
|
||||||
|
}
|
||||||
|
if (update == null) continue;
|
||||||
|
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
|
||||||
|
candidate = update;
|
||||||
|
|
||||||
|
if (updateRelease == null) continue;
|
||||||
|
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
|
||||||
|
candidateRelease = updateRelease;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
|
||||||
|
return Pair.pair(candidate, candidateRelease);
|
||||||
|
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
setStage("update.checking");
|
||||||
|
getProperties().put("total", dependents.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doPreExecute() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preExecute() {
|
||||||
|
notifyPropertiesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends Task<?>> getDependents() {
|
||||||
|
return dependents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRelyingOnDependents() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute() throws Exception {
|
||||||
|
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
|
||||||
|
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
|
||||||
|
.forEachOrdered(pair -> {
|
||||||
|
commonUpdates.add(pair.key());
|
||||||
|
if (pair.value() != null) releaseUpdates.add(pair.value());
|
||||||
|
});
|
||||||
|
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.addon.update;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||||
|
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||||
|
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||||
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
|
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||||
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public class AddonUpdateTask extends Task<Void> {
|
||||||
|
private final Collection<Task<?>> dependents = new ArrayList<>();
|
||||||
|
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
|
||||||
|
|
||||||
|
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
|
||||||
|
setStage("addon.check_update.confirm");
|
||||||
|
getProperties().put("total", addons.size());
|
||||||
|
|
||||||
|
for (AddonUpdate addon : addons) {
|
||||||
|
LocalAddonFile<?> local = addon.localAddonFile();
|
||||||
|
RemoteAddon.Version remote = addon.targetVersion();
|
||||||
|
boolean isDisabled = local.isDisabled();
|
||||||
|
String fileName = remote.file().filename();
|
||||||
|
if (isDisabled)
|
||||||
|
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
|
||||||
|
String newFileName = fileName;
|
||||||
|
Path targetFile = addonDirectory.resolve(newFileName);
|
||||||
|
|
||||||
|
dependents.add(Task
|
||||||
|
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
|
||||||
|
.thenComposeAsync(tempFile ->
|
||||||
|
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
|
||||||
|
.setName(remote.name())
|
||||||
|
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
|
||||||
|
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
|
||||||
|
.whenComplete(Schedulers.javafx(), exception -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to delete temp file " + tempFile, e);
|
||||||
|
}
|
||||||
|
if (exception != null) {
|
||||||
|
// restore state if failed
|
||||||
|
local.setOld(false);
|
||||||
|
if (isDisabled)
|
||||||
|
local.markDisabled();
|
||||||
|
} else {
|
||||||
|
local.onUpdated(newFileName);
|
||||||
|
if (!local.keepOldFiles()) {
|
||||||
|
try {
|
||||||
|
local.delete();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).whenComplete(Schedulers.javafx(), exception -> {
|
||||||
|
if (exception != null) {
|
||||||
|
LOG.warning("Failed to update addon", exception);
|
||||||
|
failedAddons.add(local);
|
||||||
|
}
|
||||||
|
}).withCounter("addon.check_update.confirm"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LocalAddonFile<?>> getFailedAddons() {
|
||||||
|
return failedAddons;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Task<?>> getDependents() {
|
||||||
|
return dependents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean doPreExecute() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preExecute() {
|
||||||
|
notifyPropertiesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRelyingOnDependents() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute() throws Exception {
|
||||||
|
if (!isDependentsSucceeded())
|
||||||
|
throw getException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.download;
|
||||||
|
|
||||||
|
import org.glavo.url.WebURL;
|
||||||
|
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
|
||||||
|
import org.jackhuang.hmcl.game.AssetObject;
|
||||||
|
import org.jackhuang.hmcl.game.GameComponentType;
|
||||||
|
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||||
|
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.util.RegionUtils;
|
||||||
|
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/// @author Glavo
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class HMCLDownloadProvider extends DownloadProvider {
|
||||||
|
private volatile DownloadSource versionListSource = DownloadSource.MIRROR;
|
||||||
|
private volatile DownloadSource fileSource = DownloadSource.MIRROR;
|
||||||
|
|
||||||
|
private static boolean preferMirror(DownloadSource source) {
|
||||||
|
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
|
||||||
|
return source != DownloadSource.OFFICIAL;
|
||||||
|
}
|
||||||
|
return source == DownloadSource.MIRROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DownloadCandidates getCandidates(
|
||||||
|
DownloadSource source,
|
||||||
|
WebURL officialUrl,
|
||||||
|
List<WebURL> mirrorUrls
|
||||||
|
) {
|
||||||
|
if (preferMirror(source)) {
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
|
||||||
|
candidates.addAll(spreadPrimary(mirrorUrls.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
|
||||||
|
candidates.add(DownloadCandidate.of(officialUrl));
|
||||||
|
return DownloadCandidates.of(List.copyOf(candidates));
|
||||||
|
} else {
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
|
||||||
|
candidates.add(DownloadCandidate.of(officialUrl));
|
||||||
|
for (WebURL mirrorUrl : mirrorUrls) {
|
||||||
|
candidates.add(DownloadCandidate.of(mirrorUrl));
|
||||||
|
}
|
||||||
|
return DownloadCandidates.of(List.copyOf(candidates));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotates the first mirror among the top entries using a stable hash of the URL, so concurrent
|
||||||
|
/// file downloads spread across healthy mirrors instead of stampeding one entry point.
|
||||||
|
private static List<DownloadCandidate> spreadPrimary(List<DownloadCandidate> mirrors, String key) {
|
||||||
|
if (mirrors.size() <= 1)
|
||||||
|
return mirrors;
|
||||||
|
|
||||||
|
int top = Math.min(2, mirrors.size());
|
||||||
|
int primary = Math.floorMod(key.hashCode(), top);
|
||||||
|
if (primary == 0)
|
||||||
|
return mirrors;
|
||||||
|
|
||||||
|
var rotated = new ArrayList<DownloadCandidate>(mirrors.size());
|
||||||
|
rotated.add(mirrors.get(primary));
|
||||||
|
for (int i = 0; i < mirrors.size(); i++) {
|
||||||
|
if (i != primary)
|
||||||
|
rotated.add(mirrors.get(i));
|
||||||
|
}
|
||||||
|
return rotated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("urls cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// true means prefer mirror, false means prefer official, null means do not use mirror
|
||||||
|
@Nullable Boolean mirrorFirst;
|
||||||
|
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
|
||||||
|
mirrorFirst = switch (source) {
|
||||||
|
case DEFAULT, MIRROR -> true;
|
||||||
|
case OFFICIAL -> false;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
mirrorFirst = switch (source) {
|
||||||
|
case DEFAULT, OFFICIAL -> null;
|
||||||
|
case MIRROR -> true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mirrorFirst == null) {
|
||||||
|
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
|
||||||
|
for (String url : urls) {
|
||||||
|
DownloadCandidate candidate = DownloadCandidate.of(url);
|
||||||
|
if (candidate.url() == null) {
|
||||||
|
// Invalid URL, just add it to the list and let the download task handle it.
|
||||||
|
candidates.add(candidate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean added = false;
|
||||||
|
for (MirrorRule rule : rules) {
|
||||||
|
@Nullable List<DownloadCandidate> list = rule.getCandidates(candidate, mirrorFirst);
|
||||||
|
if (list != null) {
|
||||||
|
candidates.addAll(list);
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added)
|
||||||
|
candidates.add(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadCandidates.of(List.copyOf(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
|
||||||
|
private final List<MirrorRule> rules;
|
||||||
|
|
||||||
|
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
|
||||||
|
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
|
||||||
|
this.rules = List.of(
|
||||||
|
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://piston-meta.mojang.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://piston-data.mojang.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://launcher.mojang.com", Function.identity()),
|
||||||
|
new MultiRootRule("https://libraries.minecraft.net", root -> root + "/libraries"),
|
||||||
|
new MultiRootRule("http://files.minecraftforge.net/maven", root -> root + "/maven"),
|
||||||
|
new MultiRootRule("https://files.minecraftforge.net/maven", root -> root + "/maven"),
|
||||||
|
new MultiRootRule("https://maven.minecraftforge.net", root -> root + "/maven"),
|
||||||
|
(source, mirrorFirst) -> {
|
||||||
|
String neoforgedMaven = "https://maven.neoforged.net/";
|
||||||
|
String neoforgedMavenRelease = "https://maven.neoforged.net/releases/";
|
||||||
|
|
||||||
|
if (source.url() == null || !source.url().href().startsWith(neoforgedMaven))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>();
|
||||||
|
List<String> roots = mirrorRoots.get();
|
||||||
|
|
||||||
|
if (source.url().href().startsWith(neoforgedMavenRelease)) {
|
||||||
|
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
|
||||||
|
List<DownloadCandidate> mirrors = roots.stream()
|
||||||
|
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
|
||||||
|
.toList();
|
||||||
|
if (mirrorFirst) {
|
||||||
|
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
|
||||||
|
candidates.add(source);
|
||||||
|
} else {
|
||||||
|
candidates.add(source);
|
||||||
|
candidates.addAll(mirrors);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidates.add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://neoforged.forgecdn.net
|
||||||
|
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
|
||||||
|
return candidates;
|
||||||
|
},
|
||||||
|
new MultiRootRule("http://dl.liteloader.com/versions", root -> root + "/maven"),
|
||||||
|
new MultiRootRule("https://dl.liteloader.com/versions", root -> root + "/maven"),
|
||||||
|
new MultiRootRule("https://meta.fabricmc.net", root -> root + "/fabric-meta"),
|
||||||
|
new MultiRootRule("https://maven.fabricmc.net", root -> root + "/maven"),
|
||||||
|
new MultiRootRule("https://authlib-injector.yushi.moe", root -> root + "/mirrors/authlib-injector"),
|
||||||
|
new SimpleRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||||
|
new SimpleRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||||
|
new SimpleRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
|
||||||
|
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
||||||
|
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
||||||
|
|
||||||
|
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
||||||
|
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
|
||||||
|
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
|
||||||
|
new SimpleRule("https://api.curseforge.com", null, "https://mod.mcimirror.top/curseforge"),
|
||||||
|
new SimpleRule("https://edge.forgecdn.net", null, "https://mod.mcimirror.top")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String primaryMirrorRoot() {
|
||||||
|
List<String> roots = mirrorRoots.get();
|
||||||
|
return roots.isEmpty() ? "https://bmclapi2.bangbang93.com" : roots.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WebURL> mirrorUrls(String suffix) {
|
||||||
|
return mirrorRoots.get().stream()
|
||||||
|
.map(root -> WebURL.parse(root + suffix))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
|
||||||
|
assert (gameVersion == null) == (type == GameComponentType.GAME);
|
||||||
|
String bmclRoot = primaryMirrorRoot();
|
||||||
|
switch (type) {
|
||||||
|
case FORGE -> {
|
||||||
|
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||||
|
|
||||||
|
DownloadSource source = versionListSource;
|
||||||
|
if (source == DownloadSource.OFFICIAL) {
|
||||||
|
return fetchOfficial;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||||
|
if (preferMirror(source)) {
|
||||||
|
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||||
|
} else {
|
||||||
|
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case NEO_FORGE -> {
|
||||||
|
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||||
|
|
||||||
|
DownloadSource source = versionListSource;
|
||||||
|
if (source == DownloadSource.OFFICIAL) {
|
||||||
|
return fetchOfficial;
|
||||||
|
}
|
||||||
|
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
|
||||||
|
if (preferMirror(source)) {
|
||||||
|
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||||
|
} else {
|
||||||
|
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case OPTIFINE -> {
|
||||||
|
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.fetchVersionsAsync(type, gameVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileSource(DownloadSource fileSource) {
|
||||||
|
this.fileSource = Objects.requireNonNull(fileSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVersionListSource(DownloadSource versionListSource) {
|
||||||
|
this.versionListSource = Objects.requireNonNull(versionListSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HMCLCacheRepository getCacheRepository() {
|
||||||
|
return HMCLCacheRepository.REPOSITORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadCandidates getGameVersionListCandidates() {
|
||||||
|
return getCandidates(
|
||||||
|
versionListSource,
|
||||||
|
GameRemoteVersion.VERSION_MANIFEST_URL,
|
||||||
|
mirrorUrls("/mc/game/version_manifest.json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
|
||||||
|
DownloadSource source = versionListSource;
|
||||||
|
return new FabricLikeVersionListCandidates(
|
||||||
|
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
|
||||||
|
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadCandidates getCleanroomVersionListCandidates() {
|
||||||
|
return "CN".equals(RegionUtils.MACHINE_REGION)
|
||||||
|
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
|
||||||
|
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadCandidates getDownloadCandidates(List<String> urls) {
|
||||||
|
return getCandidates(fileSource, rules, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadCandidates getVersionListCandidates(List<String> urls) {
|
||||||
|
return getCandidates(versionListSource, rules, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
||||||
|
String location = assetObject.getLocation();
|
||||||
|
return getCandidates(
|
||||||
|
fileSource,
|
||||||
|
WebURL.parse("https://resources.download.minecraft.net/" + location),
|
||||||
|
mirrorUrls("/assets/" + location)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNullByDefault
|
||||||
|
private static final class FallbackTask<T> extends Task<T> {
|
||||||
|
|
||||||
|
private final Task<? extends T> task;
|
||||||
|
private final Task<? extends T> fallback;
|
||||||
|
|
||||||
|
private List<Task<?>> dependencies = List.of();
|
||||||
|
|
||||||
|
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
|
||||||
|
this.task = task;
|
||||||
|
this.fallback = fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends Task<?>> getDependents() {
|
||||||
|
return List.of(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRelyingOnDependents() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute() throws Exception {
|
||||||
|
if (isDependentsSucceeded()) {
|
||||||
|
setResult(task.getResult());
|
||||||
|
} else {
|
||||||
|
dependencies = List.of(fallback);
|
||||||
|
fallback.storeTo(this::setResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task<?>> getDependencies() {
|
||||||
|
return dependencies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNullByDefault
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface MirrorRule {
|
||||||
|
@Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNullByDefault
|
||||||
|
private final class MultiRootRule implements MirrorRule {
|
||||||
|
private final String source;
|
||||||
|
private final Function<String, String> rootMapper;
|
||||||
|
|
||||||
|
private MultiRootRule(String source, Function<String, String> rootMapper) {
|
||||||
|
this.source = source;
|
||||||
|
this.rootMapper = rootMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
|
||||||
|
if (source.url() == null || !source.url().href().startsWith(this.source))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
String urlWithoutPrefix = source.url().href().substring(this.source.length());
|
||||||
|
List<DownloadCandidate> mirrors = mirrorRoots.get().stream()
|
||||||
|
.map(root -> DownloadCandidate.of(rootMapper.apply(root) + urlWithoutPrefix))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 1);
|
||||||
|
if (mirrorFirst) {
|
||||||
|
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
|
||||||
|
candidates.add(source);
|
||||||
|
} else {
|
||||||
|
candidates.add(source);
|
||||||
|
candidates.addAll(mirrors);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNullByDefault
|
||||||
|
private record SimpleRule(String source, @Nullable String first, @Nullable String fallback) implements MirrorRule {
|
||||||
|
public SimpleRule(String source, String target) {
|
||||||
|
this(source, target, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
|
||||||
|
if (source.url() == null || !source.url().href().startsWith(this.source))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
boolean addSourceFirst = !mirrorFirst || first == null;
|
||||||
|
String urlWithoutPrefix = source.url().href().substring(this.source.length());
|
||||||
|
|
||||||
|
var candidates = new ArrayList<DownloadCandidate>();
|
||||||
|
|
||||||
|
if (addSourceFirst)
|
||||||
|
candidates.add(source);
|
||||||
|
|
||||||
|
if (first != null)
|
||||||
|
candidates.add(DownloadCandidate.of(first + urlWithoutPrefix));
|
||||||
|
|
||||||
|
if (!addSourceFirst)
|
||||||
|
candidates.add(source);
|
||||||
|
|
||||||
|
if (fallback != null)
|
||||||
|
candidates.add(DownloadCandidate.of(fallback + urlWithoutPrefix));
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.download.DefaultCacheRepository;
|
||||||
|
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||||
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
|
/// Provides HMCL-specific game builders for an HMCL game repository.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class HMCLDependencyManager extends DefaultDependencyManager {
|
||||||
|
/// Creates a dependency manager for a repository and download context.
|
||||||
|
///
|
||||||
|
/// @param repository the associated game repository
|
||||||
|
/// @param downloadProvider the remote download provider
|
||||||
|
/// @param cacheRepository the artifact cache
|
||||||
|
public HMCLDependencyManager(
|
||||||
|
HMCLGameRepository repository,
|
||||||
|
DownloadProvider downloadProvider,
|
||||||
|
DefaultCacheRepository cacheRepository) {
|
||||||
|
super(repository, downloadProvider, cacheRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@inheritDoc}
|
||||||
|
@Override
|
||||||
|
public HMCLGameRepository getGameRepository() {
|
||||||
|
return (HMCLGameRepository) super.getGameRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@inheritDoc}
|
||||||
|
@Override
|
||||||
|
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
|
||||||
|
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
|
||||||
|
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
|
||||||
|
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@inheritDoc}
|
||||||
|
@Override
|
||||||
|
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
|
||||||
|
validateGameInstance(instance);
|
||||||
|
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
|
||||||
|
|
||||||
|
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
|
||||||
|
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
|
||||||
|
return new HMCLGameBuilder(
|
||||||
|
this, updateTarget.getId(), updateTarget, draft, initialManifest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.download.DefaultGameBuilder;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/// Builds game instances and applies HMCL-specific post-installation settings.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class HMCLGameBuilder extends DefaultGameBuilder {
|
||||||
|
/// Creates a builder around an HMCL target already reserved by its dependency manager.
|
||||||
|
///
|
||||||
|
/// @param dependencyManager the dependency manager for the target repository
|
||||||
|
/// @param instanceId the id of the reserved instance
|
||||||
|
/// @param updateTarget the exact update target retained by the draft, or `null` for install
|
||||||
|
/// @param draft the open draft containing `initialManifest`
|
||||||
|
/// @param initialManifest the empty target manifest retained by `draft`
|
||||||
|
HMCLGameBuilder(
|
||||||
|
HMCLDependencyManager dependencyManager,
|
||||||
|
GameInstanceID instanceId,
|
||||||
|
@Nullable HMCLGameInstance updateTarget,
|
||||||
|
DefaultGameRepositoryDraft draft,
|
||||||
|
GameInstanceManifest initialManifest) {
|
||||||
|
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@inheritDoc}
|
||||||
|
///
|
||||||
|
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
|
||||||
|
/// launches use the instance root. A read-only instance retains its existing setting.
|
||||||
|
@Override
|
||||||
|
protected void onInstanceCommitted(DefaultGameInstance instance) {
|
||||||
|
if (isolationEnabled) {
|
||||||
|
((HMCLGameInstance) instance).enableIsolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectPropertyBase;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||||
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
|
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||||
|
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||||
|
import org.jackhuang.hmcl.setting.*;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.util.FileSaver;
|
||||||
|
import org.jackhuang.hmcl.util.Lang;
|
||||||
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||||
|
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||||
|
import org.jetbrains.annotations.Contract;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||||
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class HMCLGameInstance extends DefaultGameInstance {
|
||||||
|
|
||||||
|
/// Whether the instance-local game settings file has already been inspected.
|
||||||
|
private boolean gameSettingsLoaded;
|
||||||
|
|
||||||
|
/// Whether the instance-local game settings file cannot be overwritten safely.
|
||||||
|
private boolean gameSettingsReadOnly;
|
||||||
|
|
||||||
|
/// Cached instance-local game settings, or `null` when none exist after loading.
|
||||||
|
private GameSettings.@Nullable Instance gameSettings;
|
||||||
|
|
||||||
|
/// Creates a registered instance bound to the given repository snapshot.
|
||||||
|
///
|
||||||
|
/// @param snapshot the repository snapshot that owns this instance
|
||||||
|
/// @param id the instance id
|
||||||
|
/// @param manifest the stored instance manifest
|
||||||
|
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
|
||||||
|
this(snapshot, id, manifest, (Path) null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a registered instance with an optional non-conventional manifest path.
|
||||||
|
///
|
||||||
|
/// @param snapshot the repository snapshot that owns this instance
|
||||||
|
/// @param id the instance id
|
||||||
|
/// @param manifest the stored instance manifest
|
||||||
|
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
|
||||||
|
protected HMCLGameInstance(
|
||||||
|
DefaultGameRepositorySnapshot snapshot,
|
||||||
|
GameInstanceID id,
|
||||||
|
GameInstanceManifest manifest,
|
||||||
|
@Nullable Path manifestFile) {
|
||||||
|
super(snapshot, id, manifest, manifestFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an instance that shares mutable instance-local state with another instance.
|
||||||
|
///
|
||||||
|
/// Used when the repository clones a snapshot so that settings and the icon property remain
|
||||||
|
/// available on the new wrapper.
|
||||||
|
private HMCLGameInstance(
|
||||||
|
DefaultGameRepositorySnapshot snapshot,
|
||||||
|
GameInstanceID id,
|
||||||
|
GameInstanceManifest manifest,
|
||||||
|
HMCLGameInstance shareState) {
|
||||||
|
super(snapshot, id, manifest, shareState);
|
||||||
|
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
|
||||||
|
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
|
||||||
|
this.gameSettings = shareState.gameSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
|
||||||
|
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
|
||||||
|
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HMCLGameRepository getRepository() {
|
||||||
|
return (HMCLGameRepository) super.getRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HMCLGameRepositoryLayout getLayout() {
|
||||||
|
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HMCL modpack configuration file for this instance.
|
||||||
|
///
|
||||||
|
/// @return the `modpack.cfg` path in the instance root
|
||||||
|
@Override
|
||||||
|
public Path getModpackConfigurationFile() {
|
||||||
|
return getLayout().getModpackConfigurationFile(getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this instance has an HMCL modpack configuration file.
|
||||||
|
///
|
||||||
|
/// @return whether [#getModpackConfigurationFile()] exists
|
||||||
|
public boolean isModpack() {
|
||||||
|
return Files.exists(getModpackConfigurationFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads this instance's HMCL modpack configuration.
|
||||||
|
///
|
||||||
|
/// @return the parsed configuration, or `null` when the file does not exist
|
||||||
|
/// @throws IOException if the configuration cannot be read
|
||||||
|
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
|
||||||
|
Path file = getModpackConfigurationFile();
|
||||||
|
if (Files.notExists(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
|
||||||
|
} catch (JsonParseException e) {
|
||||||
|
throw new IOException("Malformed modpack configuration: " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path getRunDirectory() {
|
||||||
|
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the loaded instance-local game settings, loading them while this instance is current.
|
||||||
|
///
|
||||||
|
/// @return the settings, or `null` when no local settings exist after loading
|
||||||
|
public @Nullable GameSettings.Instance getSettings() {
|
||||||
|
ensureGameSettingsLoaded();
|
||||||
|
return gameSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the instance-local game settings, creating an empty settings object when absent.
|
||||||
|
///
|
||||||
|
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
|
||||||
|
public @Nullable GameSettings.Instance getSettingsOrCreate() {
|
||||||
|
GameSettings.Instance setting = getSettings();
|
||||||
|
if (setting == null) {
|
||||||
|
setting = createSettings();
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves this instance's effective settings against its selected parent preset.
|
||||||
|
///
|
||||||
|
/// @return the effective settings
|
||||||
|
public GameSettings.Effective getEffectiveSettings() {
|
||||||
|
@Nullable GameSettings.Instance setting = getSettings();
|
||||||
|
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates empty instance-local game settings when none are loaded.
|
||||||
|
///
|
||||||
|
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
|
||||||
|
public @Nullable GameSettings.Instance createSettings() {
|
||||||
|
ensureGameSettingsLoaded();
|
||||||
|
if (gameSettingsReadOnly) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (gameSettings != null) {
|
||||||
|
return gameSettings;
|
||||||
|
}
|
||||||
|
return initSettings(new GameSettings.Instance(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the instance-local game settings file cannot be overwritten safely.
|
||||||
|
///
|
||||||
|
/// @return whether the settings are loaded in read-only mode
|
||||||
|
public boolean isSettingsReadOnly() {
|
||||||
|
ensureGameSettingsLoaded();
|
||||||
|
return gameSettingsReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables instance-local running-directory selection for this instance.
|
||||||
|
///
|
||||||
|
/// A blank local running directory resolves to the instance root. This operation is idempotent
|
||||||
|
/// and schedules a settings save only when it adds the override. It leaves the instance
|
||||||
|
/// unchanged when its local settings cannot be written safely.
|
||||||
|
public void enableIsolation() {
|
||||||
|
if (isSettingsReadOnly()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
|
||||||
|
if (setting != null
|
||||||
|
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backs up and overwrites the settings file when this instance still owns its settings.
|
||||||
|
public void forceOverwriteSettings() {
|
||||||
|
ensureGameSettingsLoaded();
|
||||||
|
if (!ownsGameSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSettings.Instance setting = gameSettings;
|
||||||
|
if (setting == null) {
|
||||||
|
setting = new GameSettings.Instance();
|
||||||
|
gameSettings = setting;
|
||||||
|
gameSettingsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean installAutoSave = !setting.isSavable();
|
||||||
|
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||||
|
SettingFileUtils.backupInvalidConfig(file);
|
||||||
|
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||||
|
setting.setSavable(true);
|
||||||
|
setting.setBackupOnNextSave(false);
|
||||||
|
gameSettingsReadOnly = false;
|
||||||
|
saveSettings();
|
||||||
|
if (installAutoSave) {
|
||||||
|
setting.addListener(a -> saveSettings());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the settings asynchronously when writable and still owned by the current instance.
|
||||||
|
public void saveSettings() {
|
||||||
|
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSettings.Instance setting = gameSettings;
|
||||||
|
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.isBackupOnNextSave()) {
|
||||||
|
setting.setBackupOnNextSave(false);
|
||||||
|
SettingFileUtils.backupInvalidConfig(file);
|
||||||
|
}
|
||||||
|
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the settings synchronously when writable and still owned by the current instance.
|
||||||
|
///
|
||||||
|
/// @throws IOException if saving the file fails
|
||||||
|
public void saveSettingsSync() throws IOException {
|
||||||
|
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSettings.Instance setting = gameSettings;
|
||||||
|
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
if (setting.isBackupOnNextSave()) {
|
||||||
|
setting.setBackupOnNextSave(false);
|
||||||
|
SettingFileUtils.backupInvalidConfig(file);
|
||||||
|
}
|
||||||
|
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes this instance with the given settings object.
|
||||||
|
///
|
||||||
|
/// @param setting the settings to install
|
||||||
|
/// @param allowSave whether the settings may be written back to disk
|
||||||
|
/// @return the installed settings
|
||||||
|
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
|
||||||
|
normalizeRunningDirectoryOverride(setting);
|
||||||
|
setting.setSavable(allowSave);
|
||||||
|
gameSettingsLoaded = true;
|
||||||
|
gameSettings = setting;
|
||||||
|
setting.iconProperty().addListener(observable -> invalidateIconImage());
|
||||||
|
if (allowSave) {
|
||||||
|
gameSettingsReadOnly = false;
|
||||||
|
setting.addListener(a -> saveSettings());
|
||||||
|
} else {
|
||||||
|
gameSettingsReadOnly = true;
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
|
||||||
|
/// the effective parent preset when no local settings exist.
|
||||||
|
///
|
||||||
|
/// @return a detached copy suitable for installing into another instance
|
||||||
|
public GameSettings.Instance copySettings() {
|
||||||
|
@Nullable GameSettings.Instance setting = getSettings();
|
||||||
|
if (setting != null) {
|
||||||
|
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSettings.Instance copied = new GameSettings.Instance();
|
||||||
|
copied.parentProperty().setValue(
|
||||||
|
getEffectiveSettings().getPreset().idProperty().getValue());
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first custom icon file found in this instance's root directory.
|
||||||
|
///
|
||||||
|
/// @return the icon file, or empty when no supported icon file exists
|
||||||
|
public @Nullable Path getIconFile() {
|
||||||
|
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||||
|
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||||
|
if (Files.exists(file)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces this instance's custom icon file.
|
||||||
|
///
|
||||||
|
/// Existing supported icon files are removed before `iconFile` is copied.
|
||||||
|
///
|
||||||
|
/// @param iconFile the source icon file
|
||||||
|
/// @throws IOException if the icon cannot be copied
|
||||||
|
/// @throws IllegalArgumentException if the file extension is unsupported
|
||||||
|
public void setIconFile(Path iconFile) throws IOException {
|
||||||
|
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||||
|
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
|
||||||
|
throw new IllegalArgumentException("Unsupported icon file: " + extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearIconFiles();
|
||||||
|
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
|
||||||
|
invalidateIconImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes all supported custom icon files for this instance.
|
||||||
|
///
|
||||||
|
/// Individual deletion failures are logged and do not stop later files from being attempted.
|
||||||
|
public void deleteIconFile() {
|
||||||
|
clearIconFiles();
|
||||||
|
invalidateIconImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearIconFiles() {
|
||||||
|
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||||
|
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to delete instance icon file: " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-cached icon image for this instance id.
|
||||||
|
///
|
||||||
|
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
|
||||||
|
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
|
||||||
|
private @Nullable WeakCachedIconImageProperty iconImage;
|
||||||
|
|
||||||
|
/// Returns the observable icon image for this instance.
|
||||||
|
///
|
||||||
|
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
|
||||||
|
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
|
||||||
|
/// pressure. The next [#getIconImage] reloads it.
|
||||||
|
///
|
||||||
|
/// @return the icon image property
|
||||||
|
public ReadOnlyObjectProperty<Image> iconImageProperty() {
|
||||||
|
if (iconImage == null) {
|
||||||
|
iconImage = new WeakCachedIconImageProperty();
|
||||||
|
}
|
||||||
|
return iconImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the icon image selected for this instance.
|
||||||
|
///
|
||||||
|
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
|
||||||
|
///
|
||||||
|
/// @return the selected or derived icon image
|
||||||
|
public Image getIconImage() {
|
||||||
|
return iconImageProperty().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the soft-cached icon image and notifies observers.
|
||||||
|
public void invalidateIconImage() {
|
||||||
|
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
|
||||||
|
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
|
||||||
|
private @Nullable WeakReference<Image> cache;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getBean() {
|
||||||
|
return HMCLGameInstance.this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "iconImage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Image get() {
|
||||||
|
WeakReference<Image> current = cache;
|
||||||
|
Image image = current != null ? current.get() : null;
|
||||||
|
if (image != null) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
image = computeIconImage();
|
||||||
|
cache = new WeakReference<>(image);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the icon image from settings, custom files, and the launch manifest.
|
||||||
|
///
|
||||||
|
/// @return the selected or derived icon image
|
||||||
|
private Image computeIconImage() {
|
||||||
|
@Nullable GameSettings.Instance setting = getSettings();
|
||||||
|
GameInstanceIconType iconType = setting != null
|
||||||
|
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
|
||||||
|
: GameInstanceIconType.DEFAULT;
|
||||||
|
if (iconType != GameInstanceIconType.DEFAULT) {
|
||||||
|
return iconType.getIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable Path iconFile = getIconFile();
|
||||||
|
if (iconFile != null) {
|
||||||
|
try {
|
||||||
|
return FXUtils.loadImage(iconFile, 64, 64, true, true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to load instance icon for " + getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ModLoaderType modLoader : getModLoaders()) {
|
||||||
|
return GameInstanceIconType.getIconType(modLoader).getIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasComponent(GameComponentType.OPTIFINE))
|
||||||
|
return GameInstanceIconType.OPTIFINE.getIcon();
|
||||||
|
|
||||||
|
GameVersionNumber version = getVersion();
|
||||||
|
if (version instanceof GameVersionNumber.LegacySnapshot
|
||||||
|
|| version.isAprilFools()
|
||||||
|
|| version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
|
||||||
|
return GameInstanceIconType.COMMAND.getIcon();
|
||||||
|
else if (version instanceof GameVersionNumber.Old)
|
||||||
|
return GameInstanceIconType.CRAFT_TABLE.getIcon();
|
||||||
|
else
|
||||||
|
return GameInstanceIconType.GRASS.getIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the weak cache and notifies listeners.
|
||||||
|
void invalidate() {
|
||||||
|
cache = null;
|
||||||
|
fireValueChangedEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the marker indicating that the most recent launch ended abnormally.
|
||||||
|
public void markLaunchedAbnormally() {
|
||||||
|
try {
|
||||||
|
Files.createFile(getInstanceRoot().resolve(".abnormal"));
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the abnormal-launch marker when present.
|
||||||
|
///
|
||||||
|
/// @return whether a regular marker file was present
|
||||||
|
public boolean unmarkLaunchedAbnormally() {
|
||||||
|
Path file = getInstanceRoot().resolve(".abnormal");
|
||||||
|
if (!Files.isRegularFile(file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.delete(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads settings once while this object is the repository's current instance.
|
||||||
|
private void ensureGameSettingsLoaded() {
|
||||||
|
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
|
||||||
|
loadGameSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this instance still owns the settings object used by the current snapshot.
|
||||||
|
private boolean ownsGameSettings() {
|
||||||
|
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
|
||||||
|
boolean owns = current == this
|
||||||
|
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
|
||||||
|
if (!owns && gameSettings != null) {
|
||||||
|
gameSettings.setSavable(false);
|
||||||
|
}
|
||||||
|
return owns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadGameSettings() {
|
||||||
|
gameSettingsLoaded = true;
|
||||||
|
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
|
||||||
|
if (result.setting() != null) {
|
||||||
|
initSettings(result.setting(), result.allowSave());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.allowSave()) {
|
||||||
|
gameSettingsReadOnly = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
|
||||||
|
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||||
|
legacyParent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||||
|
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
|
||||||
|
getRepository(), id, legacyParent);
|
||||||
|
if (migrationResult != null) {
|
||||||
|
initSettings(migrationResult.setting(), true);
|
||||||
|
try {
|
||||||
|
saveSettingsSync();
|
||||||
|
migrationResult.saveReceipt();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Failed to save migrated instance game settings for " + id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path getGameSettingsFile() {
|
||||||
|
return getLayout().getInstanceGameSettingsFile(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LaunchOptions.Builder getLaunchOptions(
|
||||||
|
JavaRuntime javaRuntime,
|
||||||
|
Path javaExecutable,
|
||||||
|
Path gameDir,
|
||||||
|
List<String> javaAgents,
|
||||||
|
List<String> javaArguments,
|
||||||
|
boolean makeLaunchScript) {
|
||||||
|
GameSettings.Effective vs = getEffectiveSettings();
|
||||||
|
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||||
|
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||||
|
GameVersionNumber gameVersionNumber = getVersion();
|
||||||
|
|
||||||
|
@Nullable Integer maxMemory;
|
||||||
|
if (autoMemory) {
|
||||||
|
maxMemory = noJVMOptions
|
||||||
|
? null
|
||||||
|
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||||
|
SystemInfo.getPhysicalMemoryStatus(),
|
||||||
|
javaRuntime.getPlatform()) / 1024L / 1024L);
|
||||||
|
} else {
|
||||||
|
maxMemory = vs.getMaxMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||||
|
.setInstanceId(getId())
|
||||||
|
.setGameDir(gameDir)
|
||||||
|
.setJava(javaRuntime)
|
||||||
|
.setJavaExecutable(javaExecutable)
|
||||||
|
.setVersionType(Metadata.TITLE)
|
||||||
|
.setVersionName(getId().id())
|
||||||
|
.setProfileName(Metadata.TITLE)
|
||||||
|
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
|
||||||
|
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
|
||||||
|
.setMaxMemory(maxMemory)
|
||||||
|
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
|
||||||
|
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
|
||||||
|
.setEnvironmentVariables(
|
||||||
|
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
|
||||||
|
.stream()
|
||||||
|
.map(it -> {
|
||||||
|
int idx = it.indexOf('=');
|
||||||
|
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setWidth(vs.getWidth())
|
||||||
|
.setHeight(vs.getHeight())
|
||||||
|
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
|
||||||
|
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
|
||||||
|
.setProxyOption(getProxyOption())
|
||||||
|
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
|
||||||
|
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
|
||||||
|
.setNoGeneratedJVMArgs(noJVMOptions)
|
||||||
|
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
|
||||||
|
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
|
||||||
|
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
|
||||||
|
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
|
||||||
|
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
|
||||||
|
.setRenderer(vs.getRenderer(gameVersionNumber))
|
||||||
|
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
|
||||||
|
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
|
||||||
|
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
|
||||||
|
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
|
||||||
|
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
|
||||||
|
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
|
||||||
|
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
|
||||||
|
.setJavaAgents(javaAgents)
|
||||||
|
.setJavaArguments(javaArguments);
|
||||||
|
|
||||||
|
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
|
||||||
|
if (quickPlayOption != null) {
|
||||||
|
builder.setQuickPlayOption(quickPlayOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path json = getModpackConfigurationFile();
|
||||||
|
if (Files.exists(json)) {
|
||||||
|
try {
|
||||||
|
String jsonText = Files.readString(json);
|
||||||
|
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||||
|
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||||
|
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||||
|
} catch (IOException | JsonParseException e) {
|
||||||
|
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||||
|
builder.setMaxMemory(null);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProxyOption getProxyOption() {
|
||||||
|
return switch (settings().proxyTypeProperty().get()) {
|
||||||
|
case SYSTEM -> ProxyOption.Default.INSTANCE;
|
||||||
|
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||||
|
case HTTP, SOCKS -> {
|
||||||
|
String proxyHost = settings().proxyHostProperty().get();
|
||||||
|
int proxyPort = settings().proxyPortProperty().get();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||||
|
yield ProxyOption.Default.INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
String proxyUser = settings().proxyUserProperty().get();
|
||||||
|
String proxyPass = settings().proxyPasswordProperty().get();
|
||||||
|
|
||||||
|
if (StringUtils.isBlank(proxyUser)) {
|
||||||
|
proxyUser = null;
|
||||||
|
proxyPass = null;
|
||||||
|
} else if (proxyPass == null) {
|
||||||
|
proxyPass = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
|
||||||
|
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||||
|
} else {
|
||||||
|
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a new-format instance game settings file.
|
||||||
|
private static LoadResult loadGameSettingsFile(Path file) {
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
return new LoadResult(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
|
||||||
|
if (jsonObject == null) {
|
||||||
|
LOG.warning("Instance game settings are empty: " + file);
|
||||||
|
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||||
|
return new LoadResult(fallback, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonSchema.CompatibilityResult schemaResult =
|
||||||
|
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
|
||||||
|
switch (schemaResult.status()) {
|
||||||
|
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
|
||||||
|
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
|
||||||
|
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
|
||||||
|
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||||
|
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||||
|
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!schemaResult.readable()) {
|
||||||
|
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||||
|
fallback.setSavable(false);
|
||||||
|
return new LoadResult(fallback, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
GameSettings.@Nullable Instance setting =
|
||||||
|
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
|
||||||
|
if (setting == null) {
|
||||||
|
LOG.warning("Instance game settings deserialized to null: " + file);
|
||||||
|
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||||
|
fallback.setBackupOnNextSave(true);
|
||||||
|
return new LoadResult(fallback, true);
|
||||||
|
}
|
||||||
|
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
|
||||||
|
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||||
|
}
|
||||||
|
return new LoadResult(setting, schemaResult.allowSave());
|
||||||
|
} catch (JsonParseException ex) {
|
||||||
|
LOG.warning("Failed to parse game setting " + file, ex);
|
||||||
|
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||||
|
fallback.setBackupOnNextSave(true);
|
||||||
|
return new LoadResult(fallback, true);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.warning("Failed to load game setting " + file, ex);
|
||||||
|
return new LoadResult(null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps old local custom running directories effective under the new source-selection model.
|
||||||
|
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
|
||||||
|
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
|
||||||
|
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of loading an instance-specific game settings file.
|
||||||
|
///
|
||||||
|
/// @param setting the loaded instance settings, or `null` when unavailable
|
||||||
|
/// @param allowSave whether the file may be overwritten
|
||||||
|
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional reference to an HMCL game instance bound to a repository.
|
||||||
|
///
|
||||||
|
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
|
||||||
|
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
|
||||||
|
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
|
||||||
|
/// from the current snapshot while preserving repository context.
|
||||||
|
@NotNullByDefault
|
||||||
|
public static final class Optional {
|
||||||
|
private final HMCLGameRepository repository;
|
||||||
|
private final @Nullable HMCLGameInstance instance;
|
||||||
|
|
||||||
|
/// Creates an empty optional bound only to a repository.
|
||||||
|
///
|
||||||
|
/// @param repository the repository
|
||||||
|
public Optional(HMCLGameRepository repository) {
|
||||||
|
this.repository = Objects.requireNonNull(repository);
|
||||||
|
this.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an optional that holds the given instance.
|
||||||
|
///
|
||||||
|
/// @param instance the instance
|
||||||
|
public Optional(HMCLGameInstance instance) {
|
||||||
|
this.repository = instance.getRepository();
|
||||||
|
this.instance = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
|
||||||
|
///
|
||||||
|
/// @param repository the repository
|
||||||
|
/// @param instanceId the instance id, or `null` for an empty optional
|
||||||
|
/// @return an optional that is empty when `instanceId` is null or not registered
|
||||||
|
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||||
|
if (instanceId == null) {
|
||||||
|
return new Optional(repository);
|
||||||
|
}
|
||||||
|
HMCLGameInstance instance = repository.findInstance(instanceId);
|
||||||
|
return instance != null ? new Optional(instance) : new Optional(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an optional that holds the given instance.
|
||||||
|
///
|
||||||
|
/// @param instance the instance
|
||||||
|
/// @return the optional
|
||||||
|
public static Optional of(HMCLGameInstance instance) {
|
||||||
|
return new Optional(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an empty optional bound only to a repository.
|
||||||
|
///
|
||||||
|
/// @param repository the repository
|
||||||
|
/// @return the empty optional
|
||||||
|
public static Optional empty(HMCLGameRepository repository) {
|
||||||
|
return new Optional(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the repository associated with this optional.
|
||||||
|
///
|
||||||
|
/// @return the repository
|
||||||
|
public HMCLGameRepository repository() {
|
||||||
|
return repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the held instance, if any.
|
||||||
|
///
|
||||||
|
/// @return the instance, or `null` when empty
|
||||||
|
@Contract(pure = true)
|
||||||
|
public @Nullable HMCLGameInstance instance() {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the held instance id, if any.
|
||||||
|
///
|
||||||
|
/// @return the instance id, or `null` when empty
|
||||||
|
@Contract(pure = true)
|
||||||
|
public @Nullable GameInstanceID instanceId() {
|
||||||
|
return instance != null ? instance.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether an instance is present.
|
||||||
|
///
|
||||||
|
/// @return whether [#instance()] is non-null
|
||||||
|
public boolean isPresent() {
|
||||||
|
return instance != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether no instance is present.
|
||||||
|
///
|
||||||
|
/// @return whether [#instance()] is null
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return instance == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-resolves the held instance id from the repository's current snapshot.
|
||||||
|
///
|
||||||
|
/// @return this optional when empty; otherwise a fresh optional for the same id
|
||||||
|
public Optional refreshed() {
|
||||||
|
if (instance == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return of(repository, instance.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
@@ -35,7 +20,6 @@ import java.nio.file.*;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,16 +27,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
|||||||
*/
|
*/
|
||||||
public final class HMCLGameLauncher extends DefaultLauncher {
|
public final class HMCLGameLauncher extends DefaultLauncher {
|
||||||
|
|
||||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
|
/// Creates a launcher with daemon process monitors.
|
||||||
this(repository, version, authInfo, options, null);
|
///
|
||||||
|
/// @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) {
|
/// Creates a launcher for the given instance and launch plan.
|
||||||
this(repository, version, authInfo, options, listener, true);
|
///
|
||||||
}
|
/// @param instance the instance being launched
|
||||||
|
/// @param manifest the effective launch-time manifest
|
||||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
/// @param authInfo authentication information for the game process
|
||||||
super(repository, version, authInfo, options, listener, daemon);
|
/// @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
|
@Override
|
||||||
@@ -64,10 +59,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void generateOptionsTxt() {
|
private void generateOptionsTxt() {
|
||||||
if (config().isDisableAutoGameOptions())
|
if (options.isDisableAutoGameOptions())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Path runDir = repository.getRunDirectory(version.getId());
|
Path runDir = instance.getRunDirectory();
|
||||||
Path optionsFile = runDir.resolve("options.txt");
|
Path optionsFile = runDir.resolve("options.txt");
|
||||||
Path configFolder = runDir.resolve("config");
|
Path configFolder = runDir.resolve("config");
|
||||||
|
|
||||||
@@ -92,8 +87,14 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
|||||||
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
|
* 1.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
|
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
|
||||||
*/
|
*/
|
||||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
|
GameVersionNumber gameVersion = instance.getVersion();
|
||||||
if (gameVersion.compareTo("1.1") < 0)
|
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// These snapshots lack the external language resources needed for non-English locales.
|
||||||
|
// Selecting an unavailable language causes a NullPointerException during startup.
|
||||||
|
if (gameVersion instanceof GameVersionNumber.LegacySnapshot
|
||||||
|
&& gameVersion.compareTo("13w16a") >= 0 && gameVersion.compareTo("13w23b") <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
String lang = normalizedLanguageTag(locale, gameVersion);
|
String lang = normalizedLanguageTag(locale, gameVersion);
|
||||||
@@ -159,10 +160,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
|||||||
protected void appendJvmArgs(CommandBuilder result) {
|
protected void appendJvmArgs(CommandBuilder result) {
|
||||||
super.appendJvmArgs(result);
|
super.appendJvmArgs(result);
|
||||||
|
|
||||||
if (config().getAllowAutoAgent()
|
if (options.isAllowAutoAgent()
|
||||||
&& !options.isNoGeneratedJVMArgs()
|
&& !options.isNoGeneratedJVMArgs()
|
||||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||||
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
|
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
|
||||||
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
||||||
try {
|
try {
|
||||||
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
||||||
@@ -179,9 +180,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||||
String fileName = library.getArtifact().getFileName();
|
String fileName = library.artifact().getFileName();
|
||||||
|
|
||||||
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
|
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
|
||||||
if (agentPath.toString().contains("=")) {
|
if (agentPath.toString().contains("=")) {
|
||||||
throw new IOException("Invalid library path: " + agentPath);
|
throw new IOException("Invalid library path: " + agentPath);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
|
||||||
|
/// Directory under the instance root that stores HMCL-managed instance metadata.
|
||||||
|
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||||
|
|
||||||
|
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||||
|
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||||
|
|
||||||
|
/// Directory under the instance metadata directory that stores instance state files.
|
||||||
|
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||||
|
|
||||||
|
/// Current file name for instance-specific game settings.
|
||||||
|
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||||
|
|
||||||
|
public HMCLGameRepositoryLayout(Path baseDirectory) {
|
||||||
|
super(baseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HMCL-managed metadata directory under the instance root.
|
||||||
|
///
|
||||||
|
/// This directory stores instance-scoped files owned by HMCL.
|
||||||
|
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
|
||||||
|
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
|
||||||
|
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
|
||||||
|
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HMCL-managed state directory under the instance metadata directory.
|
||||||
|
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
|
||||||
|
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current local game settings path under the instance configuration directory.
|
||||||
|
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
|
||||||
|
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
|
||||||
|
@NotNullByDefault
|
||||||
|
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
|
||||||
|
/// Creates an empty unsealed HMCL snapshot.
|
||||||
|
///
|
||||||
|
/// @param repository the owning repository
|
||||||
|
/// @param layout the HMCL layout for this snapshot
|
||||||
|
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
|
||||||
|
super(repository, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HMCLGameRepository getRepository() {
|
||||||
|
return (HMCLGameRepository) super.getRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HMCLGameRepositoryLayout getLayout() {
|
||||||
|
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HMCLGameRepositorySnapshot newEmpty() {
|
||||||
|
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Override
|
||||||
|
public Collection<HMCLGameInstance> getInstances() {
|
||||||
|
return (Collection<HMCLGameInstance>) super.getInstances();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +1,121 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
import org.jackhuang.hmcl.download.GameBuilder;
|
||||||
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
|
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||||
import org.jackhuang.hmcl.mod.Modpack;
|
import org.jackhuang.hmcl.modpack.Modpack;
|
||||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||||
import org.jackhuang.hmcl.mod.ModpackInstallTask;
|
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||||
import org.jackhuang.hmcl.setting.Profile;
|
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
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> {
|
public final class HMCLModpackInstallTask extends Task<Void> {
|
||||||
private final Path zipFile;
|
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 HMCLGameRepository repository;
|
||||||
private final DefaultDependencyManager dependency;
|
private final DefaultDependencyManager dependency;
|
||||||
private final Modpack modpack;
|
private final Modpack modpack;
|
||||||
private final List<Task<?>> dependencies = new ArrayList<>(1);
|
private final List<Task<?>> dependencies = new ArrayList<>(1);
|
||||||
private final List<Task<?>> dependents = new ArrayList<>(4);
|
private final List<Task<?>> dependents = new ArrayList<>(4);
|
||||||
|
|
||||||
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
|
/// Creates a task that installs a new HMCL modpack instance.
|
||||||
dependency = profile.getDependency();
|
///
|
||||||
repository = profile.getRepository();
|
/// @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.zipFile = zipFile;
|
||||||
this.name = name;
|
this.instanceId = instanceId;
|
||||||
|
this.updateTarget = updateTarget;
|
||||||
this.modpack = modpack;
|
this.modpack = modpack;
|
||||||
|
if (this.updateTarget != null) {
|
||||||
|
dependency.validateGameInstance(this.updateTarget);
|
||||||
|
}
|
||||||
|
|
||||||
Path run = repository.getRunDirectory(name);
|
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
|
||||||
Path json = repository.getModpackConfiguration(name);
|
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
|
||||||
if (repository.hasVersion(name) && Files.notExists(json))
|
if (this.updateTarget != null && Files.notExists(json))
|
||||||
throw new IllegalArgumentException("Version " + name + " already exists");
|
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||||
|
|
||||||
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
|
@Nullable ModpackConfiguration<Modpack> config = null;
|
||||||
|
|
||||||
onDone().register(event -> {
|
|
||||||
if (event.isFailed()) repository.removeVersionFromDisk(name);
|
|
||||||
});
|
|
||||||
|
|
||||||
ModpackConfiguration<Modpack> config = null;
|
|
||||||
try {
|
try {
|
||||||
if (Files.exists(json)) {
|
if (this.updateTarget != null && Files.exists(json)) {
|
||||||
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
|
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
|
||||||
|
|
||||||
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||||
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
|
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||||
}
|
}
|
||||||
} catch (JsonParseException | IOException ignore) {
|
} catch (JsonParseException | IOException ignore) {
|
||||||
}
|
}
|
||||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
|
|
||||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
|
onDone().register(event -> {
|
||||||
|
if (this.updateTarget == null && event.isFailed()) {
|
||||||
|
repository.removeInstanceFromDisk(this.instanceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||||
|
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -87,20 +128,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
|||||||
return dependents;
|
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
|
@Override
|
||||||
public void execute() throws Exception {
|
public void execute() throws Exception {
|
||||||
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
|
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
|
||||||
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
|
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
|
||||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
|
.withId(instanceId)
|
||||||
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
|
.withJar(null);
|
||||||
// reinstall libraries
|
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
|
||||||
// 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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
|
try (GameBuilder builder = updateTarget == null
|
||||||
|
? dependency.newGameBuilder(instanceId)
|
||||||
|
: dependency.newGameBuilder(updateTarget)) {
|
||||||
|
builder.enableIsolation();
|
||||||
|
|
||||||
|
String gameVersion = modpack.getGameVersion();
|
||||||
|
builder.component(GameComponentType.GAME, gameVersion);
|
||||||
|
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||||
|
if (mark.componentType() == GameComponentType.GAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable String componentVersion = mark.version();
|
||||||
|
if (componentVersion != null) {
|
||||||
|
if (mark.componentType() == GameComponentType.OPTIFINE) {
|
||||||
|
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
componentVersion = matcher.group("optifine");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.component(mark.componentType(), componentVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies.add(builder.buildAsync());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,9 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.mod.ModpackManifest;
|
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||||
|
|
||||||
public final class HMCLModpackManifest implements ModpackManifest {
|
public final class HMCLModpackManifest implements ModpackManifest {
|
||||||
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
||||||
|
|||||||
@@ -1,38 +1,23 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||||
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
|
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
|
||||||
import org.jackhuang.hmcl.mod.Modpack;
|
import org.jackhuang.hmcl.modpack.Modpack;
|
||||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
|
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||||
import org.jackhuang.hmcl.setting.Profile;
|
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.util.StringUtils;
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.Charset;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public final class HMCLModpackProvider implements ModpackProvider {
|
public final class HMCLModpackProvider implements ModpackProvider {
|
||||||
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
||||||
@@ -43,12 +28,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
|
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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))
|
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||||
|
|
||||||
@@ -56,31 +46,34 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
|||||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile profile = repository.getProfile();
|
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
|
||||||
|
|
||||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
|
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
|
||||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
|
||||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||||
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
|
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||||
if (game.getJar() == null)
|
if (game.jar() == null)
|
||||||
if (StringUtils.isBlank(manifest.getVersion()))
|
if (StringUtils.isBlank(manifest.getVersion()))
|
||||||
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
|
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
|
||||||
else
|
else
|
||||||
manifest.setManifest(HMCLModpackManifest.INSTANCE);
|
manifest.setManifest(HMCLModpackManifest.INSTANCE);
|
||||||
else
|
else
|
||||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
|
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private final static class HMCLModpack extends Modpack {
|
private final static class HMCLModpack extends Modpack {
|
||||||
@Override
|
@Override
|
||||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
|
public Task<?> getInstallTask(
|
||||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
DefaultDependencyManager dependencyManager,
|
||||||
|
Path zipFile,
|
||||||
|
GameInstanceID instanceId,
|
||||||
|
String iconUrl,
|
||||||
|
@Nullable Set<String> excludedFiles) {
|
||||||
|
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import com.jfoenix.controls.JFXButton;
|
import com.jfoenix.controls.JFXButton;
|
||||||
@@ -25,21 +10,21 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
|
|||||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
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.download.game.*;
|
||||||
import org.jackhuang.hmcl.java.JavaManager;
|
import org.jackhuang.hmcl.java.JavaManager;
|
||||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
import org.jackhuang.hmcl.launch.*;
|
import org.jackhuang.hmcl.launch.*;
|
||||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||||
import org.jackhuang.hmcl.setting.*;
|
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.task.*;
|
||||||
import org.jackhuang.hmcl.ui.*;
|
import org.jackhuang.hmcl.ui.*;
|
||||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
|
||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||||
import org.jackhuang.hmcl.util.*;
|
import org.jackhuang.hmcl.util.*;
|
||||||
@@ -47,54 +32,63 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
|||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||||
import org.jackhuang.hmcl.util.platform.*;
|
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.GameVersionNumber;
|
||||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.net.SocketTimeoutException;
|
import java.net.SocketTimeoutException;
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.AccessDeniedException;
|
import java.nio.file.AccessDeniedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static javafx.application.Platform.runLater;
|
import static javafx.application.Platform.runLater;
|
||||||
import static javafx.application.Platform.setImplicitExit;
|
import static javafx.application.Platform.setImplicitExit;
|
||||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
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.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.SYSTEM_PLATFORM;
|
||||||
|
|
||||||
public final class LauncherHelper {
|
public final class LauncherHelper {
|
||||||
|
|
||||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||||
|
|
||||||
private final Profile profile;
|
private final HMCLGameInstance gameInstance;
|
||||||
private Account account;
|
private Account account;
|
||||||
private final String selectedVersion;
|
|
||||||
private Path scriptFile;
|
private Path scriptFile;
|
||||||
private final VersionSetting setting;
|
private final GameSettings.Effective setting;
|
||||||
private LauncherVisibility launcherVisibility;
|
private LauncherVisibility launcherVisibility;
|
||||||
private boolean showLogs;
|
private boolean showLogs;
|
||||||
private QuickPlayOption quickPlayOption;
|
private QuickPlayOption quickPlayOption;
|
||||||
private boolean disableOfflineSkin = false;
|
private boolean disableOfflineSkin = false;
|
||||||
|
|
||||||
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
|
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
|
||||||
this.profile = Objects.requireNonNull(profile);
|
this.gameInstance = Objects.requireNonNull(gameInstance);
|
||||||
this.account = Objects.requireNonNull(account);
|
this.account = Objects.requireNonNull(account);
|
||||||
this.selectedVersion = Objects.requireNonNull(selectedVersion);
|
this.setting = gameInstance.getEffectiveSettings();
|
||||||
this.setting = profile.getVersionSetting(selectedVersion);
|
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
|
||||||
this.launcherVisibility = setting.getLauncherVisibility();
|
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
|
||||||
this.showLogs = setting.isShowLogs();
|
this.launchingStepsPane.setTitle(i18n("instance.launch"));
|
||||||
this.launchingStepsPane.setTitle(i18n("version.launch"));
|
}
|
||||||
|
|
||||||
|
public HMCLGameInstance getGameInstance() {
|
||||||
|
return gameInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HMCLGameRepository repository() {
|
||||||
|
return gameInstance.getRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
|
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
|
||||||
@@ -127,7 +121,7 @@ public final class LauncherHelper {
|
|||||||
public void launch() {
|
public void launch() {
|
||||||
FXUtils.checkFxUserThread();
|
FXUtils.checkFxUserThread();
|
||||||
|
|
||||||
LOG.info("Launching game version: " + selectedVersion);
|
LOG.info("Launching game instance: " + gameInstance.getId());
|
||||||
|
|
||||||
Controllers.dialog(launchingStepsPane);
|
Controllers.dialog(launchingStepsPane);
|
||||||
launch0();
|
launch0();
|
||||||
@@ -138,49 +132,72 @@ public final class LauncherHelper {
|
|||||||
launch();
|
launch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds and executes the launch pipeline for the captured game instance.
|
||||||
|
@SuppressWarnings("NonAtomicOperationOnVolatileField")
|
||||||
private void launch0() {
|
private void launch0() {
|
||||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||||
PROCESSES.removeIf(it -> it.get() == null);
|
PROCESSES.removeIf(it -> it.get() == null);
|
||||||
|
|
||||||
HMCLGameRepository repository = profile.getRepository();
|
final var context = new Object() {
|
||||||
DefaultDependencyManager dependencyManager = profile.getDependency();
|
volatile GameInstanceManifest launchManifest;
|
||||||
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
|
volatile JavaRuntime javaRuntime;
|
||||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
volatile Path javaExecutable;
|
||||||
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
|
};
|
||||||
|
|
||||||
|
HMCLGameRepository repository = repository();
|
||||||
|
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||||
|
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||||
List<String> javaAgents = new ArrayList<>(0);
|
List<String> javaAgents = new ArrayList<>(0);
|
||||||
List<String> javaArguments = new ArrayList<>(0);
|
List<String> javaArguments = new ArrayList<>(0);
|
||||||
|
|
||||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||||
|
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
|
||||||
|
|
||||||
TaskExecutor executor = checkGameState(profile, setting, version.get())
|
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
|
||||||
.thenComposeAsync(java -> {
|
.thenComposeAsync(java -> {
|
||||||
javaVersionRef.set(Objects.requireNonNull(java));
|
context.javaRuntime = Objects.requireNonNull(java);
|
||||||
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
|
context.javaExecutable = java.getBinary();
|
||||||
if (setting.isNotCheckGame())
|
if (java.getPlatform().os() == OperatingSystem.WINDOWS
|
||||||
|
&& scriptFile == null
|
||||||
|
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
|
||||||
|
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
|
||||||
|
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
|
||||||
|
if (Files.isRegularFile(javawBinary)) {
|
||||||
|
context.javaExecutable = javawBinary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
|
||||||
|
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||||
return null;
|
return null;
|
||||||
return Task.allOf(
|
return Task.allOf(
|
||||||
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
|
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
|
||||||
Task.composeAsync(() -> {
|
Task.composeAsync(() -> {
|
||||||
try {
|
try {
|
||||||
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
|
@Nullable ModpackConfiguration<?> configuration =
|
||||||
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
|
gameInstance.readModpackConfiguration();
|
||||||
|
if (configuration == null) return null;
|
||||||
|
@Nullable ModpackProvider provider =
|
||||||
|
ModpackHelper.getProviderByType(configuration.getType());
|
||||||
if (provider == null) return null;
|
if (provider == null) return null;
|
||||||
else return provider.createCompletionTask(dependencyManager, selectedVersion);
|
else return provider.createCompletionTask(
|
||||||
|
dependencyManager,
|
||||||
|
gameInstance);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Task.composeAsync(() -> {
|
Task.composeAsync(() -> {
|
||||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||||
|| !(setting.getRenderer() instanceof Renderer.Driver renderer)
|
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|
||||||
|| renderer.mesaDriverName() == null)
|
|| renderer.mesaDriverName() == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||||
if (lib == null)
|
if (lib == null)
|
||||||
return null;
|
return null;
|
||||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
|
||||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||||
return null;
|
return null;
|
||||||
@@ -188,7 +205,7 @@ public final class LauncherHelper {
|
|||||||
|
|
||||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||||
|
|
||||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
|
||||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||||
.thenRunAsync(() -> javaAgents.add(agent));
|
.thenRunAsync(() -> javaAgents.add(agent));
|
||||||
} else {
|
} else {
|
||||||
@@ -198,23 +215,23 @@ public final class LauncherHelper {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}).withStage("launch.state.dependencies")
|
}).withStage("launch.state.dependencies")
|
||||||
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
|
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
|
||||||
.thenComposeAsync(() -> {
|
.thenComposeAsync(() -> {
|
||||||
if (config().getAllowAutoAgent()
|
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||||
|| setting.isNoJVMArgs()
|
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||||
|| setting.isNoOptimizingJVMArgs()
|
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||||
|| Boolean.TRUE.equals(config().getShownTips().get(LWJGL_3_4_1_TIP))
|
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||||
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
|
||||||
return Task.completed(null);
|
return Task.completed(null);
|
||||||
} else {
|
} else {
|
||||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||||
runInFX(() -> {
|
runInFX(() -> {
|
||||||
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
||||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||||
config().setAllowAutoAgent(true);
|
enableAutoAgentForCurrentSetting();
|
||||||
future.complete(null);
|
future.complete(null);
|
||||||
}, () -> {
|
}, () -> {
|
||||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||||
future.complete(null);
|
future.complete(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -223,8 +240,13 @@ public final class LauncherHelper {
|
|||||||
})
|
})
|
||||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
context.javaRuntime,
|
||||||
|
context.javaExecutable,
|
||||||
|
repository.getBaseDirectory(),
|
||||||
|
javaAgents,
|
||||||
|
javaArguments,
|
||||||
|
scriptFile != null);
|
||||||
if (disableOfflineSkin) {
|
if (disableOfflineSkin) {
|
||||||
launchOptionsBuilder.setDaemon(false);
|
launchOptionsBuilder.setDaemon(false);
|
||||||
}
|
}
|
||||||
@@ -232,18 +254,48 @@ public final class LauncherHelper {
|
|||||||
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||||
|
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
|
||||||
|
try {
|
||||||
|
WinReg reg = WinReg.INSTANCE;
|
||||||
|
if (reg != null) {
|
||||||
|
Object current = reg.queryValue(
|
||||||
|
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||||
|
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||||
|
javaPathGpuReg
|
||||||
|
);
|
||||||
|
if (current == null) {
|
||||||
|
boolean result = reg.setValue(
|
||||||
|
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||||
|
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||||
|
javaPathGpuReg,
|
||||||
|
"GpuPreference=2;"
|
||||||
|
);
|
||||||
|
if (result)
|
||||||
|
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
|
||||||
|
else
|
||||||
|
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
|
||||||
|
} else {
|
||||||
|
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Failed to apply high performance GPU preference", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
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(
|
return new HMCLGameLauncher(
|
||||||
repository,
|
gameInstance,
|
||||||
version.get(),
|
context.launchManifest,
|
||||||
authInfo,
|
authInfo,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
launcherVisibility == LauncherVisibility.CLOSE
|
launcherVisibility == LauncherVisibility.CLOSE
|
||||||
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
|
? 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
|
}).thenComposeAsync(launcher -> { // launcher is prev task's result
|
||||||
if (scriptFile == null) {
|
if (scriptFile == null) {
|
||||||
@@ -260,14 +312,14 @@ public final class LauncherHelper {
|
|||||||
if (launcherVisibility == LauncherVisibility.CLOSE)
|
if (launcherVisibility == LauncherVisibility.CLOSE)
|
||||||
Launcher.stopApplication();
|
Launcher.stopApplication();
|
||||||
else
|
else
|
||||||
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
|
launchingStepsPane.setCancel(it -> {
|
||||||
process.stop();
|
process.stop();
|
||||||
it.fireEvent(new DialogCloseEvent());
|
it.fireEvent(new DialogCloseEvent());
|
||||||
}));
|
});
|
||||||
} else {
|
} else {
|
||||||
runLater(() -> {
|
runLater(() -> {
|
||||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
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(
|
}).withFakeProgress(
|
||||||
@@ -306,31 +358,29 @@ public final class LauncherHelper {
|
|||||||
} else if (ex instanceof NotDecompressingNativesException) {
|
} else if (ex instanceof NotDecompressingNativesException) {
|
||||||
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
||||||
} else if (ex instanceof LibraryDownloadException) {
|
} else if (ex instanceof LibraryDownloadException) {
|
||||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
|
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||||
if (ex.getCause() instanceof ResponseCodeException) {
|
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
|
||||||
int responseCode = rce.getResponseCode();
|
int responseCode = rce.getResponseCode();
|
||||||
String uri = rce.getUri();
|
String url = rce.getUrl();
|
||||||
if (responseCode == 404)
|
if (responseCode == 404)
|
||||||
message += i18n("download.code.404", uri);
|
message += i18n("download.code.404", url);
|
||||||
else
|
else
|
||||||
message += i18n("download.failed", uri, responseCode);
|
message += i18n("download.failed", url, responseCode);
|
||||||
} else {
|
} else {
|
||||||
message += StringUtils.getStackTrace(ex.getCause());
|
message += StringUtils.getStackTrace(ex.getCause());
|
||||||
}
|
}
|
||||||
} else if (ex instanceof DownloadException) {
|
} else if (ex instanceof DownloadException de) {
|
||||||
URI uri = ((DownloadException) ex).getUri();
|
String url = de.getUrl();
|
||||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||||
message = i18n("install.failed.downloading.timeout", uri);
|
message = i18n("install.failed.downloading.timeout", url);
|
||||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
|
||||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||||
} else {
|
} else {
|
||||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||||
}
|
}
|
||||||
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||||
message = i18n("assets.index.malformed");
|
message = i18n("assets.index.malformed");
|
||||||
@@ -338,14 +388,13 @@ public final class LauncherHelper {
|
|||||||
message = i18n("account.failed.injector_download_failure");
|
message = i18n("account.failed.injector_download_failure");
|
||||||
} else if (ex instanceof CharacterDeletedException) {
|
} else if (ex instanceof CharacterDeletedException) {
|
||||||
message = i18n("account.failed.character_deleted");
|
message = i18n("account.failed.character_deleted");
|
||||||
} else if (ex instanceof ResponseCodeException) {
|
} else if (ex instanceof ResponseCodeException rce) {
|
||||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
|
||||||
int responseCode = rce.getResponseCode();
|
int responseCode = rce.getResponseCode();
|
||||||
String uri = rce.getUri();
|
String url = rce.getUrl();
|
||||||
if (responseCode == 404)
|
if (responseCode == 404)
|
||||||
message = i18n("download.code.404", uri);
|
message = i18n("download.code.404", url);
|
||||||
else
|
else
|
||||||
message = i18n("download.failed", uri, responseCode);
|
message = i18n("download.failed", url, responseCode);
|
||||||
} else if (ex instanceof CommandTooLongException) {
|
} else if (ex instanceof CommandTooLongException) {
|
||||||
message = i18n("launch.failed.command_too_long");
|
message = i18n("launch.failed.command_too_long");
|
||||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||||
@@ -369,7 +418,7 @@ public final class LauncherHelper {
|
|||||||
message = StringUtils.getStackTrace(ex);
|
message = StringUtils.getStackTrace(ex);
|
||||||
}
|
}
|
||||||
Controllers.dialog(message,
|
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);
|
MessageType.ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,24 +428,26 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
executor.start();
|
launchingStepsPane.startExecutor();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
|
/// Checks launch requirements, allowing required Java downloads to share this launch's execution slot.
|
||||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
|
private Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
|
||||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
|
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
|
||||||
|
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||||
|
|
||||||
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
||||||
try {
|
try {
|
||||||
return setting.getJava(gameVersion, version);
|
return setting.getJava(gameVersion, manifest);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
throw new CancellationException();
|
throw new CancellationException();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Task<JavaRuntime> task;
|
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()));
|
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 -> {
|
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
|
||||||
if (java != null) {
|
if (java != null) {
|
||||||
return Task.completed(java);
|
return Task.completed(java);
|
||||||
@@ -409,14 +460,14 @@ public final class LauncherHelper {
|
|||||||
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
||||||
|
|
||||||
GameJavaVersion targetJavaVersion = null;
|
GameJavaVersion targetJavaVersion = null;
|
||||||
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
if (javaVersionType == JavaVersionType.VERSION) {
|
||||||
try {
|
try {
|
||||||
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
|
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
|
||||||
GameJavaVersion minimumJavaVersion = null;
|
GameJavaVersion minimumJavaVersion = null;
|
||||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||||
if (cleanroomVersion.isPresent()) {
|
if (cleanroomVersion != null) {
|
||||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,18 +489,18 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||||
if (cleanroomVersion.isPresent()) {
|
if (cleanroomVersion != null) {
|
||||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetJavaVersion == null)
|
if (targetJavaVersion == null)
|
||||||
targetJavaVersion = version.getJavaVersion();
|
targetJavaVersion = manifest.javaVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||||
downloadJava(targetJavaVersion, profile)
|
downloadJava(targetJavaVersion, gameInstance.getRepository())
|
||||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||||
if (exception == null) {
|
if (exception == null) {
|
||||||
future.complete(downloadedJava);
|
future.complete(downloadedJava);
|
||||||
@@ -475,8 +526,8 @@ public final class LauncherHelper {
|
|||||||
|
|
||||||
if (java != null) {
|
if (java != null) {
|
||||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||||
if (constraint.isMandatory()) {
|
if (constraint.isMandatory()) {
|
||||||
violatedMandatoryConstraints.add(constraint);
|
violatedMandatoryConstraints.add(constraint);
|
||||||
} else {
|
} else {
|
||||||
@@ -492,7 +543,7 @@ public final class LauncherHelper {
|
|||||||
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
|
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
|
||||||
|
|
||||||
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
|
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
|
||||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
|
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
|
||||||
if (suggestedJava != null) {
|
if (suggestedJava != null) {
|
||||||
FXUtils.runInFX(() -> {
|
FXUtils.runInFX(() -> {
|
||||||
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
|
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
|
||||||
@@ -512,21 +563,20 @@ public final class LauncherHelper {
|
|||||||
} else {
|
} else {
|
||||||
GameJavaVersion gameJavaVersion;
|
GameJavaVersion gameJavaVersion;
|
||||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
||||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
|
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||||
.orElse("");
|
|
||||||
|
|
||||||
gameJavaVersion = !cleanroomVersion.isEmpty()
|
gameJavaVersion = cleanroomVersion != null
|
||||||
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
||||||
: GameJavaVersion.JAVA_21;
|
: GameJavaVersion.JAVA_21;
|
||||||
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||||
gameJavaVersion = version.getJavaVersion();
|
gameJavaVersion = manifest.javaVersion();
|
||||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
||||||
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||||
else
|
else
|
||||||
gameJavaVersion = null;
|
gameJavaVersion = null;
|
||||||
|
|
||||||
if (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) {
|
if (throwable == null) {
|
||||||
setting.setJavaAutoSelected();
|
setting.setJavaAutoSelected();
|
||||||
future.complete(downloadedJava);
|
future.complete(downloadedJava);
|
||||||
@@ -539,7 +589,7 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
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));
|
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
@@ -576,7 +626,7 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 32-bit JVM cannot make use of too much memory.
|
// 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.
|
// 1.5 * 1024 is an inaccurate number.
|
||||||
// Actual memory limit depends on operating system and memory.
|
// Actual memory limit depends on operating system and memory.
|
||||||
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
|
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
|
||||||
@@ -596,7 +646,7 @@ public final class LauncherHelper {
|
|||||||
break;
|
break;
|
||||||
case MODDED_JAVA_16:
|
case MODDED_JAVA_16:
|
||||||
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
|
// 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)
|
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
|
||||||
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
||||||
else
|
else
|
||||||
@@ -609,8 +659,8 @@ public final class LauncherHelper {
|
|||||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||||
break;
|
break;
|
||||||
case CLEANROOM: {
|
case CLEANROOM: {
|
||||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
|
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||||
if (!cleanroomVersion.isEmpty())
|
if (cleanroomVersion != null)
|
||||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||||
else
|
else
|
||||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||||
@@ -623,7 +673,7 @@ public final class LauncherHelper {
|
|||||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||||
break;
|
break;
|
||||||
case VANILLA_X86:
|
case VANILLA_X86:
|
||||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
|
||||||
&& Platform.isSupportedTranslationX86_64()) {
|
&& Platform.isSupportedTranslationX86_64()) {
|
||||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||||
}
|
}
|
||||||
@@ -635,24 +685,24 @@ public final class LauncherHelper {
|
|||||||
|
|
||||||
// Cannot allocate too much memory exceeding free space.
|
// Cannot allocate too much memory exceeding free space.
|
||||||
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
|
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));
|
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)
|
.map(VersionNumber::asVersion)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
// Forge 2760~2773 will crash game with LiteLoader.
|
// 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 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) {
|
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
|
||||||
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
|
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
|
// 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 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) {
|
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
|
||||||
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
|
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
|
||||||
}
|
}
|
||||||
@@ -684,14 +734,19 @@ public final class LauncherHelper {
|
|||||||
return task.withStage("launch.state.java");
|
return task.withStage("launch.state.java");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
|
/// Prompts for and downloads Java as a required child of this launch operation.
|
||||||
|
private CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
|
||||||
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
|
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
|
||||||
Controllers.dialog(new MessageDialogPane.Builder(
|
Controllers.dialog(new MessageDialogPane.Builder(
|
||||||
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
||||||
i18n("message.warning"),
|
i18n("message.warning"),
|
||||||
MessageType.QUESTION)
|
MessageType.QUESTION)
|
||||||
.yesOrNo(() -> {
|
.yesOrNo(() -> {
|
||||||
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
|
if (!launchingStepsPane.canStartChild()) {
|
||||||
|
future.completeExceptionally(new CancellationException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
|
||||||
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
||||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||||
if (exception == null) {
|
if (exception == null) {
|
||||||
@@ -700,11 +755,11 @@ public final class LauncherHelper {
|
|||||||
Throwable resolvedException = resolveException(exception);
|
Throwable resolvedException = resolveException(exception);
|
||||||
LOG.warning("Failed to download java", exception);
|
LOG.warning("Failed to download java", exception);
|
||||||
if (!(resolvedException instanceof CancellationException)) {
|
if (!(resolvedException instanceof CancellationException)) {
|
||||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||||
}
|
}
|
||||||
future.completeExceptionally(new CancellationException());
|
future.completeExceptionally(new CancellationException());
|
||||||
}
|
}
|
||||||
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
|
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()), launchingStepsPane);
|
||||||
}, () -> future.completeExceptionally(new CancellationException())).build());
|
}, () -> future.completeExceptionally(new CancellationException())).build());
|
||||||
|
|
||||||
return future;
|
return future;
|
||||||
@@ -755,7 +810,10 @@ public final class LauncherHelper {
|
|||||||
case HIDE_AND_REOPEN:
|
case HIDE_AND_REOPEN:
|
||||||
runLater(() -> {
|
runLater(() -> {
|
||||||
Optional.ofNullable(Controllers.getStage())
|
Optional.ofNullable(Controllers.getStage())
|
||||||
.ifPresent(Stage::show);
|
.ifPresent(stage -> {
|
||||||
|
stage.setIconified(false);
|
||||||
|
stage.show();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case KEEP:
|
case KEEP:
|
||||||
@@ -774,16 +832,22 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void enableAutoAgentForCurrentSetting() {
|
||||||
* The managed process listener.
|
GameSettings.Instance instance = setting.getInstance();
|
||||||
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
|
if (instance != null
|
||||||
* Because every time we launched a game, we generates a new [HMCLProcessListener]
|
&& 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 class HMCLProcessListener implements ProcessListener {
|
||||||
|
|
||||||
private final ReentrantLock lock = new ReentrantLock();
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
private final HMCLGameRepository repository;
|
|
||||||
private final Version version;
|
|
||||||
private final LaunchOptions launchOptions;
|
private final LaunchOptions launchOptions;
|
||||||
private ManagedProcess process;
|
private ManagedProcess process;
|
||||||
private volatile boolean lwjgl;
|
private volatile boolean lwjgl;
|
||||||
@@ -795,9 +859,7 @@ public final class LauncherHelper {
|
|||||||
private Thread submitLogThread;
|
private Thread submitLogThread;
|
||||||
private LinkedBlockingQueue<Log> logBuffer;
|
private LinkedBlockingQueue<Log> logBuffer;
|
||||||
|
|
||||||
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||||
this.repository = repository;
|
|
||||||
this.version = version;
|
|
||||||
this.launchOptions = launchOptions;
|
this.launchOptions = launchOptions;
|
||||||
this.launchingLatch = launchingLatch;
|
this.launchingLatch = launchingLatch;
|
||||||
this.detectWindow = detectWindow;
|
this.detectWindow = detectWindow;
|
||||||
@@ -880,8 +942,10 @@ public final class LauncherHelper {
|
|||||||
runLater(() -> {
|
runLater(() -> {
|
||||||
// If application was stopped and execution services did not finish termination,
|
// If application was stopped and execution services did not finish termination,
|
||||||
// these codes will be executed.
|
// these codes will be executed.
|
||||||
if (Controllers.getStage() != null) {
|
@Nullable Stage stage = Controllers.getStage();
|
||||||
Controllers.getStage().hide();
|
if (stage != null) {
|
||||||
|
stage.setIconified(false);
|
||||||
|
stage.hide();
|
||||||
launchingLatch.countDown();
|
launchingLatch.countDown();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -922,7 +986,7 @@ public final class LauncherHelper {
|
|||||||
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
||||||
if (showLogs) {
|
if (showLogs) {
|
||||||
if (level == null)
|
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));
|
logBuffer.add(new Log(log, level));
|
||||||
} else {
|
} else {
|
||||||
lock.lock();
|
lock.lock();
|
||||||
@@ -954,7 +1018,7 @@ public final class LauncherHelper {
|
|||||||
@Override
|
@Override
|
||||||
public void onExit(int exitCode, ExitType exitType) {
|
public void onExit(int exitCode, ExitType exitType) {
|
||||||
if (showLogs) {
|
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();
|
submitLogThread.interrupt();
|
||||||
try {
|
try {
|
||||||
submitLogThread.join();
|
submitLogThread.join();
|
||||||
@@ -980,8 +1044,8 @@ public final class LauncherHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exitType != ExitType.NORMAL) {
|
if (exitType != ExitType.NORMAL) {
|
||||||
repository.markVersionLaunchedAbnormally(version.getId());
|
gameInstance.markLaunchedAbnormally();
|
||||||
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
|
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||||
}
|
}
|
||||||
|
|
||||||
checkExit();
|
checkExit();
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
|
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||||
|
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||||
|
import org.jackhuang.hmcl.addon.AddonProjectInfo;
|
||||||
|
import org.jackhuang.hmcl.addon.AddonProjectInfoRegistry;
|
||||||
|
import org.jackhuang.hmcl.util.Pair;
|
||||||
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
|
||||||
|
private static final int CONTAIN_CHINESE_WEIGHT = 10;
|
||||||
|
|
||||||
|
private static final int INITIAL_CAPACITY = 16;
|
||||||
|
|
||||||
|
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getApiBaseUrl() {
|
||||||
|
return getBackedRemoteModRepository().getApiBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getBaseUrl() {
|
||||||
|
return getBackedRemoteModRepository().getBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||||
|
@Nullable AddonProjectInfoRegistry<?> registry = AddonProjectInfoRegistry.getByAddonType(type);
|
||||||
|
if (registry == null || !StringUtils.containsChinese(searchFilter)) {
|
||||||
|
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (AddonProjectInfo mod : registry.search(searchFilter)) {
|
||||||
|
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.subName()) ? mod.subName() : mod.mainName()));
|
||||||
|
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||||
|
englishSearchFiltersSet.add(englishSearchFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
if (count >= 3) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (englishSearchFiltersSet.isEmpty()) {
|
||||||
|
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||||
|
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||||
|
{
|
||||||
|
SearchResult searchResult = null;
|
||||||
|
List<RemoteAddon> remoteAddons = List.of();
|
||||||
|
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||||
|
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable AddonProjectInfo chineseTranslation = registry.getInfoByCurseForgeId(remoteAddon.slug());
|
||||||
|
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.mainName()) && StringUtils.containsChinese(chineseTranslation.mainName())) {
|
||||||
|
searchResultArray[chineseIndex++] = remoteAddon;
|
||||||
|
} else {
|
||||||
|
searchResultArray[englishIndex--] = remoteAddon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalPages = searchResult.totalPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||||
|
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||||
|
@Nullable AddonProjectInfo chineseRemoteMod = registry.getInfoByCurseForgeId(remoteMod.slug());
|
||||||
|
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.mainName()) || !StringUtils.containsChinese(chineseRemoteMod.mainName())) {
|
||||||
|
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
String chineseRemoteModName = chineseRemoteMod.mainName();
|
||||||
|
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
|
||||||
|
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
|
||||||
|
}
|
||||||
|
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
|
||||||
|
for (int i = 0; i < searchFilter.length(); i++) {
|
||||||
|
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
|
||||||
|
l -= CONTAIN_CHINESE_WEIGHT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair.pair(remoteMod, l);
|
||||||
|
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getCategories(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
|
||||||
|
return getBackedRemoteModRepository().getVersionPageUrl(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/*
|
|
||||||
* Hello Minecraft! Launcher
|
|
||||||
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.game;
|
|
||||||
|
|
||||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
|
||||||
import org.jackhuang.hmcl.mod.RemoteMod;
|
|
||||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
|
||||||
import org.jackhuang.hmcl.ui.versions.ModTranslations;
|
|
||||||
import org.jackhuang.hmcl.util.Pair;
|
|
||||||
import org.jackhuang.hmcl.util.StringUtils;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
|
||||||
|
|
||||||
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
|
|
||||||
private static final int CONTAIN_CHINESE_WEIGHT = 10;
|
|
||||||
|
|
||||||
private static final int INITIAL_CAPACITY = 16;
|
|
||||||
|
|
||||||
protected abstract RemoteModRepository getBackedRemoteModRepository();
|
|
||||||
|
|
||||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
|
||||||
if (!StringUtils.containsChinese(searchFilter)) {
|
|
||||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
|
||||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
|
||||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
|
||||||
englishSearchFiltersSet.add(englishSearchFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
count++;
|
|
||||||
if (count >= 3) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (englishSearchFiltersSet.isEmpty()) {
|
|
||||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
|
||||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
|
||||||
{
|
|
||||||
SearchResult searchResult = null;
|
|
||||||
List<RemoteMod> remoteMods = List.of();
|
|
||||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
|
||||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
|
|
||||||
remoteMods = searchResult.getUnsortedResults().toList();
|
|
||||||
if (!remoteMods.isEmpty()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (RemoteMod remoteMod : remoteMods) {
|
|
||||||
if (chineseIndex > englishIndex) {
|
|
||||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
|
||||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
|
||||||
searchResultArray[chineseIndex++] = remoteMod;
|
|
||||||
} else {
|
|
||||||
searchResultArray[englishIndex--] = remoteMod;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
totalPages = searchResult.getTotalPages();
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
|
||||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
|
||||||
}
|
|
||||||
String chineseRemoteModName = chineseRemoteMod.getName();
|
|
||||||
if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) {
|
|
||||||
return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length()));
|
|
||||||
}
|
|
||||||
int l = levCalculator.calc(searchFilter, chineseRemoteModName);
|
|
||||||
for (int i = 0; i < searchFilter.length(); i++) {
|
|
||||||
if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) {
|
|
||||||
l -= CONTAIN_CHINESE_WEIGHT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Pair.pair(remoteMod, l);
|
|
||||||
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<Category> getCategories() throws IOException {
|
|
||||||
return getBackedRemoteModRepository().getCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
|
||||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
|
|
||||||
return getBackedRemoteModRepository().getModById(downloadProvider, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
|
|
||||||
return getBackedRemoteModRepository().getModFile(modId, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
|
||||||
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
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 final class Log {
|
||||||
public static final int DEFAULT_LOG_LINES = 2000;
|
public static final int DEFAULT_LOG_LINES = 2000;
|
||||||
|
|
||||||
public static int getLogLines() {
|
public static int getLogLines() {
|
||||||
Integer lines = config().getLogLines();
|
Integer lines = settings().logLinesProperty().get();
|
||||||
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
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.IOUtils;
|
||||||
import org.jackhuang.hmcl.util.io.Zipper;
|
import org.jackhuang.hmcl.util.io.Zipper;
|
||||||
import org.jackhuang.hmcl.util.logging.Logger;
|
import org.jackhuang.hmcl.util.logging.Logger;
|
||||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.DirectoryStream;
|
import java.nio.file.DirectoryStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -42,22 +30,24 @@ public final class LogExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static CompletableFuture<Void> exportLogs(
|
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) {
|
PathMatcher logMatcher) {
|
||||||
Path runDirectory = gameRepository.getRunDirectory(versionId);
|
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
|
||||||
Path baseDirectory = gameRepository.getBaseDirectory();
|
Path runDirectory = options.getGameDir();
|
||||||
List<String> versions = new ArrayList<>();
|
List<GameInstanceID> instances = new ArrayList<>();
|
||||||
|
|
||||||
String currentVersionId = versionId;
|
GameInstanceID currentInstanceId = instance.id;
|
||||||
HashSet<String> resolvedSoFar = new HashSet<>();
|
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
|
||||||
while (true) {
|
while (true) {
|
||||||
if (resolvedSoFar.contains(currentVersionId)) break;
|
if (resolvedSoFar.contains(currentInstanceId)) break;
|
||||||
resolvedSoFar.add(currentVersionId);
|
resolvedSoFar.add(currentInstanceId);
|
||||||
Version currentVersion = gameRepository.getVersion(currentVersionId);
|
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
|
||||||
versions.add(currentVersionId);
|
if (currentInstance == null)
|
||||||
|
break;
|
||||||
|
instances.add(currentInstanceId);
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
|
if (currentInstance.getManifest().inheritsFrom() != null) {
|
||||||
currentVersionId = currentVersion.getInheritsFrom();
|
currentInstanceId = currentInstance.getManifest().inheritsFrom();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -74,10 +64,12 @@ public final class LogExporter {
|
|||||||
zipper.putTextFile(logs, "minecraft.log");
|
zipper.putTextFile(logs, "minecraft.log");
|
||||||
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
||||||
|
|
||||||
for (String id : versions) {
|
for (GameInstanceID id : instances) {
|
||||||
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
|
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
|
||||||
if (Files.exists(versionJson)) {
|
if (currentInstance != null) {
|
||||||
zipper.putFile(versionJson, id + ".json");
|
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
|
||||||
|
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|||||||
@@ -1,61 +1,38 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.setting.Profile;
|
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
|
||||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
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.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
|
||||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||||
|
|
||||||
private final Profile profile;
|
|
||||||
private final Path zipFile;
|
private final Path zipFile;
|
||||||
private final Charset charset;
|
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|
||||||
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
|
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
|
||||||
this.profile = profile;
|
|
||||||
this.zipFile = zipFile;
|
this.zipFile = zipFile;
|
||||||
this.charset = charset;
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
|
||||||
|
setName(i18n("modpack.installing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute() throws Exception {
|
public void execute() throws Exception {
|
||||||
Path subdirectory;
|
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
|
||||||
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
|
|
||||||
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path dest = Paths.get("externalgames").resolve(name);
|
Path dest = Paths.get("externalgames").resolve(name);
|
||||||
|
|
||||||
setResult(dest);
|
setResult(dest);
|
||||||
|
|
||||||
new Unzipper(zipFile, dest)
|
new Unzipper(zipFile, dest)
|
||||||
.setSubDirectory(subdirectory.toString())
|
.setSubDirectory(subdirectory)
|
||||||
.setTerminateIfSubDirectoryNotExists()
|
.setTerminateIfSubDirectoryNotExists()
|
||||||
.setEncoding(charset)
|
|
||||||
.unzip();
|
.unzip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,38 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
import org.jackhuang.hmcl.mod.*;
|
import org.jackhuang.hmcl.modpack.*;
|
||||||
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
|
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
|
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
|
||||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
|
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
|
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
|
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
|
||||||
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
|
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
|
||||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
|
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||||
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
|
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
|
||||||
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
|
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
|
||||||
import org.jackhuang.hmcl.setting.Profile;
|
import org.jackhuang.hmcl.setting.*;
|
||||||
import org.jackhuang.hmcl.setting.Profiles;
|
|
||||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.util.Lang;
|
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.ExceptionalConsumer;
|
||||||
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
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.CompressingUtils;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
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 org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.Charset;
|
|
||||||
import java.nio.file.FileSystem;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@@ -55,12 +40,13 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Stream;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||||
import static org.jackhuang.hmcl.util.Lang.toIterable;
|
|
||||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
|
|
||||||
|
/// Utilities for reading, installing, and applying modpack-specific game settings.
|
||||||
|
@NotNullByDefault
|
||||||
public final class ModpackHelper {
|
public final class ModpackHelper {
|
||||||
private ModpackHelper() {
|
private ModpackHelper() {
|
||||||
}
|
}
|
||||||
@@ -75,7 +61,7 @@ public final class ModpackHelper {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static {
|
static {
|
||||||
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
|
MultiMCComponents.setImplementation(Metadata.FULL_NAME + " v" + Metadata.VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -88,8 +74,8 @@ public final class ModpackHelper {
|
|||||||
return "zip".equals(ext) || "mrpack".equals(ext);
|
return "zip".equals(ext) || "mrpack".equals(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
|
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
|
||||||
// Order for trying detecting manifest is necessary here.
|
// Order for trying detecting manifest is necessary here.
|
||||||
// Do not change to iterating providers.
|
// Do not change to iterating providers.
|
||||||
for (ModpackProvider provider : new ModpackProvider[]{
|
for (ModpackProvider provider : new ModpackProvider[]{
|
||||||
@@ -100,15 +86,15 @@ public final class ModpackHelper {
|
|||||||
MultiMCModpackProvider.INSTANCE,
|
MultiMCModpackProvider.INSTANCE,
|
||||||
ServerModpackProvider.INSTANCE}) {
|
ServerModpackProvider.INSTANCE}) {
|
||||||
try {
|
try {
|
||||||
return provider.readManifest(zipFile, file, charset);
|
return provider.readManifest(zipFile, file);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException ignored) {
|
} catch (IOException ignored) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
try {
|
||||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||||
throw new ManuallyCreatedModpackException(file);
|
throw new ManuallyCreatedModpackException(file);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// ignore it
|
// ignore it
|
||||||
@@ -117,28 +103,35 @@ public final class ModpackHelper {
|
|||||||
throw new UnsupportedModpackException(file.toString());
|
throw new UnsupportedModpackException(file.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
|
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
|
||||||
Path root = fs.getPath("/");
|
throws IOException, UnsupportedModpackException {
|
||||||
if (isMinecraftDirectory(root)) return root;
|
|
||||||
try (Stream<Path> firstLayer = Files.list(root)) {
|
|
||||||
for (Path dir : toIterable(firstLayer)) {
|
|
||||||
if (isMinecraftDirectory(dir)) return dir;
|
|
||||||
|
|
||||||
try (Stream<Path> secondLayer = Files.list(dir)) {
|
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
|
||||||
for (Path subdir : toIterable(secondLayer)) {
|
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
|
||||||
if (isMinecraftDirectory(subdir)) return subdir;
|
|
||||||
|
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);
|
throw new UnsupportedModpackException(modpackName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isMinecraftDirectory(Path path) {
|
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
|
||||||
return Files.isDirectory(path.resolve("versions")) &&
|
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
|
||||||
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
|
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
|
||||||
@@ -149,16 +142,10 @@ public final class ModpackHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
|
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
|
||||||
profile.getRepository().markVersionAsModpack(name);
|
|
||||||
|
|
||||||
ExceptionalRunnable<?> success = () -> {
|
ExceptionalRunnable<?> success = () -> {
|
||||||
HMCLGameRepository repository = profile.getRepository();
|
repository.refresh();
|
||||||
repository.refreshVersions();
|
repository.getInstance(instanceId).enableIsolation();
|
||||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
|
||||||
repository.undoMark(name);
|
|
||||||
if (vs != null)
|
|
||||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||||
@@ -168,8 +155,9 @@ public final class ModpackHelper {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
|
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
|
||||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,30 +165,49 @@ public final class ModpackHelper {
|
|||||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
|
/// Extracts a manually created modpack and selects its new external game directory.
|
||||||
|
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
|
||||||
if (isExternalGameNameConflicts(name)) {
|
if (isExternalGameNameConflicts(name)) {
|
||||||
throw new IllegalArgumentException("name existing");
|
throw new IllegalArgumentException("name existing");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
|
return new ManuallyCreatedModpackInstallTask(zipFile, name)
|
||||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||||
Profile newProfile = new Profile(name, location);
|
GameDirectory newGameDirectory = new GameDirectory(
|
||||||
newProfile.setUseRelativePath(true);
|
GameDirectoryManager.newGameDirectoryId(),
|
||||||
Profiles.getProfiles().add(newProfile);
|
LocalizedText.plain(name),
|
||||||
Profiles.setSelectedProfile(newProfile);
|
PortablePath.fromPath(location));
|
||||||
});
|
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||||
|
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||||
|
})
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
|
.withStagesHints("hmcl.modpack");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
|
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||||
profile.getRepository().markVersionAsModpack(name);
|
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 = () -> {
|
ExceptionalRunnable<?> success = () -> {
|
||||||
HMCLGameRepository repository = profile.getRepository();
|
repository.refresh();
|
||||||
repository.refreshVersions();
|
repository.getInstance(instanceId).enableIsolation();
|
||||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
|
||||||
repository.undoMark(name);
|
|
||||||
if (vs != null)
|
|
||||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||||
@@ -211,114 +218,181 @@ public final class ModpackHelper {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
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)
|
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
.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)
|
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)
|
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||||
else
|
else
|
||||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||||
.whenComplete(Schedulers.javafx(), success, failure)
|
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
.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 {
|
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
|
||||||
|
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||||
switch (configuration.getType()) {
|
switch (configuration.getType()) {
|
||||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
|
return new ModpackUpdateTask(
|
||||||
|
instance,
|
||||||
|
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||||
|
.thenComposeAsync(repository.refreshAsync())
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new UnsupportedModpackException();
|
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, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
|
||||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
}
|
||||||
|
|
||||||
|
/// Creates an update task that respects optional-file selection.
|
||||||
|
///
|
||||||
|
/// @param repository the target repository
|
||||||
|
/// @param zipFile the modpack archive
|
||||||
|
/// @param instanceId the instance to update
|
||||||
|
/// @param configuration the existing modpack configuration
|
||||||
|
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||||
|
/// When non-null, must not contain `null` elements.
|
||||||
|
/// @return the update task
|
||||||
|
public static Task<?> getUpdateTask(
|
||||||
|
HMCLGameRepository repository,
|
||||||
|
Path zipFile,
|
||||||
|
GameInstanceID instanceId,
|
||||||
|
ModpackConfiguration<?> configuration,
|
||||||
|
@Nullable Set<String> excludedFiles)
|
||||||
|
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||||
|
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
|
||||||
|
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
throw new UnsupportedModpackException();
|
throw new UnsupportedModpackException();
|
||||||
}
|
}
|
||||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
.thenComposeAsync(repository.refreshAsync())
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
|
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||||
else
|
else
|
||||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
.thenComposeAsync(repository.refreshAsync())
|
||||||
|
.withStage("hmcl.modpack")
|
||||||
|
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||||
vs.setUsesGlobal(false);
|
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
|
||||||
|
|
||||||
if (c.isOverrideJavaLocation()) {
|
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()) {
|
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)
|
if (c.getMaxMemory() != null)
|
||||||
vs.setMaxMemory(c.getMaxMemory());
|
setting.maxMemoryProperty().setValue(c.getMaxMemory());
|
||||||
vs.setMinMemory(c.getMinMemory());
|
setting.minMemoryProperty().setValue(c.getMinMemory());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.isOverrideCommands()) {
|
if (c.isOverrideCommands()) {
|
||||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
setting.getOverrideProperties().addAll(List.of(
|
||||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
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()) {
|
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()) {
|
if (c.isOverrideConsole()) {
|
||||||
vs.setShowLogs(c.isShowConsole());
|
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
|
||||||
|
setting.showLogsProperty().setValue(c.isShowConsole());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.isOverrideWindow()) {
|
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)
|
if (c.getWidth() != null)
|
||||||
vs.setWidth(c.getWidth());
|
setting.widthProperty().setValue(c.getWidth().doubleValue());
|
||||||
if (c.getHeight() != null)
|
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()) {
|
if (c.isOverrideCommands()) {
|
||||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
setting.getOverrideProperties().addAll(List.of(
|
||||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
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()) {
|
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(), () -> {
|
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||||
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
|
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(), () -> {
|
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||||
ModpackHelper.toVersionSetting(manifest, vs);
|
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(), () -> {
|
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
|
||||||
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
|
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
|
||||||
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
|
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));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import fi.iki.elonen.NanoHTTPD;
|
import fi.iki.elonen.NanoHTTPD;
|
||||||
@@ -53,7 +38,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
|||||||
private String idToken;
|
private String idToken;
|
||||||
|
|
||||||
private OAuthServer(int port) {
|
private OAuthServer(int port) {
|
||||||
super(port);
|
super("localhost", port);
|
||||||
|
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
|
||||||
@@ -146,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
|||||||
String html;
|
String html;
|
||||||
try {
|
try {
|
||||||
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
|
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
|
||||||
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||||
.replace("%lang%", Locale.getDefault().toLanguageTag())
|
.replace("%lang%", Locale.getDefault().toLanguageTag())
|
||||||
.replace("%success%", i18n("message.success"))
|
.replace("%success%", i18n("message.success"))
|
||||||
.replace("%ok%", i18n("button.ok"))
|
.replace("%ok%", i18n("button.ok"))
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||||
|
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||||
|
import org.jackhuang.hmcl.util.StringUtils;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
/// Resolves the routes that the one-click join button can connect through.
|
||||||
|
@NotNullByDefault
|
||||||
|
public final class ServerEndpoint {
|
||||||
|
private ServerEndpoint() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One selectable connection route.
|
||||||
|
///
|
||||||
|
/// @param name the display name
|
||||||
|
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
|
||||||
|
public record Route(String name, String address) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the routes configured for an instance.
|
||||||
|
///
|
||||||
|
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
|
||||||
|
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
|
||||||
|
/// used, each as a single route.
|
||||||
|
///
|
||||||
|
/// @param configurationFile the instance's `modpack.json` path
|
||||||
|
/// @return the routes in display order; empty when nothing is configured
|
||||||
|
public static @Unmodifiable List<Route> routes(Path configurationFile) {
|
||||||
|
List<Route> result = new ArrayList<>();
|
||||||
|
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
|
||||||
|
if (manifest != null) {
|
||||||
|
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
|
||||||
|
if (StringUtils.isNotBlank(route.getAddress())) {
|
||||||
|
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
|
||||||
|
result.add(new Route("default", manifest.getServerAddress().trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
String fallback = Metadata.getDefaultServerAddress();
|
||||||
|
if (!fallback.isEmpty()) {
|
||||||
|
result.add(new Route("default", fallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
|
||||||
|
///
|
||||||
|
/// @param configurationFile the instance's `modpack.json` path
|
||||||
|
/// @return the route to connect through, or `null` when nothing is configured
|
||||||
|
public static @Nullable Route selected(Path configurationFile) {
|
||||||
|
List<Route> routes = routes(configurationFile);
|
||||||
|
if (routes.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
@Nullable String saved = readSelectedName(configurationFile);
|
||||||
|
for (Route route : routes) {
|
||||||
|
if (route.name().equals(saved)) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remembers the player's route choice next to the instance's modpack configuration.
|
||||||
|
///
|
||||||
|
/// @param configurationFile the instance's `modpack.json` path
|
||||||
|
/// @param route the chosen route
|
||||||
|
public static void select(Path configurationFile, Route route) {
|
||||||
|
try {
|
||||||
|
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Unable to save the selected route", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path selectionFile(Path configurationFile) {
|
||||||
|
return configurationFile.resolveSibling("server-route.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @Nullable String readSelectedName(Path configurationFile) {
|
||||||
|
try {
|
||||||
|
Path file = selectionFile(configurationFile);
|
||||||
|
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
|
||||||
|
if (!Files.isRegularFile(configurationFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ModpackConfiguration<ServerModpackManifest> configuration =
|
||||||
|
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
|
||||||
|
return configuration != null ? configuration.getManifest() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Unable to read server routes from " + configurationFile, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.game;
|
package org.jackhuang.hmcl.game;
|
||||||
|
|
||||||
import javafx.beans.InvalidationListener;
|
import javafx.beans.InvalidationListener;
|
||||||
@@ -33,6 +18,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
|||||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||||
import org.jackhuang.hmcl.auth.offline.Skin;
|
import org.jackhuang.hmcl.auth.offline.Skin;
|
||||||
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
||||||
|
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
@@ -45,7 +31,10 @@ import java.io.InputStream;
|
|||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
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.CompletableFuture;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -65,29 +54,18 @@ public final class TexturesLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==== Texture Loading ====
|
// ==== Texture Loading ====
|
||||||
public static class LoadedTexture {
|
public record LoadedTexture(Image image, Map<String, String> metadata) {
|
||||||
private final Image image;
|
public LoadedTexture {
|
||||||
private final Map<String, String> metadata;
|
requireNonNull(image);
|
||||||
|
requireNonNull(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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
|
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) {
|
private static Path getTexturePath(Texture texture) {
|
||||||
String url = texture.getUrl();
|
String url = texture.url();
|
||||||
int slash = url.lastIndexOf('/');
|
int slash = url.lastIndexOf('/');
|
||||||
int dot = url.lastIndexOf('.');
|
int dot = url.lastIndexOf('.');
|
||||||
if (dot < slash) {
|
if (dot < slash) {
|
||||||
@@ -99,7 +77,7 @@ public final class TexturesLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
|
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");
|
throw new IOException("Texture url is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +85,14 @@ public final class TexturesLoader {
|
|||||||
if (!Files.isRegularFile(file)) {
|
if (!Files.isRegularFile(file)) {
|
||||||
// download it
|
// download it
|
||||||
try {
|
try {
|
||||||
new FileDownloadTask(texture.getUrl(), file).run();
|
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
|
||||||
LOG.info("Texture downloaded: " + texture.getUrl());
|
LOG.info("Texture downloaded: " + texture.url());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (Files.isRegularFile(file)) {
|
if (Files.isRegularFile(file)) {
|
||||||
// concurrency conflict?
|
// 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 {
|
} else {
|
||||||
throw new IOException("Failed to download texture " + texture.getUrl());
|
throw new IOException("Failed to download texture " + texture.url());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +105,7 @@ public final class TexturesLoader {
|
|||||||
if (img.isError())
|
if (img.isError())
|
||||||
throw img.getException();
|
throw img.getException();
|
||||||
|
|
||||||
Map<String, String> metadata = texture.getMetadata();
|
Map<String, String> metadata = texture.metadata();
|
||||||
if (metadata == null) {
|
if (metadata == null) {
|
||||||
metadata = emptyMap();
|
metadata = emptyMap();
|
||||||
}
|
}
|
||||||
@@ -158,7 +136,7 @@ public final class TexturesLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static TextureModel getDefaultModel(UUID uuid) {
|
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.WIDE
|
||||||
: TextureModel.SLIM;
|
: TextureModel.SLIM;
|
||||||
}
|
}
|
||||||
@@ -176,7 +154,7 @@ public final class TexturesLoader {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
|
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
|
||||||
.filter(it -> StringUtils.isNotBlank(it.getUrl())))
|
.filter(it -> StringUtils.isNotBlank(it.url())))
|
||||||
.asyncMap(it -> {
|
.asyncMap(it -> {
|
||||||
if (it.isPresent()) {
|
if (it.isPresent()) {
|
||||||
Texture texture = it.get();
|
Texture texture = it.get();
|
||||||
@@ -184,7 +162,7 @@ public final class TexturesLoader {
|
|||||||
try {
|
try {
|
||||||
return loadTexture(texture);
|
return loadTexture(texture);
|
||||||
} catch (Throwable e) {
|
} 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;
|
return uuidFallback;
|
||||||
}
|
}
|
||||||
}, POOL);
|
}, POOL);
|
||||||
@@ -195,29 +173,29 @@ public final class TexturesLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
|
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
|
||||||
LoadedTexture uuidFallback = getDefaultSkin(account.getUUID());
|
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
|
||||||
if (account instanceof OfflineAccount) {
|
if (account instanceof OfflineAccount) {
|
||||||
OfflineAccount offlineAccount = (OfflineAccount) account;
|
OfflineAccount offlineAccount = (OfflineAccount) account;
|
||||||
|
|
||||||
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
|
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
|
||||||
InvalidationListener listener = o -> {
|
InvalidationListener listener = o -> {
|
||||||
Skin skin = offlineAccount.getSkin();
|
Skin skin = offlineAccount.getSkin();
|
||||||
String username = offlineAccount.getUsername();
|
String profileName = offlineAccount.getProfileName();
|
||||||
|
|
||||||
binding.set(uuidFallback);
|
binding.set(uuidFallback);
|
||||||
if (skin != null) {
|
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) {
|
if (exception != null) {
|
||||||
LOG.warning("Failed to load texture", exception);
|
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;
|
Map<String, String> metadata;
|
||||||
if (result.getModel() != null) {
|
if (result.model() != null) {
|
||||||
metadata = singletonMap("model", result.getModel().modelName);
|
metadata = singletonMap("model", result.model().modelName);
|
||||||
} else {
|
} else {
|
||||||
metadata = emptyMap();
|
metadata = emptyMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.set(new LoadedTexture(result.getSkin().getImage(), metadata));
|
binding.set(new LoadedTexture(result.skin().image(), metadata));
|
||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
@@ -234,12 +212,12 @@ public final class TexturesLoader {
|
|||||||
.asyncMap(textures -> {
|
.asyncMap(textures -> {
|
||||||
if (textures.isPresent()) {
|
if (textures.isPresent()) {
|
||||||
Texture texture = textures.get().get(TextureType.SKIN);
|
Texture texture = textures.get().get(TextureType.SKIN);
|
||||||
if (texture != null && StringUtils.isNotBlank(texture.getUrl())) {
|
if (texture != null && StringUtils.isNotBlank(texture.url())) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
try {
|
try {
|
||||||
return loadTexture(texture);
|
return loadTexture(texture);
|
||||||
} catch (Throwable e) {
|
} 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;
|
return uuidFallback;
|
||||||
}
|
}
|
||||||
}, POOL);
|
}, POOL);
|
||||||
@@ -318,7 +296,7 @@ public final class TexturesLoader {
|
|||||||
fxAvatarBinding(canvas, skinBinding(account));
|
fxAvatarBinding(canvas, skinBinding(account));
|
||||||
else {
|
else {
|
||||||
unbindAvatar(canvas);
|
unbindAvatar(canvas);
|
||||||
drawAvatar(canvas, getDefaultSkin(account.getUUID()).image);
|
drawAvatar(canvas, getDefaultSkin(account.getProfileID()).image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
@@ -168,7 +153,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
|||||||
update.put("component", gameJavaVersion.component());
|
update.put("component", gameJavaVersion.component());
|
||||||
|
|
||||||
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
|
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
|
||||||
result.remoteFiles().getFiles().forEach((path, file) -> {
|
result.remoteFiles().files().forEach((path, file) -> {
|
||||||
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
|
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
|
||||||
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
|
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
|
||||||
if (downloadInfo != null) {
|
if (downloadInfo != null) {
|
||||||
@@ -190,12 +175,12 @@ public final class HMCLJavaRepository implements JavaRepository {
|
|||||||
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
|
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
|
||||||
Path javaDir = getJavaDir(platform, name);
|
Path javaDir = getJavaDir(platform, name);
|
||||||
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
|
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
|
||||||
if (!result.getInfo().getPlatform().equals(platform))
|
if (!result.info().getPlatform().equals(platform))
|
||||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
|
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
|
||||||
|
|
||||||
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
||||||
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
||||||
return JavaRuntime.of(executable, result.getInfo(), true);
|
return JavaRuntime.of(executable, result.info(), true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import kala.compress.archivers.ArchiveEntry;
|
import kala.compress.archivers.ArchiveEntry;
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import com.google.gson.*;
|
import com.google.gson.*;
|
||||||
|
|||||||
@@ -1,42 +1,29 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import com.google.gson.*;
|
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 com.google.gson.stream.JsonWriter;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
import org.jackhuang.hmcl.game.*;
|
||||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
|
||||||
import org.jackhuang.hmcl.game.Version;
|
|
||||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.ui.FXUtils;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
import org.jackhuang.hmcl.util.CacheRepository;
|
|
||||||
import org.jackhuang.hmcl.util.DigestUtils;
|
import org.jackhuang.hmcl.util.DigestUtils;
|
||||||
import org.jackhuang.hmcl.util.Lang;
|
import org.jackhuang.hmcl.util.FXThread;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
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.platform.windows.WinReg;
|
||||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
@@ -74,8 +61,12 @@ public final class JavaManager {
|
|||||||
"Semeru"
|
"Semeru"
|
||||||
};
|
};
|
||||||
|
|
||||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
|
/// Managed Mojang/Disco Java installs for this workspace (beside `.minecraft`), not the user-global HMCL profile.
|
||||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
|
public static final HMCLJavaRepository REPOSITORY =
|
||||||
|
new HMCLJavaRepository(Metadata.CURRENT_DIRECTORY.resolve("java"));
|
||||||
|
/// Optional portable installs under the launcher-local `.hmcl` directory.
|
||||||
|
public static final HMCLJavaRepository LOCAL_REPOSITORY =
|
||||||
|
new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
|
||||||
|
|
||||||
public static String getMojangJavaPlatform(Platform platform) {
|
public static String getMojangJavaPlatform(Platform platform) {
|
||||||
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
||||||
@@ -213,9 +204,11 @@ public final class JavaManager {
|
|||||||
|
|
||||||
String pathString = javaRuntime.getBinary().toString();
|
String pathString = javaRuntime.getBinary().toString();
|
||||||
|
|
||||||
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
|
if (!SettingsManager.isUserSettingsReadOnly()) {
|
||||||
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
|
SettingsManager.userSettings().getDisabledJava().remove(pathString);
|
||||||
addJava(javaRuntime);
|
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
|
||||||
|
addJava(javaRuntime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return javaRuntime;
|
return javaRuntime;
|
||||||
});
|
});
|
||||||
@@ -267,7 +260,7 @@ public final class JavaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FXThread
|
@FXThread
|
||||||
public static void addJava(JavaRuntime java) throws InterruptedException {
|
public static void addJava(JavaRuntime java) throws InterruptedException {
|
||||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||||
if (!oldMap.containsKey(java.getBinary())) {
|
if (!oldMap.containsKey(java.getBinary())) {
|
||||||
@@ -278,12 +271,12 @@ public final class JavaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FXThread
|
@FXThread
|
||||||
public static void removeJava(JavaRuntime java) throws InterruptedException {
|
public static void removeJava(JavaRuntime java) throws InterruptedException {
|
||||||
removeJava(java.getBinary());
|
removeJava(java.getBinary());
|
||||||
}
|
}
|
||||||
|
|
||||||
// FXThread
|
@FXThread
|
||||||
public static void removeJava(Path realPath) throws InterruptedException {
|
public static void removeJava(Path realPath) throws InterruptedException {
|
||||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||||
if (oldMap.containsKey(realPath)) {
|
if (oldMap.containsKey(realPath)) {
|
||||||
@@ -306,13 +299,13 @@ public final class JavaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
|
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
|
||||||
return findSuitableJava(getAllJava(), gameVersion, version);
|
return findSuitableJava(getAllJava(), gameVersion, manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
|
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
|
||||||
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
|
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
|
||||||
|
|
||||||
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
||||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||||
@@ -333,8 +326,8 @@ public final class JavaManager {
|
|||||||
boolean violationSuggested = false;
|
boolean violationSuggested = false;
|
||||||
|
|
||||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||||
if (constraint.isMandatory()) {
|
if (constraint.isMandatory()) {
|
||||||
violationMandatory = true;
|
violationMandatory = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -365,7 +358,7 @@ public final class JavaManager {
|
|||||||
// search java
|
// search java
|
||||||
|
|
||||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
|
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
|
||||||
Searcher searcher = new Searcher(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("javaCache.json"));
|
Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json"));
|
||||||
if (useCache)
|
if (useCache)
|
||||||
searcher.loadCache();
|
searcher.loadCache();
|
||||||
|
|
||||||
@@ -429,14 +422,14 @@ public final class JavaManager {
|
|||||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||||
|
|
||||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||||
searcher.searchAllOfficialJava(Path.of(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) {
|
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||||
}
|
}
|
||||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
|
||||||
|
|
||||||
// Search in PATH.
|
// Search in PATH.
|
||||||
if (System.getenv("PATH") != null) {
|
if (System.getenv("PATH") != null) {
|
||||||
@@ -468,7 +461,7 @@ public final class JavaManager {
|
|||||||
|
|
||||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
||||||
|
|
||||||
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
|
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
|
||||||
try {
|
try {
|
||||||
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
||||||
} catch (InvalidPathException e) {
|
} catch (InvalidPathException e) {
|
||||||
@@ -479,7 +472,7 @@ public final class JavaManager {
|
|||||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||||
if (currentJava != null
|
if (currentJava != null
|
||||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +483,7 @@ public final class JavaManager {
|
|||||||
it.isJDK() ? "JDK" : "JRE",
|
it.isJDK() ? "JDK" : "JRE",
|
||||||
it.getVersion(),
|
it.getVersion(),
|
||||||
it.getPlatform().getArchitecture().getDisplayName(),
|
it.getPlatform().getArchitecture().getDisplayName(),
|
||||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||||
it.getBinary()))
|
it.getBinary()))
|
||||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||||
return searcher.javaRuntimes;
|
return searcher.javaRuntimes;
|
||||||
@@ -685,7 +678,7 @@ public final class JavaManager {
|
|||||||
|
|
||||||
if (javaRuntimes.containsKey(executable)
|
if (javaRuntimes.containsKey(executable)
|
||||||
|| failed.contains(executable)
|
|| failed.contains(executable)
|
||||||
|| ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,7 +826,7 @@ public final class JavaManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
|
||||||
Path path;
|
Path path;
|
||||||
try {
|
try {
|
||||||
path = Path.of(programFiles);
|
path = Path.of(programFiles);
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.java;
|
package org.jackhuang.hmcl.java;
|
||||||
|
|
||||||
import com.google.gson.*;
|
import com.google.gson.*;
|
||||||
@@ -34,33 +19,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
|||||||
* @author Glavo
|
* @author Glavo
|
||||||
*/
|
*/
|
||||||
@JsonAdapter(JavaManifest.Serializer.class)
|
@JsonAdapter(JavaManifest.Serializer.class)
|
||||||
public final class JavaManifest {
|
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||||
|
|
||||||
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 static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
||||||
|
|
||||||
@@ -69,16 +28,16 @@ public final class JavaManifest {
|
|||||||
@Override
|
@Override
|
||||||
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||||
JsonObject res = new JsonObject();
|
JsonObject res = new JsonObject();
|
||||||
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
|
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
|
||||||
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
|
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
|
||||||
res.addProperty("java.version", src.getInfo().getVersion());
|
res.addProperty("java.version", src.info().getVersion());
|
||||||
res.addProperty("java.vendor", src.getInfo().getVendor());
|
res.addProperty("java.vendor", src.info().getVendor());
|
||||||
|
|
||||||
if (src.getUpdate() != null)
|
if (src.update() != null)
|
||||||
res.add("update", context.serialize(src.getUpdate()));
|
res.add("update", context.serialize(src.update()));
|
||||||
|
|
||||||
if (src.getFiles() != null)
|
if (src.files() != null)
|
||||||
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
|
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
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,275 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,12 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
import javafx.beans.InvalidationListener;
|
import javafx.beans.InvalidationListener;
|
||||||
import javafx.beans.Observable;
|
import javafx.beans.Observable;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.collections.FXCollections;
|
|
||||||
import javafx.collections.ListChangeListener;
|
import javafx.collections.ListChangeListener;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
@@ -35,28 +20,22 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
|
|||||||
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||||
import org.jackhuang.hmcl.game.OAuthServer;
|
import org.jackhuang.hmcl.game.OAuthServer;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
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.io.JarUtils;
|
||||||
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import javax.net.ssl.SSLException;
|
import javax.net.ssl.SSLException;
|
||||||
import java.io.IOException;
|
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.nio.file.Paths;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
import static java.util.stream.Collectors.toList;
|
import static java.util.stream.Collectors.toList;
|
||||||
import static javafx.collections.FXCollections.observableArrayList;
|
import static javafx.collections.FXCollections.observableArrayList;
|
||||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
|
||||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
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.Lang.mapOf;
|
||||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
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.i18n.I18n.i18n;
|
||||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
@@ -74,7 +53,14 @@ public final class Accounts {
|
|||||||
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
|
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 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 MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
|
||||||
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
/// Account factories offered when creating a new account from the UI.
|
||||||
|
///
|
||||||
|
/// Primary: Offline and Microsoft. Authlib-injector remains available for external Yggdrasil servers.
|
||||||
|
public static final List<AccountFactory<?>> FACTORIES = List.of(
|
||||||
|
FACTORY_OFFLINE,
|
||||||
|
FACTORY_MICROSOFT,
|
||||||
|
FACTORY_AUTHLIB_INJECTOR
|
||||||
|
);
|
||||||
|
|
||||||
// ==== login type / account factory mapping ====
|
// ==== login type / account factory mapping ====
|
||||||
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
||||||
@@ -120,9 +106,6 @@ public final class Accounts {
|
|||||||
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
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 ObservableList<Account> accounts = observableArrayList(account -> new Observable[]{account});
|
||||||
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
|
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
|
||||||
|
|
||||||
@@ -131,120 +114,219 @@ public final class Accounts {
|
|||||||
*/
|
*/
|
||||||
private static boolean initialized = false;
|
private static boolean initialized = false;
|
||||||
|
|
||||||
private static Map<Object, Object> getAccountStorage(Account account) {
|
private static SerializedAccount serializeAccount(Account account) {
|
||||||
Map<Object, Object> storage = account.toStorage();
|
JsonObject metadata = new JsonObject();
|
||||||
storage.put("type", getLoginType(getAccountFactory(account)));
|
metadata.addProperty("type", getLoginType(getAccountFactory(account)));
|
||||||
return storage;
|
account.writeMetadata(metadata);
|
||||||
|
JsonObject privateData = new JsonObject();
|
||||||
|
account.writePrivateData(privateData);
|
||||||
|
return new SerializedAccount(metadata, privateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void updateAccountStorages() {
|
/// Ensures account IDs are unique across local and shared account metadata records before accounts are instantiated.
|
||||||
// don't update the underlying storage before data loading is completed
|
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
|
// otherwise it might cause data loss
|
||||||
if (!initialized)
|
if (!initialized)
|
||||||
return;
|
return;
|
||||||
// update storage
|
ArrayList<JsonObject> globalMetadata = new ArrayList<>();
|
||||||
|
LinkedHashMap<AccountID, JsonObject> globalPrivateData = new LinkedHashMap<>();
|
||||||
ArrayList<Map<Object, Object>> global = new ArrayList<>();
|
ArrayList<JsonObject> portableMetadata = new ArrayList<>();
|
||||||
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
|
LinkedHashMap<AccountID, JsonObject> portablePrivateData = new LinkedHashMap<>();
|
||||||
|
|
||||||
for (Account account : accounts) {
|
for (Account account : accounts) {
|
||||||
Map<Object, Object> storage = getAccountStorage(account);
|
SerializedAccount serialized = serializeAccount(account);
|
||||||
if (account.isPortable())
|
if (account.isPortable()) {
|
||||||
portable.add(storage);
|
portableMetadata.add(serialized.metadata());
|
||||||
else
|
if (!serialized.privateData().isEmpty()) {
|
||||||
global.add(storage);
|
portablePrivateData.put(account.getAccountID(), serialized.privateData());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if (!global.equals(globalAccountStorages))
|
globalMetadata.add(serialized.metadata());
|
||||||
globalAccountStorages.setAll(global);
|
if (!serialized.privateData().isEmpty()) {
|
||||||
if (!portable.equals(config().getAccountStorages()))
|
globalPrivateData.put(account.getAccountID(), serialized.privateData());
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
globalAccountStorages.addListener(onInvalidating(() ->
|
List<AccountID> retainedAccountIDs = getAccountIDs(globalMetadata, portableMetadata);
|
||||||
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
|
if (!SettingsManager.isUserGameAccountsReadOnly())
|
||||||
|
SettingsManager.updateUserGameAccounts(globalMetadata, globalPrivateData, retainedAccountIDs);
|
||||||
|
if (!SettingsManager.isGameAccountsReadOnly())
|
||||||
|
SettingsManager.updateGameAccounts(portableMetadata, portablePrivateData, retainedAccountIDs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Account parseAccount(Map<Object, Object> storage) {
|
/// Returns whether the account metadata and credential files selected by the portability flag are read-only.
|
||||||
AccountFactory<?> factory = type2factory.get(storage.get("type"));
|
///
|
||||||
|
/// @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) {
|
if (factory == null) {
|
||||||
LOG.warning("Unrecognized account type: " + storage);
|
LOG.warning("Unrecognized account type: " + describeAccountRecord(record));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return factory.fromStorage(storage);
|
AccountID accountID = Account.readAccountID(record);
|
||||||
|
return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.warning("Failed to load account: " + storage, e);
|
LOG.warning("Failed to load account: " + describeAccountRecord(record), e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Serialized account metadata and private data.
|
||||||
* Called when it's ready to load accounts from {@link ConfigHolder#config()}.
|
///
|
||||||
*/
|
/// @param metadata public metadata stored in `accounts.json`
|
||||||
static void init() {
|
/// @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)
|
if (initialized)
|
||||||
throw new IllegalStateException("Already initialized");
|
throw new IllegalStateException("Already initialized");
|
||||||
|
|
||||||
if (!config().isAddedLittleSkin()) {
|
AccountIDNormalization accountIDNormalization = ensureUniqueAccountIDs();
|
||||||
AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/");
|
if (accountIDNormalization.localChanged()) {
|
||||||
|
SettingsManager.saveGameAccountMetadataRecords();
|
||||||
if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) {
|
}
|
||||||
config().getAuthlibInjectorServers().add(0, littleSkin);
|
if (accountIDNormalization.sharedChanged()) {
|
||||||
}
|
SettingsManager.saveUserGameAccountMetadataRecords();
|
||||||
|
|
||||||
config().setAddedLittleSkin(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadGlobalAccountStorages();
|
|
||||||
|
|
||||||
// load accounts
|
// load accounts
|
||||||
Account selected = null;
|
Account selected = null;
|
||||||
for (Map<Object, Object> storage : config().getAccountStorages()) {
|
for (JsonObject record : getAccountMetadataRecords()) {
|
||||||
Account account = parseAccount(storage);
|
Account account = parseAccount(record, true);
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
account.setPortable(true);
|
account.setPortable(true);
|
||||||
accounts.add(account);
|
accounts.add(account);
|
||||||
if (Boolean.TRUE.equals(storage.get("selected"))) {
|
if (JsonUtils.getBoolean(record, "selected", false)) {
|
||||||
selected = account;
|
selected = account;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Map<Object, Object> storage : globalAccountStorages) {
|
for (JsonObject record : getUserAccountMetadataRecords()) {
|
||||||
Account account = parseAccount(storage);
|
Account account = parseAccount(record, false);
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
accounts.add(account);
|
accounts.add(account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String selectedAccountIdentifier = config().getSelectedAccount();
|
AccountID selectedAccountID = settings().selectedAccountProperty().get();
|
||||||
if (selected == null && selectedAccountIdentifier != null) {
|
if (selected == null && selectedAccountID != null) {
|
||||||
boolean portable = true;
|
|
||||||
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
|
|
||||||
portable = false;
|
|
||||||
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Account account : accounts) {
|
for (Account account : accounts) {
|
||||||
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
|
if (account.getAccountID().equals(selectedAccountID)) {
|
||||||
if (portable == account.isPortable()) {
|
selected = account;
|
||||||
selected = account;
|
break;
|
||||||
break;
|
|
||||||
} else if (selected == null) {
|
|
||||||
selected = account;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,15 +335,18 @@ public final class Accounts {
|
|||||||
selected = accounts.get(0);
|
selected = accounts.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalConfig().isEnableOfflineAccount())
|
if (!SettingsManager.isUserSettingsReadOnly()
|
||||||
|
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||||
for (Account account : accounts) {
|
for (Account account : accounts) {
|
||||||
if (account instanceof MicrosoftAccount) {
|
if (account instanceof MicrosoftAccount) {
|
||||||
globalConfig().setEnableOfflineAccount(true);
|
UserSettings userSettings = userSettings();
|
||||||
|
userSettings.enableOfflineAccountProperty().set(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalConfig().isEnableOfflineAccount())
|
if (!SettingsManager.isUserSettingsReadOnly()
|
||||||
|
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||||
accounts.addListener(new ListChangeListener<Account>() {
|
accounts.addListener(new ListChangeListener<Account>() {
|
||||||
@Override
|
@Override
|
||||||
public void onChanged(Change<? extends Account> change) {
|
public void onChanged(Change<? extends Account> change) {
|
||||||
@@ -269,7 +354,8 @@ public final class Accounts {
|
|||||||
for (Account account : change.getAddedSubList()) {
|
for (Account account : change.getAddedSubList()) {
|
||||||
if (account instanceof MicrosoftAccount) {
|
if (account instanceof MicrosoftAccount) {
|
||||||
accounts.removeListener(this);
|
accounts.removeListener(this);
|
||||||
globalConfig().setEnableOfflineAccount(true);
|
UserSettings userSettings = userSettings();
|
||||||
|
userSettings.enableOfflineAccountProperty().set(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +367,7 @@ public final class Accounts {
|
|||||||
|
|
||||||
InvalidationListener listener = o -> {
|
InvalidationListener listener = o -> {
|
||||||
// this method first checks whether the current selection is valid
|
// 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)
|
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
|
||||||
Account account = selectedAccount.get();
|
Account account = selectedAccount.get();
|
||||||
if (accounts.isEmpty()) {
|
if (accounts.isEmpty()) {
|
||||||
@@ -304,16 +390,16 @@ public final class Accounts {
|
|||||||
selectedAccount.addListener(onInvalidating(() -> {
|
selectedAccount.addListener(onInvalidating(() -> {
|
||||||
Account account = selectedAccount.get();
|
Account account = selectedAccount.get();
|
||||||
if (account != null)
|
if (account != null)
|
||||||
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
|
settings().selectedAccountProperty().set(account.getAccountID());
|
||||||
else
|
else
|
||||||
config().setSelectedAccount(null);
|
settings().selectedAccountProperty().set(null);
|
||||||
}));
|
}));
|
||||||
accounts.addListener(listener);
|
accounts.addListener(listener);
|
||||||
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
|
accounts.addListener(onInvalidating(Accounts::updateAccountMetadataRecords));
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
Account finalSelected = selected;
|
Account finalSelected = selected;
|
||||||
@@ -326,7 +412,7 @@ public final class Accounts {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
|
for (AuthlibInjectorServer server : getAuthlibInjectorServers()) {
|
||||||
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
||||||
continue;
|
continue;
|
||||||
Schedulers.io().execute(() -> {
|
Schedulers.io().execute(() -> {
|
||||||
@@ -373,12 +459,12 @@ public final class Accounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
||||||
return config().getAuthlibInjectorServers().stream()
|
return getAuthlibInjectorServers().stream()
|
||||||
.filter(server -> url.equals(server.getUrl()))
|
.filter(server -> url.equals(server.getUrl()))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
||||||
config().getAuthlibInjectorServers().add(server);
|
getAuthlibInjectorServers().add(server);
|
||||||
return server;
|
return server;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -391,7 +477,8 @@ public final class Accounts {
|
|||||||
accounts.stream()
|
accounts.stream()
|
||||||
.filter(AuthlibInjectorAccount.class::isInstance)
|
.filter(AuthlibInjectorAccount.class::isInstance)
|
||||||
.map(AuthlibInjectorAccount.class::cast)
|
.map(AuthlibInjectorAccount.class::cast)
|
||||||
.filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer()))
|
.filter(it -> !getAuthlibInjectorServers().contains(it.getServer()))
|
||||||
|
.filter(Accounts::canRemoveAccount)
|
||||||
.collect(toList())
|
.collect(toList())
|
||||||
.forEach(accounts::remove);
|
.forEach(accounts::remove);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import javafx.beans.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 used only when recognizing an existing entry.
|
||||||
|
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
|
||||||
|
/// The JSON schema supported by this authlib-injector server list.
|
||||||
|
public static final JsonSchema CURRENT_SCHEMA =
|
||||||
|
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The default list is empty; operators add their own authentication server when needed.
|
||||||
|
public static AuthlibInjectorServerList createDefault() {
|
||||||
|
return new AuthlibInjectorServerList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
@@ -35,7 +20,8 @@ import java.util.List;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CopyOnWriteArraySet;
|
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;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
@JsonSerializable
|
@JsonSerializable
|
||||||
@@ -71,7 +57,7 @@ public final class AuthlibInjectorServers implements Validation {
|
|||||||
configLocation = Paths.get(CONFIG_FILENAME);
|
configLocation = Paths.get(CONFIG_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
|
||||||
AuthlibInjectorServers configInstance;
|
AuthlibInjectorServers configInstance;
|
||||||
try {
|
try {
|
||||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||||
@@ -81,11 +67,11 @@ public final class AuthlibInjectorServers implements Validation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!configInstance.urls.isEmpty()) {
|
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) {
|
for (String url : configInstance.urls) {
|
||||||
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
||||||
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
||||||
config().getAuthlibInjectorServers().add(server);
|
getAuthlibInjectorServers().add(server);
|
||||||
servers.add(server);
|
servers.add(server);
|
||||||
})
|
})
|
||||||
.start();
|
.start();
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
|
||||||
|
/// Uses one built-in wallpaper selected by launcher settings.
|
||||||
|
BUILTIN,
|
||||||
|
|
||||||
|
/// Uses a user-selected local image file or a random image from a selected directory.
|
||||||
|
CUSTOM,
|
||||||
|
|
||||||
|
/// Uses an image loaded from a user-provided URL.
|
||||||
|
NETWORK,
|
||||||
|
|
||||||
|
/// 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,816 +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("disableAutoShowUpdateDialog")
|
|
||||||
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
|
|
||||||
|
|
||||||
public BooleanProperty disableAutoShowUpdateDialogProperty() {
|
|
||||||
return disableAutoShowUpdateDialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isDisableAutoShowUpdateDialog() {
|
|
||||||
return disableAutoShowUpdateDialog.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) {
|
|
||||||
this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SerializedName("disableAprilFools")
|
|
||||||
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
|
|
||||||
|
|
||||||
public BooleanProperty disableAprilFoolsProperty() {
|
|
||||||
return disableAprilFools;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isDisableAprilFools() {
|
|
||||||
return disableAprilFools.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDisableAprilFools(boolean disableAprilFools) {
|
|
||||||
this.disableAprilFools.set(disableAprilFools);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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("defaultAddonSource")
|
|
||||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
|
||||||
|
|
||||||
public StringProperty defaultAddonSourceProperty() {
|
|
||||||
return defaultAddonSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDefaultAddonSource() {
|
|
||||||
return defaultAddonSource.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDefaultAddonSource(String defaultAddonSource) {
|
|
||||||
this.defaultAddonSource.set(defaultAddonSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SerializedName("allowAutoAgent")
|
|
||||||
private final BooleanProperty allowAutoAgent = new SimpleBooleanProperty(false);
|
|
||||||
|
|
||||||
public BooleanProperty allowAutoAgentProperty() {
|
|
||||||
return allowAutoAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean getAllowAutoAgent() {
|
|
||||||
return allowAutoAgent.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAllowAutoAgent(boolean allowAutoAgent) {
|
|
||||||
this.allowAutoAgent.set(allowAutoAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,15 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
/// @author Glavo
|
||||||
|
public enum DefaultIsolationType {
|
||||||
|
/// Default to isolate all instances.
|
||||||
|
ALWAYS,
|
||||||
|
|
||||||
|
/// Default to isolate instances with any modloader.
|
||||||
|
MODDED,
|
||||||
|
|
||||||
|
/// No instances are isolated by default.
|
||||||
|
NEVER,
|
||||||
|
}
|
||||||
@@ -1,152 +1,53 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
import javafx.beans.InvalidationListener;
|
import javafx.beans.InvalidationListener;
|
||||||
import org.jackhuang.hmcl.download.*;
|
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||||
import org.jackhuang.hmcl.task.DownloadException;
|
import org.jackhuang.hmcl.download.HMCLDownloadProvider;
|
||||||
import org.jackhuang.hmcl.task.FetchTask;
|
import org.jackhuang.hmcl.task.FetchTask;
|
||||||
import org.jackhuang.hmcl.util.Lang;
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
import org.jackhuang.hmcl.util.StringUtils;
|
import org.jetbrains.annotations.Unmodifiable;
|
||||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
|
||||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
|
||||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
|
||||||
|
|
||||||
import javax.net.ssl.SSLHandshakeException;
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.AccessDeniedException;
|
|
||||||
import java.util.List;
|
import java.util.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.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 {
|
public final class DownloadProviders {
|
||||||
private DownloadProviders() {
|
private DownloadProviders() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
|
private static final MirrorRoots MIRROR_ROOTS =
|
||||||
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
|
new MirrorRoots(System.getProperty("hmcl.bmclapi.override"));
|
||||||
|
|
||||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(MIRROR_ROOTS::getRankedRoots);
|
||||||
|
|
||||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
/// Initializes download provider settings, mirror probing, and download thread settings.
|
||||||
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
|
public static void init() {
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void init() {
|
|
||||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||||
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
|
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
|
||||||
? DEFAULT_CONCURRENCY
|
? DEFAULT_CONCURRENCY
|
||||||
: config().getDownloadThreads());
|
: settings().downloadThreadsProperty().get());
|
||||||
};
|
};
|
||||||
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||||
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||||
onChangeDownloadThreads.invalidated(null);
|
onChangeDownloadThreads.invalidated(null);
|
||||||
|
|
||||||
InvalidationListener onChangeDownloadSource = observable -> {
|
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
|
||||||
if (config().isAutoChooseDownloadType()) {
|
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
|
||||||
String versionListSource = config().getVersionListSource();
|
|
||||||
DownloadProvider downloadProvider = versionListSource != null
|
MIRROR_ROOTS.maybeProbeAsync();
|
||||||
? 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
|
||||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
|
||||||
config().downloadTypeProperty().addListener(onChangeDownloadSource);
|
|
||||||
onChangeDownloadSource.invalidated(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/// Get current primary preferred download provider
|
||||||
* Get current primary preferred download provider
|
|
||||||
*/
|
|
||||||
public static DownloadProvider getDownloadProvider() {
|
public static DownloadProvider getDownloadProvider() {
|
||||||
return PROVIDER_WRAPPER;
|
MIRROR_ROOTS.maybeProbeAsync();
|
||||||
|
return PROVIDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String localizeErrorMessage(Throwable exception) {
|
/// Returns the current ranked BMCLAPI-compatible mirror roots (fastest first).
|
||||||
if (exception instanceof DownloadException) {
|
public static @Unmodifiable List<String> getRankedMirrorRoots() {
|
||||||
URI uri = ((DownloadException) exception).getUri();
|
return MIRROR_ROOTS.getRankedRoots();
|
||||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
|
||||||
return i18n("install.failed.downloading.timeout", uri);
|
|
||||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
|
||||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
|
||||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
|
||||||
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
|
||||||
} else {
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
|
||||||
}
|
|
||||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
|
||||||
return i18n("download.code.404", uri);
|
|
||||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
|
||||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
|
|
||||||
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
|
|
||||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
|
|
||||||
}
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
|
|
||||||
} else {
|
|
||||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
|
||||||
}
|
|
||||||
} else if (exception instanceof ArtifactMalformedException) {
|
|
||||||
return i18n("exception.artifact_malformed");
|
|
||||||
} else if (exception instanceof CancellationException) {
|
|
||||||
return i18n("message.cancelled");
|
|
||||||
}
|
|
||||||
return StringUtils.getStackTrace(exception);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
|
|
||||||
|
/// Preferred source selection for download providers.
|
||||||
|
@NotNullByDefault
|
||||||
|
public enum DownloadSource {
|
||||||
|
/// Automatically chooses the best source for the current environment.
|
||||||
|
DEFAULT,
|
||||||
|
|
||||||
|
/// Prefers official download sources.
|
||||||
|
OFFICIAL,
|
||||||
|
|
||||||
|
/// Prefers mirror download sources.
|
||||||
|
MIRROR,
|
||||||
|
}
|
||||||
@@ -1,27 +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;
|
|
||||||
|
|
||||||
public enum EnumBackgroundImage {
|
|
||||||
DEFAULT,
|
|
||||||
CUSTOM,
|
|
||||||
CLASSIC,
|
|
||||||
NETWORK,
|
|
||||||
TRANSLUCENT, // Deprecated
|
|
||||||
PAINT
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
public enum EnumCommonDirectory {
|
public enum EnumCommonDirectory {
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.setting;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
@@ -37,7 +22,7 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.*;
|
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;
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,17 +41,17 @@ public final class FontManager {
|
|||||||
|
|
||||||
// Recommended
|
// Recommended
|
||||||
|
|
||||||
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
|
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
|
||||||
if (font != null)
|
if (font != null)
|
||||||
return font;
|
return font;
|
||||||
|
|
||||||
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
|
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
|
||||||
if (font != null)
|
if (font != null)
|
||||||
return font;
|
return font;
|
||||||
|
|
||||||
// Legacy
|
// Legacy
|
||||||
|
|
||||||
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
|
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
|
||||||
if (font != null)
|
if (font != null)
|
||||||
return font;
|
return font;
|
||||||
|
|
||||||
@@ -74,7 +59,7 @@ public final class FontManager {
|
|||||||
if (font != null)
|
if (font != null)
|
||||||
return font;
|
return font;
|
||||||
|
|
||||||
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
|
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
|
||||||
if (font != null)
|
if (font != null)
|
||||||
return font;
|
return font;
|
||||||
|
|
||||||
@@ -97,13 +82,34 @@ public final class FontManager {
|
|||||||
|
|
||||||
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
||||||
|
|
||||||
|
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
|
||||||
|
private static final String[] BUNDLED_FONTS = {
|
||||||
|
"/assets/fonts/NotoSansSC-Regular.ttf",
|
||||||
|
"/assets/fonts/NotoSansSC-Bold.ttf",
|
||||||
|
"/assets/fonts/NotoSerifSC-Regular.ttf"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
|
||||||
|
private static void loadBundledFonts() {
|
||||||
|
for (String resource : BUNDLED_FONTS) {
|
||||||
|
try (var in = FontManager.class.getResourceAsStream(resource)) {
|
||||||
|
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
|
||||||
|
LOG.warning("Failed to load bundled font " + resource);
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
LOG.warning("Failed to load bundled font " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static {
|
static {
|
||||||
|
loadBundledFonts();
|
||||||
updateFont();
|
updateFont();
|
||||||
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void updateFont() {
|
private static void updateFont() {
|
||||||
String fontFamily = config().getLauncherFontFamily();
|
String fontFamily = settings().launcherFontFamilyProperty().get();
|
||||||
if (fontFamily == null)
|
if (fontFamily == null)
|
||||||
fontFamily = System.getProperty("hmcl.font.override");
|
fontFamily = System.getProperty("hmcl.font.override");
|
||||||
if (fontFamily == null)
|
if (fontFamily == null)
|
||||||
@@ -236,7 +242,7 @@ public final class FontManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void setFontFamily(String fontFamily) {
|
public static void setFontFamily(String fontFamily) {
|
||||||
config().setLauncherFontFamily(fontFamily);
|
settings().launcherFontFamilyProperty().set(fontFamily);
|
||||||
updateFont();
|
updateFont();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import javafx.beans.Observable;
|
||||||
|
import javafx.beans.property.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,195 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
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,55 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import 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,498 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.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.*;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
|
||||||
|
if (needRebuildGameDirectories) {
|
||||||
|
rebuildGameDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert !mergedGameDirectories.isEmpty();
|
||||||
|
|
||||||
|
// Dedicated server client: always prefer the fixed workspace `.minecraft` directory.
|
||||||
|
GameDirectory currentGameDirectory = null;
|
||||||
|
for (GameDirectory gameDirectory : mergedGameDirectories) {
|
||||||
|
if (DEFAULT_GAME_DIRECTORY_ID.equals(gameDirectory.getId())
|
||||||
|
|| isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
|
||||||
|
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 fixed workspace game directory when no game directory exists.
|
||||||
|
private static boolean createDefaultGameDirectoriesIfEmpty() {
|
||||||
|
if (localGameDirectories().getGameDirectories().isEmpty()
|
||||||
|
&& userGameDirectories().getGameDirectories().isEmpty()) {
|
||||||
|
localGameDirectories().getGameDirectories()
|
||||||
|
.add(new GameDirectory(
|
||||||
|
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||||
|
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);
|
||||||
|
rebuildGameDirectories();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||||
|
import org.jackhuang.hmcl.game.GameComponentType;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
public enum GameInstanceIconType {
|
||||||
|
DEFAULT("/assets/img/grass.png"),
|
||||||
|
|
||||||
|
GRASS("/assets/img/grass.png"),
|
||||||
|
CHEST("/assets/img/chest.png"),
|
||||||
|
CHICKEN("/assets/img/chicken.png"),
|
||||||
|
COMMAND("/assets/img/command.png"),
|
||||||
|
OPTIFINE("/assets/img/optifine.png"),
|
||||||
|
CRAFT_TABLE("/assets/img/craft_table.png"),
|
||||||
|
FABRIC("/assets/img/fabric.png"),
|
||||||
|
FORGE("/assets/img/forge.png"),
|
||||||
|
NEO_FORGE("/assets/img/neoforge.png"),
|
||||||
|
FURNACE("/assets/img/furnace.png"),
|
||||||
|
QUILT("/assets/img/quilt.png"),
|
||||||
|
/// Retained for settings migration; UI no longer offers this icon.
|
||||||
|
APRIL_FOOLS("/assets/img/command.png"),
|
||||||
|
CLEANROOM("/assets/img/cleanroom.png"),
|
||||||
|
LEGACY_FABRIC("/assets/img/legacyfabric.png")
|
||||||
|
;
|
||||||
|
|
||||||
|
// Please append new items at last
|
||||||
|
|
||||||
|
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
|
||||||
|
return switch (modLoaderType) {
|
||||||
|
case FORGE -> GameInstanceIconType.FORGE;
|
||||||
|
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||||
|
case FABRIC -> GameInstanceIconType.FABRIC;
|
||||||
|
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
|
||||||
|
case QUILT -> GameInstanceIconType.QUILT;
|
||||||
|
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
|
||||||
|
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||||
|
default -> GameInstanceIconType.COMMAND;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
|
||||||
|
return switch (componentType) {
|
||||||
|
case GAME -> GameInstanceIconType.GRASS;
|
||||||
|
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
|
||||||
|
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
|
||||||
|
case FORGE -> GameInstanceIconType.FORGE;
|
||||||
|
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||||
|
case LITELOADER -> GameInstanceIconType.CHICKEN;
|
||||||
|
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
|
||||||
|
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
|
||||||
|
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private final String resourceUrl;
|
||||||
|
|
||||||
|
GameInstanceIconType(String resourceUrl) {
|
||||||
|
this.resourceUrl = resourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Image getIcon() {
|
||||||
|
return FXUtils.newBuiltinImage(resourceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import 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,154 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import javafx.beans.Observable;
|
||||||
|
import javafx.beans.property.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, 1));
|
||||||
|
|
||||||
|
/// 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,17 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
/// The initial display mode of the game window.
|
||||||
|
///
|
||||||
|
/// @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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.jackhuang.hmcl.setting;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
/**
|
import org.jetbrains.annotations.NotNullByDefault;
|
||||||
* @author Glavo
|
|
||||||
*/
|
/// 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 {
|
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,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||||
|
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,210 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
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,627 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
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.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.
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// 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 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.MIRROR);
|
||||||
|
|
||||||
|
/// 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.MIRROR);
|
||||||
|
|
||||||
|
/// 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,191 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.JsonAdapter;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import javafx.beans.Observable;
|
||||||
|
import javafx.beans.property.DoubleProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleDoubleProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
/*
|
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||||
* Hello Minecraft! Launcher
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
* 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;
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,542 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
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,82 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||||
|
package org.jackhuang.hmcl.setting;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 1226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user