Compare commits
613
Commits
release/3.13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdacd65473 | ||
|
|
f176fbd1fd | ||
|
|
9d43a6fc0f | ||
|
|
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 | ||
|
|
40d21bbe24 | ||
|
|
d0be7efca3 | ||
|
|
3bed03c535 | ||
|
|
d295e606a9 | ||
|
|
36afe32146 | ||
|
|
87dbbdd96f | ||
|
|
9b8e67b1b3 | ||
|
|
f5a5e6a6da | ||
|
|
f0466c1d8e | ||
|
|
903ecbb127 | ||
|
|
4c6f3aeb99 | ||
|
|
d6a4b3480f | ||
|
|
5e84e5bcd6 | ||
|
|
6ce7af05b2 | ||
|
|
0d62a12353 | ||
|
|
880aab0ba5 | ||
|
|
0b64e89aaa | ||
|
|
7b6379674b | ||
|
|
55f4b0d8fc | ||
|
|
863c3c9c7f | ||
|
|
1f5fb983b0 | ||
|
|
856d5ec5af | ||
|
|
20a5670876 | ||
|
|
bdf6a58be8 | ||
|
|
7c74dd438f | ||
|
|
5803235407 | ||
|
|
afbf567fef | ||
|
|
72b3c1981a | ||
|
|
72ee82543b | ||
|
|
996c9f4c86 | ||
|
|
f8c1e97376 | ||
|
|
f146040ff3 | ||
|
|
0da28173ab | ||
|
|
be822666a7 | ||
|
|
8c99ee3b0d | ||
|
|
0e3b4a7853 | ||
|
|
615b1dbaae | ||
|
|
0fa51f830d | ||
|
|
007bb345d0 | ||
|
|
46536f11d3 | ||
|
|
b1232b5dc8 | ||
|
|
f8f047d67e | ||
|
|
86529c6f15 | ||
|
|
d63829c344 | ||
|
|
34359a7e86 | ||
|
|
aa335d8365 | ||
|
|
a565d3b7ce | ||
|
|
3bff3c7f23 | ||
|
|
4e6e832e9c | ||
|
|
d6648a840e | ||
|
|
f8c2af716f | ||
|
|
bd812ccb67 | ||
|
|
dad190f7e6 | ||
|
|
1b1ba64a38 | ||
|
|
e31268c39d | ||
|
|
cb9d32dc91 | ||
|
|
36568df5b1 | ||
|
|
b4ac42d789 | ||
|
|
32aafb11a3 | ||
|
|
36dab70b7a | ||
|
|
ebf253b1a7 | ||
|
|
539dcca5ca | ||
|
|
659b6c2a70 | ||
|
|
3dcc7ab311 | ||
|
|
e0ccb58272 | ||
|
|
d4ec8ebec8 | ||
|
|
f3b21e5099 | ||
|
|
202fd38180 | ||
|
|
f5768f2f95 | ||
|
|
2d377d06de | ||
|
|
42d1581ba0 | ||
|
|
995a23c2bc | ||
|
|
8969f55a1f | ||
|
|
312a285792 | ||
|
|
d85b052195 | ||
|
|
cd0d3d1b97 | ||
|
|
6ccc7df998 | ||
|
|
73e5909a69 | ||
|
|
ade487277d | ||
|
|
e755d17977 | ||
|
|
292288de9e | ||
|
|
57f6dec462 | ||
|
|
8e1a549c9d | ||
|
|
1d18eae377 | ||
|
|
6ef04d2fa5 | ||
|
|
1778e9d916 | ||
|
|
dbb63e9faa | ||
|
|
6b1c3182eb |
No files matched your search
@@ -6,3 +6,6 @@ contact_links:
|
||||
- name: Discord 服务器
|
||||
url: https://discord.gg/jVvC7HfM6U
|
||||
about: Hello Minecraft! Launcher 的官方 Discord 服务器。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -1,3 +0,0 @@
|
||||
code_review:
|
||||
pull_request_opened:
|
||||
code_review: false
|
||||
@@ -0,0 +1,69 @@
|
||||
# Default: normalize all text files to LF in the repository and on checkout.
|
||||
# Git auto-detects binary files and leaves them untouched.
|
||||
* text=auto eol=lf
|
||||
|
||||
# ─── Explicitly text – source & build ────────────────────────────────────────
|
||||
*.java text eol=lf
|
||||
*.js text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.groovy text eol=lf
|
||||
*.gradle text eol=lf
|
||||
|
||||
# ─── Configuration & data ─────────────────────────────────────────────────────
|
||||
*.properties text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.MF text eol=lf
|
||||
|
||||
# Gradle dependency-lock files
|
||||
*.lockfile text eol=lf
|
||||
|
||||
# ─── Scripts ──────────────────────────────────────────────────────────────────
|
||||
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# CI pipeline scripts
|
||||
Jenkinsfile text eol=lf
|
||||
|
||||
# Windows batch files must keep CRLF so cmd.exe can parse them.
|
||||
*.bat text eol=crlf
|
||||
|
||||
# ─── Binary files ─────────────────────────────────────────────────────────────
|
||||
# Raster / animated images
|
||||
*.png binary
|
||||
*.apng binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
|
||||
# Archives & compiled artifacts
|
||||
*.jar binary
|
||||
*.zip binary
|
||||
*.class binary
|
||||
|
||||
# Cryptographic / certificate files
|
||||
*.der binary
|
||||
|
||||
# Minecraft schematic
|
||||
*.litematic binary
|
||||
*.nbt binary
|
||||
*.schem binary
|
||||
*.schematic binary
|
||||
|
||||
# Compiled / intermediate build artefacts
|
||||
*.bin binary
|
||||
*.probe binary
|
||||
|
||||
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: QQ 群
|
||||
url: https://docs.hmcl.net/groups.html
|
||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||
- name: GitHub Issue
|
||||
url: https://github.com/HMCL-dev/HMCL/issues/new/choose
|
||||
about: 本仓库为镜像仓库,Bug 报告与功能请求请在 GitHub 上提交。
|
||||
@@ -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
|
||||
|
||||
# Pushes to main and pull requests only. A branch with an open pull request used to build twice
|
||||
# (once for the push, once for the pull request), which kept the runners queued.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
# A newer run for the same pull request or branch replaces the older one.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -14,18 +25,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
distribution: 'liberica'
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-cleanup: never
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build --no-daemon --parallel
|
||||
# Only main writes the dependency cache; pull requests read it.
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
# The repository's wrapper downloads Gradle itself, so cache that download too.
|
||||
- name: Cache Gradle distribution
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper
|
||||
.gradle/wrapper-neo
|
||||
key: gradle-dist-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/wrapper/GradleWrapperNeo.java') }}
|
||||
- name: Install Xvfb
|
||||
run: |
|
||||
# Installing is the slowest setup step, so skip it when the runner already has xvfb, and
|
||||
# only refresh the package index when a plain install fails.
|
||||
command -v xvfb-run >/dev/null || sudo apt-get install -y --no-install-recommends xvfb \
|
||||
|| { sudo apt-get update && sudo apt-get install -y --no-install-recommends xvfb; }
|
||||
# One Gradle run builds, tests and runs the code and translation checks, so the
|
||||
# project is configured and compiled once instead of in two workflows.
|
||||
- name: Build and check with Gradle
|
||||
run: xvfb-run --auto-servernum ./gradlew build checkstyle checkTranslations --no-daemon --parallel
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
@@ -48,3 +78,9 @@ jobs:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-sh
|
||||
path: HMCL/build/libs/HMCL-*.sh
|
||||
archive: false
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-deb
|
||||
path: HMCL/build/libs/HMCL-*.deb
|
||||
archive: false
|
||||
@@ -0,0 +1,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"
|
||||
@@ -46,16 +46,20 @@ jobs:
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
|
||||
- name: Verify artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
|
||||
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
|
||||
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
|
||||
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
|
||||
|
||||
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
|
||||
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
|
||||
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
|
||||
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -67,6 +71,7 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
|
||||
|
||||
# CNB Release Note
|
||||
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
|
||||
@@ -76,6 +81,8 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
|
||||
env:
|
||||
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
|
||||
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
|
||||
@@ -86,6 +93,7 @@ jobs:
|
||||
"HMCL-${{ env.HMCL_VERSION }}.exe" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.jar" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.sh" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.deb" \
|
||||
--target "${{ env.HMCL_COMMIT_SHA }}" \
|
||||
--title "${{ env.HMCL_TAG_NAME }}" \
|
||||
--notes-file RELEASE_NOTE \
|
||||
@@ -120,6 +128,9 @@ jobs:
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.sh"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.deb"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
|
||||
CNB_REPO: HMCL-dev/HMCL
|
||||
+11
@@ -4,7 +4,10 @@
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
*.jfr
|
||||
|
||||
.gradle
|
||||
gradle.properties
|
||||
|
||||
*.lck
|
||||
*.1
|
||||
@@ -16,6 +19,8 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
hmcl-mod-update-list-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
@@ -43,6 +48,7 @@ language-subtag-registry
|
||||
# eclipse
|
||||
/bin/
|
||||
/HMCL/bin/
|
||||
/HMCLBoot/bin/
|
||||
/HMCLCore/bin/
|
||||
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
||||
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
||||
@@ -69,3 +75,8 @@ language-subtag-registry
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.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">
|
||||
<copyright>
|
||||
<option name="notice" value="Hello Minecraft! Launcher Copyright (C) &#36;today.year huangyuhui <huanghongxun2008@126.com> and contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>." />
|
||||
<option name="myName" value="HMCL" />
|
||||
</copyright>
|
||||
<copyright>
|
||||
<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" />
|
||||
</copyright>
|
||||
</component>
|
||||
Generated
+1
@@ -3,6 +3,7 @@
|
||||
<LanguageOptions name="JAVA">
|
||||
<option name="fileTypeOverride" value="3" />
|
||||
<option name="addBlankAfter" value="false" />
|
||||
<option name="block" value="false" />
|
||||
</LanguageOptions>
|
||||
</settings>
|
||||
</component>
|
||||
@@ -0,0 +1,25 @@
|
||||
# Java Code Style Requirements
|
||||
|
||||
These rules apply to all Java code written or modified in this repository.
|
||||
|
||||
## Nullability
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
|
||||
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
||||
- Nullability in code being written or modified must never be implicit.
|
||||
|
||||
## Immutability
|
||||
|
||||
- Immutable arrays and collections must be explicitly annotated with JetBrains Annotations `@Unmodifiable` or `@UnmodifiableView` as appropriate.
|
||||
- For arrays, use type-use syntax such as `String @Unmodifiable []`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class, field, and method must have documentation.
|
||||
- Documentation must use `///` Markdown-style Javadoc comments.
|
||||
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
|
||||
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
|
||||
+105
-75
@@ -1,12 +1,16 @@
|
||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
||||
import org.jackhuang.hmcl.gradle.addon.FormatJsonFile
|
||||
import org.jackhuang.hmcl.gradle.addon.UpdateAddonProjectInfos
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
import org.jackhuang.hmcl.gradle.utils.OfficialBuildValueSource
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
@@ -50,17 +54,20 @@ if (buildNumber != null) {
|
||||
}
|
||||
}
|
||||
|
||||
val embedResources by configurations.registering
|
||||
val embedResources = configurations.register("embedResources")
|
||||
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.fxsvgimage)
|
||||
implementation(libs.java.info)
|
||||
implementation(libs.monet.fx)
|
||||
implementation(libs.nayuki.qrcodegen)
|
||||
implementation(libs.uuid.tools)
|
||||
|
||||
testImplementation(libs.jimfs)
|
||||
|
||||
if (launcherExe.isBlank()) {
|
||||
implementation(libs.hmclauncher)
|
||||
@@ -139,6 +146,7 @@ val addOpens = listOf(
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.graphics/com.sun.javafx.tk.quantum",
|
||||
"javafx.controls/javafx.scene.control",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
@@ -147,33 +155,28 @@ tasks.compileJava {
|
||||
options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" })
|
||||
}
|
||||
|
||||
val hmclProperties = buildList {
|
||||
add("hmcl.version" to project.version.toString())
|
||||
add("hmcl.add-opens" to addOpens.joinToString(" "))
|
||||
System.getenv("GITHUB_SHA")?.let {
|
||||
add("hmcl.version.hash" to it)
|
||||
}
|
||||
add("hmcl.version.type" to versionType)
|
||||
add("hmcl.microsoft.auth.id" to microsoftAuthId)
|
||||
add("hmcl.curseforge.apikey" to curseForgeApiKey)
|
||||
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
|
||||
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
|
||||
}
|
||||
val createPropertiesFile = tasks.register<WriteProperties>("createPropertiesFile") {
|
||||
destinationFile.set(layout.buildDirectory.file("hmcl.properties"))
|
||||
encoding = "UTF-8"
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
val createPropertiesFile by tasks.registering {
|
||||
outputs.file(hmclPropertiesFile)
|
||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
||||
|
||||
doLast {
|
||||
val targetFile = hmclPropertiesFile.get().asFile
|
||||
targetFile.parentFile.mkdir()
|
||||
targetFile.bufferedWriter().use {
|
||||
for ((k, v) in hmclProperties) {
|
||||
it.write("$k=$v\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
property("hmcl.version", project.version.toString())
|
||||
property("hmcl.add-opens", addOpens.joinToString(" "))
|
||||
System.getenv("GITHUB_SHA")?.let { property("hmcl.version.hash", it) }
|
||||
property("hmcl.version.type", versionType)
|
||||
property("hmcl.microsoft.auth.id", microsoftAuthId)
|
||||
property("hmcl.curseforge.apikey", curseForgeApiKey)
|
||||
property("hmcl.authlib-injector.version", libs.authlib.injector.get().version!!)
|
||||
property("hmcl.lwjgl-unsafe-agent.version", libs.lwjgl.unsafe.agent.get().version!!)
|
||||
property("hmcl.official", providers.of(OfficialBuildValueSource::class) {
|
||||
parameters.publicKeyFile.set(layout.projectDirectory.file("src/main/resources/assets/hmcl_signature_publickey.der"))
|
||||
parameters.privateKeyLocation.set(providers.environmentVariable("HMCL_SIGNATURE_KEY"))
|
||||
// Pin the official public key independently of the replaceable resource file.
|
||||
// Under HMCL’s license agreement, you **MUST** change the software name or the version number in
|
||||
// a reasonable way to distinguish it from the original version.
|
||||
// You may modify this hash if and only if you have changed the software name or version number
|
||||
// in accordance with the license requirements.
|
||||
parameters.expectedSha256.set("36aadcfde8626b9d4c7a74facb3fdfda47e703c205a673fbe6a56b1d419f8b8e")
|
||||
})
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
@@ -195,7 +198,7 @@ tasks.shadowJar {
|
||||
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
|
||||
|
||||
listOf(
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*",
|
||||
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
|
||||
).forEach { exclude("com/sun/jna/$it/**") }
|
||||
|
||||
@@ -207,7 +210,7 @@ tasks.shadowJar {
|
||||
}
|
||||
|
||||
manifest.attributes(
|
||||
"Created-By" to "Copyright(c) 2013-2025 huangyuhui.",
|
||||
"Created-By" to "Copyright(c) 2013-2026 The HMCL Authors",
|
||||
"Implementation-Version" to project.version.toString(),
|
||||
"Main-Class" to "org.jackhuang.hmcl.Main",
|
||||
"Multi-Release" to "true",
|
||||
@@ -229,13 +232,12 @@ tasks.shadowJar {
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
dependsOn(createPropertiesFile)
|
||||
dependsOn(upsideDownTranslate)
|
||||
dependsOn(createLocaleNamesResourceBundle)
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
into("assets/") {
|
||||
from(hmclPropertiesFile)
|
||||
from(createPropertiesFile.flatMap { it.destinationFile })
|
||||
from(embedResources)
|
||||
}
|
||||
|
||||
@@ -245,26 +247,24 @@ tasks.processResources {
|
||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||
}
|
||||
|
||||
inputs.property("terracotta_version", libs.versions.terracotta)
|
||||
doLast {
|
||||
upgradeTerracottaConfig.get().checkValid()
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
val makeExecutables = tasks.register("makeExecutables") {
|
||||
val extensions = listOf("exe", "sh")
|
||||
|
||||
dependsOn(tasks.jar)
|
||||
|
||||
inputs.file(jarPath)
|
||||
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
|
||||
outputs.files(extensions.map { artifactFile(it) })
|
||||
|
||||
doLast {
|
||||
val jarContent = jarPath.readBytes()
|
||||
|
||||
ZipFile(jarPath).use { zipFile ->
|
||||
for (extension in extensions) {
|
||||
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
|
||||
val output = artifactFile(extension)
|
||||
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
|
||||
?: throw GradleException("HMCLauncher.$extension not found")
|
||||
|
||||
@@ -279,8 +279,32 @@ val makeExecutables by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
|
||||
dependsOn(makeExecutables)
|
||||
|
||||
val debFile = layout.file(provider { artifactFile("deb") })
|
||||
|
||||
val debChannel = when (versionType) {
|
||||
"stable" -> ReleaseType.STABLE
|
||||
"dev" -> ReleaseType.DEVELOPMENT
|
||||
else -> ReleaseType.NIGHTLY
|
||||
}
|
||||
|
||||
version.set(project.version.toString())
|
||||
releaseType.set(debChannel)
|
||||
launcherClassName.set("org.jackhuang.hmcl.Launcher")
|
||||
appShFile.set(layout.file(provider { artifactFile("sh") }))
|
||||
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
|
||||
outputFile.set(debFile)
|
||||
|
||||
doLast {
|
||||
createChecksum(debFile.get().asFile)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn(makeExecutables)
|
||||
dependsOn(makeDeb)
|
||||
}
|
||||
|
||||
fun parseToolOptions(options: String?): MutableList<String> {
|
||||
@@ -333,6 +357,11 @@ fun parseToolOptions(options: String?): MutableList<String> {
|
||||
return result
|
||||
}
|
||||
|
||||
// Tests use the same JavaFX reflective access as the packaged application.
|
||||
tasks.withType<Test> {
|
||||
jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" })
|
||||
}
|
||||
|
||||
// For IntelliJ IDEA
|
||||
tasks.withType<JavaExec> {
|
||||
if (name != "run") {
|
||||
@@ -371,25 +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
|
||||
|
||||
@@ -397,42 +407,62 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
|
||||
englishFile.set(dir.file("I18N.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
|
||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||
}
|
||||
|
||||
// l10n
|
||||
|
||||
tasks.register<SyncTranslations>("syncTranslations") {
|
||||
group = "localization"
|
||||
description = "Synchronizes translation keys and blank lines with I18N.properties."
|
||||
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
sourceFile.set(dir.file("I18N.properties"))
|
||||
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
|
||||
}
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
|
||||
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
|
||||
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
|
||||
}
|
||||
|
||||
val createLanguageList by tasks.registering(CreateLanguageList::class) {
|
||||
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
|
||||
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||
resourceBundleBaseName.set("I18N")
|
||||
additionalLanguages.set(listOf("en-Qabs"))
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
dependsOn(createLanguageList)
|
||||
val createLocaleNamesResourceBundle =
|
||||
tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
|
||||
// mcmod data
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModData") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModJson") {
|
||||
file.set(layout.projectDirectory.file("mod.json"))
|
||||
}
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModPackData") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt"))
|
||||
tasks.register<FormatJsonFile>("formatModpackJson") {
|
||||
file.set(layout.projectDirectory.file("modpack.json"))
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("mod.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/mod.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MOD)
|
||||
}
|
||||
|
||||
tasks.register<UpdateAddonProjectInfos>("updateModpackProjectInfos") {
|
||||
inputFile.set(layout.projectDirectory.file("modpack.json"))
|
||||
outputFile.set(layout.projectDirectory.file("src/main/resources/assets/addon/modpack.csv"))
|
||||
projectType.set(UpdateAddonProjectInfos.AddonProjectType.MODPACK)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXCheckBoxSkin;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.CheckBoxTreeItem;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
|
||||
///
|
||||
/// The checkbox's selected and indeterminate states are bidirectionally bound to
|
||||
/// the current tree item. Reusing or clearing the cell removes the old bindings.
|
||||
/// Installing a checkbox skin or switching tree items applies the selection state
|
||||
/// immediately, without playing a selection animation.
|
||||
/// Ordinary tree items retain the display provided by [JFXTreeCell].
|
||||
/// Empty cells and `null` values display neither content nor a checkbox.
|
||||
///
|
||||
/// @param <T> the type of value stored in each tree item
|
||||
@NotNullByDefault
|
||||
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
|
||||
|
||||
/// The checkbox bound to the currently displayed checkable tree item.
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
|
||||
/// Places the checkbox before the graphic supplied by the superclass.
|
||||
private final HBox graphic = new HBox();
|
||||
|
||||
/// The tree item whose selection properties are currently bound, if any.
|
||||
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
|
||||
|
||||
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
|
||||
public JFXCheckTreeCell() {
|
||||
checkBox.setAllowIndeterminate(false);
|
||||
checkBox.setFocusTraversable(false);
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
graphic.setPickOnBounds(false);
|
||||
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
|
||||
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
|
||||
}
|
||||
|
||||
/// Finishes animations from the previous item and immediately displays the current state.
|
||||
private void finishSelectionAnimation() {
|
||||
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
|
||||
skin.finishSelectionAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the checkbox to the inherited display and updates its selection bindings.
|
||||
@Override
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
// Release graphics before the superclass reuses or reparents them.
|
||||
graphic.getChildren().clear();
|
||||
super.updateDisplay(item, empty);
|
||||
|
||||
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
|
||||
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
|
||||
if (boundTreeItem != treeItem) {
|
||||
if (boundTreeItem != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
|
||||
}
|
||||
boundTreeItem = treeItem;
|
||||
if (treeItem != null) {
|
||||
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
|
||||
} else {
|
||||
checkBox.setSelected(false);
|
||||
checkBox.setIndeterminate(false);
|
||||
}
|
||||
finishSelectionAnimation();
|
||||
}
|
||||
|
||||
if (treeItem != null) {
|
||||
@Nullable Node itemGraphic = getGraphic();
|
||||
graphic.getChildren().add(checkBox);
|
||||
if (itemGraphic != null) {
|
||||
graphic.getChildren().add(itemGraphic);
|
||||
}
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
private void initialize() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
this.setCellFactory(listView -> new JFXListCell<T>() {
|
||||
this.setCellFactory(listView -> new JFXListCell<>() {
|
||||
@Override
|
||||
public void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
// had to refactor the code out of the skin class to allow
|
||||
// customization of the button cell
|
||||
this.setButtonCell(new ListCell<T>() {
|
||||
this.setButtonCell(new ListCell<>() {
|
||||
{
|
||||
// fixed clearing the combo box value is causing
|
||||
// java prompt text to be shown because the button cell is not updated
|
||||
|
||||
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
|
||||
*/
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
T oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
|
||||
super.updateItem(item, empty);
|
||||
if (empty) {
|
||||
setText(null);
|
||||
|
||||
@@ -38,7 +38,6 @@ import javafx.scene.paint.Paint;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
@@ -68,14 +67,18 @@ public class JFXPasswordField extends PasswordField {
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
|
||||
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
|
||||
}
|
||||
|
||||
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
|
||||
// https://github.com/HMCL-dev/HMCL/issues/5822
|
||||
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-password-field'.
|
||||
* <p>
|
||||
@@ -294,4 +297,3 @@ public class JFXPasswordField extends PasswordField {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -278,6 +278,10 @@ public class JFXRippler extends StackPane {
|
||||
rippler.releaseRipple();
|
||||
}
|
||||
|
||||
public void releaseRippleImmediately() {
|
||||
rippler.releaseRippleImmediately();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates Ripple effect in the center of the control
|
||||
*
|
||||
@@ -405,6 +409,21 @@ public class JFXRippler extends StackPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseRippleImmediately() {
|
||||
Ripple ripple = ripplesQueue.poll();
|
||||
if (ripple != null) {
|
||||
getChildren().remove(ripple);
|
||||
if (generating.getAndSet(false)) {
|
||||
if (overlayRect != null) {
|
||||
overlayRect.inAnimation.stop();
|
||||
if (!forceOverlay) {
|
||||
overlayRect.setOpacity(0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cacheRippleClip(boolean cached) {
|
||||
cacheRipplerClip = cached;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
StackPane toastContainer = new StackPane(toast);
|
||||
toastContainer.setPadding(new Insets(20));
|
||||
actionContainer = new StackPane();
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
actionContainer.setVisible(false);
|
||||
actionContainer.setManaged(false);
|
||||
|
||||
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
||||
if (getPrefWidth() == -1) {
|
||||
@@ -78,6 +79,7 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
actionContainer.getChildren().add(action);
|
||||
|
||||
if (!actionText.isEmpty()) {
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
action.setVisible(true);
|
||||
actionContainer.setVisible(true);
|
||||
actionContainer.setManaged(true);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXSwitchSkin;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.css.SimpleStyleableDoubleProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableDoubleProperty;
|
||||
import javafx.css.StyleablePropertyFactory;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.scene.AccessibleAction;
|
||||
import javafx.scene.AccessibleAttribute;
|
||||
import javafx.scene.AccessibleRole;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// A Material Design 3 switch for a boolean setting.
|
||||
///
|
||||
/// Supports clicking, dragging, and Space-key activation. Labels belong to the surrounding layout;
|
||||
/// supply an accessible text description. Size and colors are configurable through CSS.
|
||||
@NotNullByDefault
|
||||
public class JFXSwitch extends Control {
|
||||
|
||||
/// Creates an unselected switch.
|
||||
public JFXSwitch() {
|
||||
getStyleClass().setAll("jfx-switch");
|
||||
setFocusTraversable(true);
|
||||
setAccessibleRole(AccessibleRole.TOGGLE_BUTTON);
|
||||
}
|
||||
|
||||
/// Selection state.
|
||||
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected") {
|
||||
/// Updates visual and accessible selection without firing an action.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
pseudoClassStateChanged(SELECTED, get());
|
||||
notifyAccessibleAttributeChanged(AccessibleAttribute.SELECTED);
|
||||
}
|
||||
};
|
||||
|
||||
/// Changes selection without firing an action event.
|
||||
public final void setSelected(boolean value) {
|
||||
selected.set(value);
|
||||
}
|
||||
|
||||
/// Returns whether the switch is selected.
|
||||
public final boolean isSelected() {
|
||||
return selected.get();
|
||||
}
|
||||
|
||||
/// Returns the bindable selection state; the default is false.
|
||||
public final BooleanProperty selectedProperty() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// Read-only activation state.
|
||||
private final ReadOnlyBooleanWrapper armed = new ReadOnlyBooleanWrapper(this, "armed") {
|
||||
/// Reflects input feedback in CSS.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
pseudoClassStateChanged(ARMED, get());
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns whether the switch is armed.
|
||||
public final boolean isArmed() {
|
||||
return armed.get();
|
||||
}
|
||||
|
||||
/// Returns the read-only state indicating a pending activation.
|
||||
public final ReadOnlyBooleanProperty armedProperty() {
|
||||
return armed.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Optional action handler.
|
||||
private @Nullable ObjectProperty<@Nullable EventHandler<ActionEvent>> onAction;
|
||||
|
||||
/// Sets or clears the action handler.
|
||||
public final void setOnAction(@Nullable EventHandler<ActionEvent> handler) {
|
||||
if (onAction != null || handler != null)
|
||||
onActionProperty().set(handler);
|
||||
}
|
||||
|
||||
/// Returns the action handler, or null when none is installed.
|
||||
public final @Nullable EventHandler<ActionEvent> getOnAction() {
|
||||
return onAction == null ? null : onAction.get();
|
||||
}
|
||||
|
||||
/// Returns the handler property for activation events.
|
||||
public final ObjectProperty<@Nullable EventHandler<ActionEvent>> onActionProperty() {
|
||||
if (onAction == null) {
|
||||
onAction = new SimpleObjectProperty<>(this, "onAction") {
|
||||
/// Updates the action handler.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
setEventHandler(ActionEvent.ACTION, get());
|
||||
}
|
||||
};
|
||||
}
|
||||
return onAction;
|
||||
}
|
||||
|
||||
/// The requested track height in logical pixels.
|
||||
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(SIZE, this, "size", 32.0) {
|
||||
/// Validates the size and requests layout.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
get();
|
||||
requestLayout();
|
||||
}
|
||||
};
|
||||
|
||||
/// Sets the track height; see [#sizeProperty()].
|
||||
public final void setSize(double value) {
|
||||
size.set(value);
|
||||
}
|
||||
|
||||
/// Returns the requested track height in logical pixels.
|
||||
public final double getSize() {
|
||||
return size.get();
|
||||
}
|
||||
|
||||
/// Track height in logical pixels, styled by `-jfx-size`; the default is 32.
|
||||
/// Scales the artwork and interaction target proportionally; nonpositive or nonfinite sizes render at the default size.
|
||||
public final StyleableDoubleProperty sizeProperty() {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// Optional selected-icon property.
|
||||
private @Nullable BooleanProperty showSelectedIcon;
|
||||
|
||||
/// Sets the value of [#showSelectedIconProperty()].
|
||||
public final void setShowSelectedIcon(boolean value) {
|
||||
if (showSelectedIcon != null || value)
|
||||
showSelectedIconProperty().set(value);
|
||||
}
|
||||
|
||||
/// Returns whether the selected icon is shown.
|
||||
public final boolean isShowSelectedIcon() {
|
||||
return showSelectedIcon != null && showSelectedIcon.get();
|
||||
}
|
||||
|
||||
/// Whether to show a check mark when selected; defaults to false.
|
||||
public final BooleanProperty showSelectedIconProperty() {
|
||||
if (showSelectedIcon == null)
|
||||
showSelectedIcon = createOptionProperty("showSelectedIcon", false, false);
|
||||
return showSelectedIcon;
|
||||
}
|
||||
|
||||
/// Optional unselected-icon property.
|
||||
private @Nullable BooleanProperty showUnselectedIcon;
|
||||
|
||||
/// Sets the value of [#showUnselectedIconProperty()].
|
||||
public final void setShowUnselectedIcon(boolean value) {
|
||||
if (showUnselectedIcon != null || value)
|
||||
showUnselectedIconProperty().set(value);
|
||||
}
|
||||
|
||||
/// Returns whether the unselected icon is shown.
|
||||
public final boolean isShowUnselectedIcon() {
|
||||
return showUnselectedIcon != null && showUnselectedIcon.get();
|
||||
}
|
||||
|
||||
/// Whether to show a cross when unselected; defaults to false.
|
||||
public final BooleanProperty showUnselectedIconProperty() {
|
||||
if (showUnselectedIcon == null)
|
||||
showUnselectedIcon = createOptionProperty("showUnselectedIcon", false, false);
|
||||
return showUnselectedIcon;
|
||||
}
|
||||
|
||||
/// Optional animation policy override.
|
||||
private @Nullable BooleanProperty disableAnimation;
|
||||
|
||||
/// Sets the value of [#disableAnimationProperty()].
|
||||
public final void setDisableAnimation(boolean value) {
|
||||
if (disableAnimation != null || value == AnimationUtils.isAnimationEnabled())
|
||||
disableAnimationProperty().set(value);
|
||||
}
|
||||
|
||||
/// Returns whether animation is disabled.
|
||||
public final boolean isDisableAnimation() {
|
||||
return disableAnimation == null ? !AnimationUtils.isAnimationEnabled() : disableAnimation.get();
|
||||
}
|
||||
|
||||
/// Disables animation, defaulting to the inverse of the global animation-enabled setting.
|
||||
public final BooleanProperty disableAnimationProperty() {
|
||||
if (disableAnimation == null)
|
||||
disableAnimation = createOptionProperty("disableAnimation", !AnimationUtils.isAnimationEnabled(), true);
|
||||
return disableAnimation;
|
||||
}
|
||||
|
||||
/// Arms input feedback unless the switch is disabled or its selection is bound.
|
||||
public final void arm() {
|
||||
if (!isDisabled() && !selected.isBound())
|
||||
armed.set(true);
|
||||
}
|
||||
|
||||
/// Clears input feedback without firing an action.
|
||||
public final void disarm() {
|
||||
armed.set(false);
|
||||
}
|
||||
|
||||
/// Toggles selection and fires one action; disabled or bound switches ignore activation.
|
||||
public void fire() {
|
||||
if (!isDisabled() && !selected.isBound()) {
|
||||
setSelected(!isSelected());
|
||||
fireEvent(new ActionEvent(this, this));
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the default switch skin.
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXSwitchSkin(this);
|
||||
}
|
||||
|
||||
/// Creates a property that updates the skin, optionally settling active animations.
|
||||
private BooleanProperty createOptionProperty(String name, boolean initialValue, boolean finishAnimation) {
|
||||
return new SimpleBooleanProperty(this, name, initialValue) {
|
||||
/// Validates the value even without a skin, so subsequent changes also reach this callback.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
get();
|
||||
if (getSkin() instanceof JFXSwitchSkin skin)
|
||||
skin.updateOptions(finishAnimation);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Indicates that the switch is selected.
|
||||
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
|
||||
/// Indicates a pending activation.
|
||||
private static final PseudoClass ARMED = PseudoClass.getPseudoClass("armed");
|
||||
|
||||
/// Factory for switch CSS metadata.
|
||||
private static final StyleablePropertyFactory<JFXSwitch> STYLEABLES =
|
||||
new StyleablePropertyFactory<>(Control.getClassCssMetaData());
|
||||
|
||||
/// CSS metadata for track height.
|
||||
private static final CssMetaData<JFXSwitch, Number> SIZE =
|
||||
STYLEABLES.createSizeCssMetaData("-jfx-size", control -> control.size, 32.0);
|
||||
|
||||
/// Switch and inherited CSS metadata.
|
||||
private static final @Unmodifiable List<CssMetaData<? extends Styleable, ?>> CSS_META_DATA =
|
||||
List.copyOf(STYLEABLES.getCssMetaData());
|
||||
|
||||
/// Returns immutable CSS metadata, including inherited control properties.
|
||||
public static @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return CSS_META_DATA;
|
||||
}
|
||||
|
||||
/// Returns the metadata used to style this switch.
|
||||
@Override
|
||||
public @Unmodifiable List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
/// Exposes selection to assistive technology.
|
||||
@Override
|
||||
public @Nullable Object queryAccessibleAttribute(AccessibleAttribute attribute, @Nullable Object... parameters) {
|
||||
return attribute == AccessibleAttribute.SELECTED ? isSelected() : super.queryAccessibleAttribute(attribute, parameters);
|
||||
}
|
||||
|
||||
/// Handles accessible activation.
|
||||
@Override
|
||||
public void executeAccessibleAction(AccessibleAction action, @Nullable Object... parameters) {
|
||||
if (action == AccessibleAction.FIRE)
|
||||
fire();
|
||||
else
|
||||
super.executeAccessibleAction(action, parameters);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXToggleButtonSkin;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JFXToggleButton is the material design implementation of a toggle button.
|
||||
* important CSS Selectors:
|
||||
* <p>
|
||||
* .jfx-toggle-button{
|
||||
* -fx-toggle-color: color-value;
|
||||
* -fx-untoggle-color: color-value;
|
||||
* -fx-toggle-line-color: color-value;
|
||||
* -fx-untoggle-line-color: color-value;
|
||||
* }
|
||||
* <p>
|
||||
* To change the rippler color when toggled:
|
||||
* <p>
|
||||
* .jfx-toggle-button .jfx-rippler{
|
||||
* -fx-rippler-fill: color-value;
|
||||
* }
|
||||
* <p>
|
||||
* .jfx-toggle-button:selected .jfx-rippler{
|
||||
* -fx-rippler-fill: color-value;
|
||||
* }
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXToggleButton extends ToggleButton {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXToggleButton() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXToggleButtonSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
// it's up for the user to add this behavior
|
||||
// toggleColor.addListener((o, oldVal, newVal) -> {
|
||||
// // update line color in case not set by the user
|
||||
// if(newVal instanceof Color)
|
||||
// toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter());
|
||||
// });
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* styleable Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-toggle-button'.
|
||||
* <p>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button";
|
||||
|
||||
/**
|
||||
* default color used when the button is toggled
|
||||
*/
|
||||
private final StyleableObjectProperty<Paint> toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR,
|
||||
JFXToggleButton.this,
|
||||
"toggleColor",
|
||||
Color.valueOf(
|
||||
"#009688"));
|
||||
|
||||
public Paint getToggleColor() {
|
||||
return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> toggleColorProperty() {
|
||||
return this.toggleColor;
|
||||
}
|
||||
|
||||
public void setToggleColor(Paint color) {
|
||||
this.toggleColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the button is not toggled
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR,
|
||||
JFXToggleButton.this,
|
||||
"unToggleColor",
|
||||
Color.valueOf(
|
||||
"#FAFAFA"));
|
||||
|
||||
public Paint getUnToggleColor() {
|
||||
return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unToggleColorProperty() {
|
||||
return this.untoggleColor;
|
||||
}
|
||||
|
||||
public void setUnToggleColor(Paint color) {
|
||||
this.untoggleColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default line color used when the button is toggled
|
||||
*/
|
||||
private final StyleableObjectProperty<Paint> toggleLineColor = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.TOGGLE_LINE_COLOR,
|
||||
JFXToggleButton.this,
|
||||
"toggleLineColor",
|
||||
Color.valueOf("#77C2BB"));
|
||||
|
||||
public Paint getToggleLineColor() {
|
||||
return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> toggleLineColorProperty() {
|
||||
return this.toggleLineColor;
|
||||
}
|
||||
|
||||
public void setToggleLineColor(Paint color) {
|
||||
this.toggleLineColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default line color used when the button is not toggled
|
||||
*/
|
||||
private final StyleableObjectProperty<Paint> untoggleLineColor = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.UNTOGGLE_LINE_COLOR,
|
||||
JFXToggleButton.this,
|
||||
"unToggleLineColor",
|
||||
Color.valueOf("#999999"));
|
||||
|
||||
public Paint getUnToggleLineColor() {
|
||||
return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unToggleLineColorProperty() {
|
||||
return this.untoggleLineColor;
|
||||
}
|
||||
|
||||
public void setUnToggleLineColor(Paint color) {
|
||||
this.untoggleLineColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default size of the toggle button.
|
||||
*/
|
||||
private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty(
|
||||
StyleableProperties.SIZE,
|
||||
JFXToggleButton.this,
|
||||
"size",
|
||||
10.0);
|
||||
|
||||
public double getSize() {
|
||||
return size.get();
|
||||
}
|
||||
|
||||
public StyleableDoubleProperty sizeProperty() {
|
||||
return this.size;
|
||||
}
|
||||
|
||||
public void setSize(double size) {
|
||||
this.size.set(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the visual indicator for focus
|
||||
*/
|
||||
private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS,
|
||||
JFXToggleButton.this,
|
||||
"disableVisualFocus",
|
||||
false);
|
||||
|
||||
public final StyleableBooleanProperty disableVisualFocusProperty() {
|
||||
return this.disableVisualFocus;
|
||||
}
|
||||
|
||||
public final Boolean isDisableVisualFocus() {
|
||||
return disableVisualFocus != null && this.disableVisualFocusProperty().get();
|
||||
}
|
||||
|
||||
public final void setDisableVisualFocus(final Boolean disabled) {
|
||||
this.disableVisualFocusProperty().set(disabled);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* disable animation on button action
|
||||
*/
|
||||
private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION,
|
||||
JFXToggleButton.this,
|
||||
"disableAnimation",
|
||||
!AnimationUtils.isAnimationEnabled());
|
||||
|
||||
public final StyleableBooleanProperty disableAnimationProperty() {
|
||||
return this.disableAnimation;
|
||||
}
|
||||
|
||||
public final Boolean isDisableAnimation() {
|
||||
return disableAnimation != null && this.disableAnimationProperty().get();
|
||||
}
|
||||
|
||||
public final void setDisableAnimation(final Boolean disabled) {
|
||||
this.disableAnimationProperty().set(disabled);
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_COLOR =
|
||||
new CssMetaData<>("-jfx-toggle-color",
|
||||
PaintConverter.getInstance(), Color.valueOf("#009688")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.toggleColor == null || !control.toggleColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
||||
return control.toggleColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_COLOR =
|
||||
new CssMetaData<>("-jfx-untoggle-color",
|
||||
PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.untoggleColor == null || !control.untoggleColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
||||
return control.unToggleColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXToggleButton, Paint> TOGGLE_LINE_COLOR =
|
||||
new CssMetaData<>("-jfx-toggle-line-color",
|
||||
PaintConverter.getInstance(), Color.valueOf("#77C2BB")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.toggleLineColor == null || !control.toggleLineColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
||||
return control.toggleLineColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXToggleButton, Paint> UNTOGGLE_LINE_COLOR =
|
||||
new CssMetaData<>("-jfx-untoggle-line-color",
|
||||
PaintConverter.getInstance(), Color.valueOf("#999999")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.untoggleLineColor == null || !control.untoggleLineColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXToggleButton control) {
|
||||
return control.unToggleLineColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXToggleButton, Number> SIZE =
|
||||
new CssMetaData<>("-jfx-size",
|
||||
StyleConverter.getSizeConverter(), 10.0) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return !control.size.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Number> getStyleableProperty(JFXToggleButton control) {
|
||||
return control.sizeProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_VISUAL_FOCUS =
|
||||
new CssMetaData<>("-jfx-disable-visual-focus",
|
||||
BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.disableVisualFocus == null || !control.disableVisualFocus.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
|
||||
return control.disableVisualFocusProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXToggleButton, Boolean> DISABLE_ANIMATION =
|
||||
new CssMetaData<>("-jfx-disable-animation",
|
||||
BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXToggleButton control) {
|
||||
return control.disableAnimation == null || !control.disableAnimation.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) {
|
||||
return control.disableAnimationProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(ToggleButton.getClassCssMetaData());
|
||||
Collections.addAll(styleables,
|
||||
SIZE,
|
||||
TOGGLE_COLOR,
|
||||
UNTOGGLE_COLOR,
|
||||
TOGGLE_LINE_COLOR,
|
||||
UNTOGGLE_LINE_COLOR,
|
||||
DISABLE_VISUAL_FOCUS,
|
||||
DISABLE_ANIMATION
|
||||
);
|
||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
treeItemRef = new WeakReference<>(newTreeItem);
|
||||
}
|
||||
};
|
||||
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
|
||||
treeItemProperty().addListener(weakTreeItemListener);
|
||||
treeItemProperty().addListener(treeItemInvalidationListener);
|
||||
if (getTreeItem() != null) {
|
||||
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||
}
|
||||
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
selectedPane.setVisible(isSelected());
|
||||
}
|
||||
|
||||
private void updateDisplay(T item, boolean empty) {
|
||||
/// Updates the text and graphic for the current value and tree item.
|
||||
///
|
||||
/// @implSpec Called when the cell value or the current tree item's graphic changes.
|
||||
/// Subclasses may override this method to decorate the default display.
|
||||
///
|
||||
/// @param item the cell value, or `null` to clear the display
|
||||
/// @param empty whether the cell has no item
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
if (item == null || empty) {
|
||||
hbox = null;
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
TreeItem<T> treeItem = getTreeItem();
|
||||
@Nullable TreeItem<T> treeItem = getTreeItem();
|
||||
if (treeItem != null && treeItem.getGraphic() != null) {
|
||||
if (item instanceof Node) {
|
||||
setText(null);
|
||||
|
||||
@@ -12,35 +12,25 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import com.jfoenix.transitions.JFXFillTransition;
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.skin.CheckBoxSkin;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.Border;
|
||||
import javafx.scene.layout.BorderStroke;
|
||||
import javafx.scene.layout.BorderStrokeStyle;
|
||||
import javafx.scene.layout.BorderWidths;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
private final StackPane mark = new StackPane();
|
||||
private final SVGPath markShape = new SVGPath();
|
||||
private final double lineThick = 2.0;
|
||||
private final double padding = 10.0;
|
||||
private final JFXRippler rippler;
|
||||
@@ -48,7 +38,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final double labelOffset = -8.0;
|
||||
private final Transition transition;
|
||||
private boolean invalid = true;
|
||||
private JFXFillTransition select;
|
||||
private boolean selected;
|
||||
private boolean indeterminate;
|
||||
|
||||
/// The checkbox fill animation, initialized with the configured checked color.
|
||||
private JFXFillTransition fillTransition;
|
||||
|
||||
public JFXCheckBoxSkin(JFXCheckBox control) {
|
||||
super(control);
|
||||
@@ -61,22 +55,17 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
boxContainer.getChildren().add(this.box);
|
||||
boxContainer.setPadding(new Insets(this.padding));
|
||||
this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK);
|
||||
this.updateRippleColor();
|
||||
SVGPath shape = new SVGPath();
|
||||
shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setShape(shape);
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
this.updateRippleColor(control.isSelected(), control.isIndeterminate());
|
||||
this.mark.setShape(this.markShape);
|
||||
this.updateMarkShape(true, false);
|
||||
this.mark.setVisible(false);
|
||||
this.mark.setScaleX(0.0);
|
||||
this.mark.setScaleY(0.0);
|
||||
boxContainer.getChildren().add(this.mark);
|
||||
this.container.getChildren().add(this.rippler);
|
||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||
control.selectedProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.updateRippleColor();
|
||||
this.playSelectAnimation(newVal);
|
||||
});
|
||||
this.registerChangeListener(control.selectedProperty(), ignored -> requestSelectionUpdate());
|
||||
this.registerChangeListener(control.indeterminateProperty(), ignored -> requestSelectionUpdate());
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null)
|
||||
@@ -93,37 +82,63 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||
this.updateChildren();
|
||||
this.registerChangeListener(control.checkedColorProperty(), ignored -> {
|
||||
if (select != null) {
|
||||
select.stop();
|
||||
if (fillTransition != null) {
|
||||
fillTransition.stop();
|
||||
}
|
||||
this.createFillTransition();
|
||||
updateColors();
|
||||
boolean selected = control.isSelected();
|
||||
boolean indeterminate = control.isIndeterminate();
|
||||
JFXFillTransition fillTransition = this.createFillTransition();
|
||||
this.fillTransition = fillTransition;
|
||||
fillTransition.jumpTo(selected || indeterminate ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
updateColors(selected, indeterminate);
|
||||
});
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors());
|
||||
this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors(control.isSelected(), control.isIndeterminate()));
|
||||
this.transition = new CheckBoxTransition();
|
||||
this.createFillTransition();
|
||||
this.fillTransition = this.createFillTransition();
|
||||
}
|
||||
|
||||
private void updateRippleColor() {
|
||||
var control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(control.isSelected()
|
||||
private void requestSelectionUpdate() {
|
||||
this.invalid = true;
|
||||
this.getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
private void updateRippleColor(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.rippler.setRipplerFill(selected || indeterminate
|
||||
? control.getCheckedColor()
|
||||
: control.getUnCheckedColor());
|
||||
}
|
||||
|
||||
private void updateColors() {
|
||||
private void updateColors(boolean selected, boolean indeterminate) {
|
||||
var control = (JFXCheckBox) getSkinnable();
|
||||
boolean isSelected = control.isSelected();
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
boolean marked = selected || indeterminate;
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, marked ? control.getCheckedColor() : Color.TRANSPARENT);
|
||||
rippler.setRipplerFill(marked ? control.getCheckedColor() : control.getUnCheckedColor());
|
||||
this.updateBorder(selected, indeterminate);
|
||||
}
|
||||
|
||||
private void updateBorder(boolean selected, boolean indeterminate) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
final BorderStroke borderStroke = box.getBorder().getStrokes().get(0);
|
||||
box.setBorder(new Border(new BorderStroke(
|
||||
isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
selected || indeterminate ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
borderStroke.getTopStyle(),
|
||||
borderStroke.getRadii(),
|
||||
borderStroke.getWidths())));
|
||||
}
|
||||
|
||||
private void updateMarkShape(boolean selected, boolean indeterminate) {
|
||||
if (indeterminate) {
|
||||
this.markShape.setContent("M1 0 H9 A1 1 0 0 1 10 1 A1 1 0 0 1 9 2 H1 A1 1 0 0 1 0 1 A1 1 0 0 1 1 0 Z");
|
||||
this.mark.setMaxSize(10.0, 2.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary;");
|
||||
} else if (selected) {
|
||||
this.markShape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z");
|
||||
this.mark.setMaxSize(15.0, 12.0);
|
||||
this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;");
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if (this.container != null) {
|
||||
@@ -151,9 +166,10 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x;
|
||||
double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x;
|
||||
if (this.invalid) {
|
||||
if (this.getSkinnable().isSelected()) {
|
||||
this.playSelectAnimation(true);
|
||||
}
|
||||
boolean selected = checkBox.isSelected();
|
||||
boolean indeterminate = checkBox.isIndeterminate();
|
||||
this.updateRippleColor(selected, indeterminate);
|
||||
this.playSelectAnimation(selected, indeterminate);
|
||||
|
||||
this.invalid = false;
|
||||
}
|
||||
@@ -180,27 +196,85 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
};
|
||||
}
|
||||
|
||||
private void playSelectAnimation(Boolean selection) {
|
||||
if (selection == null) {
|
||||
selection = false;
|
||||
private void playSelectAnimation(boolean selected, boolean indeterminate) {
|
||||
boolean previousSelected = this.selected;
|
||||
boolean previousIndeterminate = this.indeterminate;
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean previousMarked = previousSelected || previousIndeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
boolean sameVisualState = previousIndeterminate == indeterminate
|
||||
&& (indeterminate || previousSelected == selected);
|
||||
this.updateBorder(selected, indeterminate);
|
||||
|
||||
if (sameVisualState) {
|
||||
return;
|
||||
}
|
||||
this.invalid = false;
|
||||
|
||||
if (!AnimationUtils.isAnimationEnabled()) {
|
||||
finishSelectionAnimation(selected, indeterminate);
|
||||
return;
|
||||
}
|
||||
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
if (previousMarked && marked) {
|
||||
this.transition.stop();
|
||||
this.transition.setOnFinished(null);
|
||||
this.transition.jumpTo(this.transition.getCycleDuration());
|
||||
this.transition.setRate(-1.0);
|
||||
this.transition.setOnFinished(event -> {
|
||||
this.transition.setOnFinished(null);
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
this.transition.jumpTo(Duration.ZERO);
|
||||
this.transition.setRate(1.0);
|
||||
this.transition.play();
|
||||
});
|
||||
this.transition.play();
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setOnFinished(null);
|
||||
if (marked) {
|
||||
this.updateMarkShape(selected, indeterminate);
|
||||
}
|
||||
this.transition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.setRate(marked ? 1.0 : -1.0);
|
||||
this.fillTransition.play();
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
this.box.setBorder(new Border(new BorderStroke(
|
||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
BorderStrokeStyle.SOLID,
|
||||
new CornerRadii(2.0),
|
||||
new BorderWidths(this.lineThick))));
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
/// Stops selection animations and immediately displays the checkbox's current state.
|
||||
///
|
||||
/// The state indicator, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses pending selection updates and the initial selection animation.
|
||||
/// The skin must not have been disposed.
|
||||
public void finishSelectionAnimation() {
|
||||
CheckBox checkBox = this.getSkinnable();
|
||||
this.finishSelectionAnimation(checkBox.isSelected(), checkBox.isIndeterminate());
|
||||
}
|
||||
|
||||
private void finishSelectionAnimation(boolean selected, boolean indeterminate) {
|
||||
this.selected = selected;
|
||||
this.indeterminate = indeterminate;
|
||||
boolean marked = selected || indeterminate;
|
||||
transition.stop();
|
||||
transition.setOnFinished(null);
|
||||
transition.jumpTo(marked ? transition.getCycleDuration() : Duration.ZERO);
|
||||
fillTransition.stop();
|
||||
fillTransition.jumpTo(marked ? fillTransition.getCycleDuration() : Duration.ZERO);
|
||||
invalid = false;
|
||||
updateMarkShape(selected, indeterminate);
|
||||
mark.setVisible(marked);
|
||||
mark.setScaleX(marked ? 1.0 : 0.0);
|
||||
mark.setScaleY(marked ? 1.0 : 0.0);
|
||||
updateColors(selected, indeterminate);
|
||||
}
|
||||
|
||||
private JFXFillTransition createFillTransition() {
|
||||
JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT,
|
||||
(Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor());
|
||||
this.select.setInterpolator(Interpolator.EASE_OUT);
|
||||
fillTransition.setInterpolator(Interpolator.EASE_OUT);
|
||||
return fillTransition;
|
||||
}
|
||||
|
||||
private final class CheckBoxTransition extends CachedTransition {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
|
||||
import com.jfoenix.concurrency.JFXUtilities;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.EventTarget;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
protected final ObjectProperty<Paint> promptTextFill;
|
||||
private boolean invalid = true;
|
||||
private final StackPane customPane;
|
||||
private final StackPane line = new StackPane();
|
||||
private final StackPane focusedLine = new StackPane();
|
||||
private final Text promptText = new Text();
|
||||
private final double initScale = 0.05;
|
||||
private final Scale scale;
|
||||
private final Timeline linesAnimation;
|
||||
private ParallelTransition transition;
|
||||
private CachedTransition promptTextUpTransition;
|
||||
private CachedTransition promptTextDownTransition;
|
||||
private CachedTransition promptTextColorTransition;
|
||||
private final Scale promptTextScale;
|
||||
private Paint oldPromptTextFill;
|
||||
private final BooleanBinding usePromptText;
|
||||
|
||||
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
|
||||
super(comboBox);
|
||||
this.scale = new Scale(this.initScale, 1.0F);
|
||||
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
|
||||
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
|
||||
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
|
||||
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.promptText.textProperty().bind(comboBox.promptTextProperty());
|
||||
this.promptText.fillProperty().bind(this.promptTextFill);
|
||||
this.promptText.getStyleClass().addAll("text", "prompt-text");
|
||||
this.promptText.getTransforms().add(this.promptTextScale);
|
||||
if (!comboBox.isLabelFloat()) {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.customPane = new StackPane();
|
||||
this.customPane.setMouseTransparent(true);
|
||||
this.customPane.getStyleClass().add("combo-box-button-container");
|
||||
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
|
||||
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.customPane.getChildren().add(this.promptText);
|
||||
this.getChildren().add(0, this.customPane);
|
||||
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
|
||||
this.line.getStyleClass().add("input-line");
|
||||
this.focusedLine.getStyleClass().add("input-focused-line");
|
||||
this.getChildren().add(this.line);
|
||||
this.getChildren().add(this.focusedLine);
|
||||
this.line.setPrefHeight(1.0F);
|
||||
this.line.setTranslateY(1.0F);
|
||||
this.line.setManaged(false);
|
||||
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (this.getSkinnable().isDisabled()) {
|
||||
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
|
||||
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
this.focusedLine.setPrefHeight(2.0F);
|
||||
this.focusedLine.setTranslateY(0.0F);
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
this.focusedLine.getTransforms().add(this.scale);
|
||||
this.focusedLine.setManaged(false);
|
||||
if (comboBox.isEditable()) {
|
||||
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
|
||||
comboBox.getEditor().promptTextProperty().unbind();
|
||||
comboBox.getEditor().setPromptText(null);
|
||||
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.usePromptText.invalidate();
|
||||
comboBox.setValue(this.getConverter().fromString(newVal));
|
||||
});
|
||||
}
|
||||
|
||||
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.promptText.visibleProperty().unbind();
|
||||
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
|
||||
} else {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.createFocusTransition();
|
||||
});
|
||||
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
});
|
||||
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.unFocus();
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for (Node child : getChildren()) {
|
||||
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
|
||||
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
|
||||
// We implement hideOnClick ourselves to prevent the popup from
|
||||
// hiding when releasing the mouse on the scrollbar.
|
||||
|
||||
this.setHideOnClick(false);
|
||||
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
|
||||
EventTarget target = t.getTarget();
|
||||
if (target instanceof Node targetNode) {
|
||||
List<String> s = targetNode.getStyleClass();
|
||||
if (s.contains("thumb")
|
||||
|| s.contains("track")
|
||||
|| s.contains("decrement-arrow")
|
||||
|| s.contains("increment-arrow")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
Platform.runLater(() -> {
|
||||
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
|
||||
FXUtils.smoothScrolling(virtualFlow);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
this.customPane.resizeRelocate(x, y, w, h);
|
||||
if (this.invalid) {
|
||||
this.invalid = false;
|
||||
if (!this.getSkinnable().isEditable()) {
|
||||
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
|
||||
if (javaPromptText != null) {
|
||||
this.promptTextFill.set(javaPromptText.getFill());
|
||||
}
|
||||
}
|
||||
|
||||
this.createFloatingAnimation();
|
||||
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
|
||||
this.animateFloatingLabel(true);
|
||||
}
|
||||
}
|
||||
|
||||
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
|
||||
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
|
||||
this.scale.setPivotX(w / (double) 2.0F);
|
||||
}
|
||||
|
||||
private void createFloatingAnimation() {
|
||||
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition.setOnFinished((finish) -> {
|
||||
this.promptText.setTranslateY(0.0F);
|
||||
this.promptTextScale.setX(1.0F);
|
||||
this.promptTextScale.setY(1.0F);
|
||||
});
|
||||
}
|
||||
|
||||
private void focus() {
|
||||
if (this.transition == null) {
|
||||
this.createFocusTransition();
|
||||
}
|
||||
|
||||
this.transition.play();
|
||||
}
|
||||
|
||||
private void animateFloatingLabel(boolean up) {
|
||||
if (this.promptText == null) {
|
||||
Platform.runLater(() -> this.animateFloatingLabel(up));
|
||||
} else {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
this.transition.getChildren().remove(this.promptTextUpTransition);
|
||||
this.transition.getChildren().remove(this.promptTextColorTransition);
|
||||
this.transition = null;
|
||||
}
|
||||
|
||||
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
|
||||
this.promptTextDownTransition.stop();
|
||||
this.promptTextUpTransition.play();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextColorTransition.play();
|
||||
}
|
||||
} else if (!up) {
|
||||
this.promptTextUpTransition.stop();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
}
|
||||
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void createFocusTransition() {
|
||||
this.transition = new ParallelTransition();
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.transition.getChildren().add(this.promptTextUpTransition);
|
||||
this.transition.getChildren().add(this.promptTextColorTransition);
|
||||
}
|
||||
|
||||
this.transition.getChildren().add(this.linesAnimation);
|
||||
}
|
||||
|
||||
private void unFocus() {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
}
|
||||
|
||||
this.scale.setX(this.initScale);
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
if (this.usePromptText()) {
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean usePromptText() {
|
||||
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
|
||||
String promptTxt = this.getSkinnable().getPromptText();
|
||||
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.svg.SVGGlyph;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.animation.Animation.Status;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.SnapshotParameters;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.SVGContainer;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Year;
|
||||
import java.time.YearMonth;
|
||||
import java.time.chrono.ChronoLocalDate;
|
||||
import java.time.chrono.Chronology;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DecimalStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class JFXDatePickerContent extends VBox {
|
||||
private static final String SPINNER_LABEL = "spinner-label";
|
||||
private static final String ROBOTO = "Roboto";
|
||||
protected JFXDatePicker datePicker;
|
||||
private JFXButton backMonthButton;
|
||||
private JFXButton forwardMonthButton;
|
||||
private Label selectedDateLabel;
|
||||
private Label selectedYearLabel;
|
||||
private Label monthYearLabel;
|
||||
protected GridPane contentGrid;
|
||||
private final StackPane calendarPlaceHolder = new StackPane();
|
||||
private final CachedTransition showTransition;
|
||||
private final CachedTransition hideTransition;
|
||||
private ParallelTransition tempImageTransition;
|
||||
private final int daysPerWeek = 7;
|
||||
private final List<DateCell> weekDaysCells = new ArrayList<>();
|
||||
private final List<DateCell> weekNumberCells = new ArrayList<>();
|
||||
protected List<DateCell> dayCells = new ArrayList<>();
|
||||
private LocalDate[] dayCellDates;
|
||||
private DateCell currentFocusedDayCell = null;
|
||||
private final ListView<String> yearsListView = new JFXListView<>() {
|
||||
{
|
||||
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
|
||||
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
this.setCellFactory((listView) -> new ListCell<>() {
|
||||
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
|
||||
final Label label = new Label();
|
||||
final StackPane root;
|
||||
final RipplerContainer ripplerContainer;
|
||||
|
||||
final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
{
|
||||
label.getStyleClass().add(SPINNER_LABEL);
|
||||
label.setMaxWidth(Double.MAX_VALUE);
|
||||
label.textProperty().bind(itemProperty());
|
||||
StackPane pane = new StackPane(label);
|
||||
pane.setPadding(new Insets(7));
|
||||
ripplerContainer = new RipplerContainer(pane);
|
||||
root = new StackPane(ripplerContainer);
|
||||
root.getStyleClass().add("data-picker-list-cell");
|
||||
this.setGraphic(root);
|
||||
|
||||
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
|
||||
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
|
||||
if (newVal != null && getItem() != null && getItem().equals(newVal))
|
||||
updateSelected(true);
|
||||
});
|
||||
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
|
||||
itemProperty().addListener(selectedListenerWeak);
|
||||
|
||||
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
|
||||
if (getListView() != null) {
|
||||
if (selected) {
|
||||
getListView().getSelectionModel().select(getIndex());
|
||||
} else {
|
||||
getListView().getSelectionModel().clearSelection(getIndex());
|
||||
}
|
||||
}
|
||||
root.pseudoClassStateChanged(SELECTED, selected);
|
||||
if (selected) {
|
||||
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
|
||||
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updateItem(String item, boolean empty) {
|
||||
String oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
setText(null);
|
||||
|
||||
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
|
||||
ripplerContainer.resetFeedback();
|
||||
|
||||
if (!empty && item != null) {
|
||||
setGraphic(root);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
|
||||
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
|
||||
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
|
||||
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
|
||||
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
|
||||
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
|
||||
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
|
||||
|
||||
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
|
||||
|
||||
JFXDatePickerContent(final DatePicker datePicker) {
|
||||
this.datePicker = (JFXDatePicker) datePicker;
|
||||
this.getStyleClass().add("date-picker-popup");
|
||||
LocalDate date = datePicker.getValue();
|
||||
this.valueProperty.set(date);
|
||||
this.valueProperty.addListener((o, old, n) -> {
|
||||
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
|
||||
this.updateValues();
|
||||
});
|
||||
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
|
||||
this.getChildren().add(this.createHeaderPane());
|
||||
this.getChildren().add(new Separator());
|
||||
this.contentGrid = new GridPane() {
|
||||
protected double computePrefWidth(double height) {
|
||||
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
|
||||
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
|
||||
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
|
||||
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
|
||||
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
|
||||
}
|
||||
|
||||
protected void layoutChildren() {
|
||||
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
|
||||
super.layoutChildren();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
this.contentGrid.setFocusTraversable(true);
|
||||
this.contentGrid.getStyleClass().add("calendar-grid");
|
||||
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
|
||||
this.contentGrid.setVgap(4);
|
||||
this.contentGrid.setHgap(4);
|
||||
this.createWeekDaysCells();
|
||||
this.createDayCells();
|
||||
|
||||
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
|
||||
|
||||
StackPane main = new StackPane();
|
||||
this.yearsListView.setVisible(false);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
|
||||
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
|
||||
main.getChildren().setAll(this.contentGrid, this.yearsListView);
|
||||
|
||||
VBox contentHolder = new VBox();
|
||||
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
|
||||
this.calendarPlaceHolder.getStyleClass().add("content");
|
||||
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
|
||||
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.createActions());
|
||||
|
||||
this.refresh();
|
||||
this.addEventHandler(KeyEvent.ANY, (event) -> {
|
||||
Node node = this.getScene().getFocusOwner();
|
||||
if (node instanceof DateCell) {
|
||||
this.currentFocusedDayCell = (DateCell) node;
|
||||
}
|
||||
|
||||
switch (event.getCode()) {
|
||||
case HOME -> {
|
||||
this.init();
|
||||
this.goToDate(LocalDate.now(), true);
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_UP -> {
|
||||
if (!this.backMonthButton.isDisabled()) {
|
||||
this.forward(-1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_DOWN -> {
|
||||
if (!this.forwardMonthButton.isDisabled()) {
|
||||
this.forward(1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case ESCAPE -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
event.consume();
|
||||
}
|
||||
default -> event.consume();
|
||||
}
|
||||
|
||||
});
|
||||
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(true);
|
||||
}
|
||||
};
|
||||
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void stopping() {
|
||||
super.stopping();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ObjectProperty<YearMonth> displayedYearMonthProperty() {
|
||||
return this.selectedYearMonth;
|
||||
}
|
||||
|
||||
private void createWeekDaysCells() {
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("day-name-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
cell.setAlignment(Pos.BASELINE_CENTER);
|
||||
this.weekDaysCells.add(cell);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("week-number-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.weekNumberCells.add(cell);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected VBox createHeaderPane() {
|
||||
Label title = new Label(I18n.i18n("button.select_date"));
|
||||
title.getStyleClass().add("title");
|
||||
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
|
||||
HBox titleContainer = new HBox(title);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
titleContainer.setPadding(new Insets(0, 0, 5, 0));
|
||||
this.selectedYearLabel = new Label();
|
||||
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
|
||||
HBox yearLabelContainer = new HBox();
|
||||
yearLabelContainer.getStyleClass().add("spinner");
|
||||
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
|
||||
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
yearLabelContainer.setFillHeight(false);
|
||||
yearLabelContainer.setOnMouseClicked((click) -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
this.selectedDateLabel = new Label();
|
||||
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
|
||||
HBox selectedDateContainer = new HBox();
|
||||
selectedDateContainer.getStyleClass().add("spinner");
|
||||
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
|
||||
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
selectedDateContainer.setOnMouseClicked((click) -> {
|
||||
if (this.yearsListView.isVisible()) {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
VBox headerPanel = new VBox();
|
||||
headerPanel.getStyleClass().add("month-year-pane");
|
||||
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
|
||||
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
|
||||
return headerPanel;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarArrowsPane() {
|
||||
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
|
||||
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
|
||||
leftChevron.setSize(6.0F, 11.0F);
|
||||
rightChevron.setSize(6.0F, 11.0F);
|
||||
this.backMonthButton = new JFXButton();
|
||||
this.backMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.backMonthButton.getStyleClass().add("left-button");
|
||||
this.backMonthButton.setGraphic(leftChevron);
|
||||
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
|
||||
this.forwardMonthButton = new JFXButton();
|
||||
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.forwardMonthButton.getStyleClass().add("right-button");
|
||||
this.forwardMonthButton.setGraphic(rightChevron);
|
||||
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
|
||||
BorderPane arrowsContainer = new BorderPane();
|
||||
arrowsContainer.setLeft(this.backMonthButton);
|
||||
arrowsContainer.setRight(this.forwardMonthButton);
|
||||
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
|
||||
arrowsContainer.setPickOnBounds(false);
|
||||
return arrowsContainer;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarMonthLabelPane() {
|
||||
this.monthYearLabel = new Label();
|
||||
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
|
||||
SVGContainer svgContainer = new SVGContainer(13);
|
||||
HBox box = new HBox(monthYearLabel, svgContainer);
|
||||
box.setSpacing(3);
|
||||
box.setAlignment(Pos.CENTER);
|
||||
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
|
||||
FXUtils.onClicked(box, () -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
} else {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
});
|
||||
BorderPane monthContainer = new BorderPane();
|
||||
monthContainer.setMinHeight(50.0F);
|
||||
monthContainer.setCenter(box);
|
||||
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
|
||||
return monthContainer;
|
||||
}
|
||||
|
||||
protected HBox createActions() {
|
||||
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
|
||||
acceptButton.getStyleClass().add("dialog-accept");
|
||||
FXUtils.onClicked(acceptButton, () -> {
|
||||
datePicker.setValue(valueProperty.get());
|
||||
fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
|
||||
HBox hBox = new HBox(cancelButton, acceptButton);
|
||||
hBox.setPadding(new Insets(0, 5, 3, 0));
|
||||
hBox.setSpacing(5);
|
||||
hBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
return hBox;
|
||||
}
|
||||
|
||||
void updateContentGrid() {
|
||||
this.contentGrid.getColumnConstraints().clear();
|
||||
this.contentGrid.getChildren().clear();
|
||||
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
ColumnConstraints columnConstraints = new ColumnConstraints();
|
||||
columnConstraints.setPercentWidth(100.0F);
|
||||
|
||||
for (int i = 0; i < colsNumber; ++i) {
|
||||
this.contentGrid.getColumnConstraints().add(columnConstraints);
|
||||
}
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
|
||||
}
|
||||
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
|
||||
}
|
||||
}
|
||||
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
this.updateDayNameCells();
|
||||
this.updateValues();
|
||||
}
|
||||
|
||||
private void updateDayNameCells() {
|
||||
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
|
||||
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
|
||||
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
this.weekDaysCells.get(i).setText(name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void updateValues() {
|
||||
this.updateWeekNumberDateCells();
|
||||
this.updateDayCells();
|
||||
this.updateMonthYearPane();
|
||||
}
|
||||
|
||||
void updateWeekNumberDateCells() {
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
Locale locale = this.getLocale();
|
||||
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
LocalDate date = firstDayOfMonth.plusWeeks(i);
|
||||
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
|
||||
this.weekNumberCells.get(i).setText(weekNumber);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void updateDayCells() {
|
||||
Locale locale = this.getLocale();
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
|
||||
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
|
||||
YearMonth currentYearMonth = this.selectedYearMonth.get();
|
||||
int daysInCurMonth = -1;
|
||||
|
||||
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
|
||||
DateCell dayCell = this.dayCells.get(i);
|
||||
try {
|
||||
if (daysInCurMonth == -1) {
|
||||
daysInCurMonth = currentYearMonth.lengthOfMonth();
|
||||
}
|
||||
|
||||
int dayIndex = i - firstOfMonthIndex + 1;
|
||||
LocalDate date;
|
||||
if (dayIndex <= 0) {
|
||||
YearMonth previous = currentYearMonth.minusMonths(1);
|
||||
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
|
||||
} else if (dayIndex > daysInCurMonth) {
|
||||
YearMonth next = currentYearMonth.plusMonths(1);
|
||||
date = next.atDay(dayIndex - daysInCurMonth);
|
||||
} else {
|
||||
date = currentYearMonth.atDay(dayIndex);
|
||||
}
|
||||
this.dayCellDates[i] = date;
|
||||
if (date.equals(LocalDate.now())) {
|
||||
dayCell.getStyleClass().add("today");
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("today");
|
||||
}
|
||||
|
||||
if (date.equals(valueProperty.get())) {
|
||||
dayCell.getStyleClass().add("selected");
|
||||
this.currentFocusedDayCell = dayCell;
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("selected");
|
||||
}
|
||||
|
||||
ChronoLocalDate cDate = chrono.date(date);
|
||||
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
|
||||
dayCell.setText(cellText);
|
||||
if (i < firstOfMonthIndex) {
|
||||
dayCell.getStyleClass().add("previous-month");
|
||||
dayCell.getStyleClass().removeAll("next-month");
|
||||
dayCell.setDisable(true);
|
||||
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
|
||||
dayCell.getStyleClass().add("next-month");
|
||||
dayCell.getStyleClass().removeAll("previous-month");
|
||||
dayCell.setDisable(true);
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("previous-month", "next-month");
|
||||
dayCell.setDisable(false);
|
||||
}
|
||||
|
||||
dayCell.updateItem(date, false);
|
||||
} catch (DateTimeException var13) {
|
||||
dayCell.setText("");
|
||||
dayCell.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void updateMonthYearPane() {
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
|
||||
|
||||
this.selectedYearLabel.setText(this.formatYear(yearMonth));
|
||||
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
|
||||
Chronology chrono = this.datePicker.getChronology();
|
||||
LocalDate firstDayOfMonth = yearMonth.atDay(1);
|
||||
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
|
||||
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
|
||||
}
|
||||
|
||||
private String formatMonth(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String formatYear(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected LocalDate dayCellDate(DateCell dateCell) {
|
||||
assert this.dayCellDates != null;
|
||||
|
||||
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
|
||||
}
|
||||
|
||||
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
|
||||
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
|
||||
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
|
||||
this.getParent().setManaged(false);
|
||||
SnapshotParameters snapShotparams = new SnapshotParameters();
|
||||
snapShotparams.setFill(Color.TRANSPARENT);
|
||||
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
|
||||
ImageView tempImage = new ImageView(temp);
|
||||
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
|
||||
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
|
||||
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
|
||||
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
|
||||
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
|
||||
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
|
||||
contentTransition.setToX(0.0F);
|
||||
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
|
||||
this.tempImageTransition.setOnFinished((finish) -> {
|
||||
this.calendarPlaceHolder.getChildren().remove(tempImage);
|
||||
this.getParent().setManaged(true);
|
||||
});
|
||||
this.tempImageTransition.play();
|
||||
}
|
||||
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
DateCell dateCell = this.currentFocusedDayCell;
|
||||
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
|
||||
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
|
||||
}
|
||||
|
||||
this.goToDayCell(dateCell, offset, unit, focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
|
||||
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDate(LocalDate date, boolean focusDayCell) {
|
||||
if (this.isValidDate(this.datePicker.getChronology(), date)) {
|
||||
this.valueProperty.set(date);
|
||||
this.selectedYearMonth.set(YearMonth.from(date));
|
||||
if (focusDayCell) {
|
||||
this.findDayCellOfDate(date).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void selectDayCell(DateCell dateCell) {
|
||||
this.valueProperty.set(this.dayCellDate(dateCell));
|
||||
}
|
||||
|
||||
private DateCell findDayCellOfDate(LocalDate date) {
|
||||
for (int i = 0; i < this.dayCellDates.length; ++i) {
|
||||
if (date.equals(this.dayCellDates[i])) {
|
||||
return this.dayCells.get(i);
|
||||
}
|
||||
}
|
||||
|
||||
return this.dayCells.get(this.dayCells.size() / 2 + 1);
|
||||
}
|
||||
|
||||
void init() {
|
||||
this.contentGrid.setOpacity(1.0F);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.setVisible(false);
|
||||
}
|
||||
|
||||
void clearFocus() {
|
||||
LocalDate focusDate = this.valueProperty.get();
|
||||
if (focusDate == null) {
|
||||
focusDate = LocalDate.now();
|
||||
}
|
||||
|
||||
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
|
||||
this.goToDate(focusDate, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void createDayCells() {
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
DateCell dayCell = this.createDayCell();
|
||||
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
|
||||
if (click.getButton() == MouseButton.PRIMARY) {
|
||||
DateCell selectedDayCell = (DateCell) click.getSource();
|
||||
this.selectDayCell(selectedDayCell);
|
||||
}
|
||||
});
|
||||
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
|
||||
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
|
||||
dayCell.setAlignment(Pos.BASELINE_CENTER);
|
||||
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
|
||||
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.dayCells.add(dayCell);
|
||||
}
|
||||
}
|
||||
|
||||
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
|
||||
this.updateContentGrid();
|
||||
}
|
||||
|
||||
private DateCell createDayCell() {
|
||||
DateCell dayCell = null;
|
||||
if (this.datePicker.getDayCellFactory() != null) {
|
||||
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
|
||||
}
|
||||
|
||||
if (dayCell == null) {
|
||||
dayCell = new DateCell();
|
||||
}
|
||||
dayCell.getStyleClass().add("day-cell");
|
||||
dayCell.setPrefSize(36.0F, 36.0F);
|
||||
dayCell.setDisable(false);
|
||||
dayCell.setStyle(null);
|
||||
dayCell.setGraphic(null);
|
||||
dayCell.setTooltip(null);
|
||||
|
||||
return dayCell;
|
||||
}
|
||||
|
||||
protected Chronology getPrimaryChronology() {
|
||||
return this.datePicker.getChronology();
|
||||
}
|
||||
|
||||
protected Locale getLocale() {
|
||||
return SettingsManager.settings().languageProperty().get().getLocale();
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
|
||||
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date) {
|
||||
try {
|
||||
if (date != null) {
|
||||
chrono.date(date);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (DateTimeException var4) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.ReflectionHelper;
|
||||
import com.jfoenix.adapters.skins.DatePickerSkin;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXDialog;
|
||||
import com.jfoenix.controls.JFXDialog.DialogTransition;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Stack;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class JFXDatePickerSkin extends DatePickerSkin {
|
||||
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
|
||||
|
||||
private final JFXDatePicker jfxDatePicker;
|
||||
private TextField displayNode;
|
||||
private JFXDatePickerContent content;
|
||||
private JFXDialog dialog;
|
||||
|
||||
public JFXDatePickerSkin(JFXDatePicker datePicker) {
|
||||
super(datePicker);
|
||||
this.jfxDatePicker = datePicker;
|
||||
|
||||
try {
|
||||
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
|
||||
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
|
||||
|
||||
int i = changeListeners.length - 1;
|
||||
if (i >= 0) {
|
||||
while (i >= 0 && changeListeners[i] == null) {
|
||||
--i;
|
||||
}
|
||||
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
|
||||
}
|
||||
} catch (NullPointerException e) {
|
||||
LOG.warning("Cannot remove focusedProperty listener", e);
|
||||
}
|
||||
|
||||
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
|
||||
if (this.getEditor() != null && !newVal) {
|
||||
this.setTextFromTextFieldIntoComboBoxValue2();
|
||||
}
|
||||
|
||||
});
|
||||
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
|
||||
arrowBox.setAlignment(Pos.CENTER);
|
||||
this.setArrow(arrowBox);
|
||||
this.getArrowButton().getChildren().setAll(arrowBox);
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
|
||||
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
|
||||
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.content = null;
|
||||
this.setPopup2(null);
|
||||
});
|
||||
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
|
||||
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
|
||||
if (this.jfxDatePicker.isShowing()) {
|
||||
if (this.content != null) {
|
||||
LocalDate date = this.jfxDatePicker.getValue();
|
||||
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.content.updateValues();
|
||||
}
|
||||
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
|
||||
if (this.content != null) {
|
||||
this.content.updateContentGrid();
|
||||
this.content.updateWeekNumberDateCells();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.jfxDatePicker.fireEvent(new ActionEvent());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public JFXDatePickerContent getPopupContent() {
|
||||
if (this.content == null) {
|
||||
this.content = new JFXDatePickerContent(this.jfxDatePicker);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
super.show();
|
||||
}
|
||||
|
||||
if (this.content != null) {
|
||||
this.content.init();
|
||||
this.content.clearFocus();
|
||||
}
|
||||
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
|
||||
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
|
||||
if (dialogParent == null) {
|
||||
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
|
||||
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
|
||||
@Override
|
||||
public void close() {
|
||||
super.close();
|
||||
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
|
||||
if (o instanceof Stack<?> stack) {
|
||||
stack.pop();
|
||||
}
|
||||
jfxDatePicker.hide();
|
||||
}
|
||||
};
|
||||
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
|
||||
getArrowButton().setOnMouseClicked((click) -> {
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
StackPane parent = this.jfxDatePicker.getDialogParent();
|
||||
if (parent == null) {
|
||||
parent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
|
||||
contentStack.push(this.getPopupContent());
|
||||
|
||||
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
|
||||
this.dialog.show(parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
JFXDialog getDialog() {
|
||||
return this.dialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getDisplayNode() {
|
||||
if (this.displayNode == null) {
|
||||
this.displayNode = this.getEditableInputNode2();
|
||||
this.displayNode.getStyleClass().add("date-picker-display-node");
|
||||
this.updateDisplayNode2();
|
||||
}
|
||||
|
||||
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
|
||||
return this.displayNode;
|
||||
}
|
||||
|
||||
public void syncWithAutoUpdate() {
|
||||
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
|
||||
this.jfxDatePicker.hide();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
arc = new Arc();
|
||||
arc.setManaged(false);
|
||||
arc.setLength(180);
|
||||
arc.setLength(-180);
|
||||
arc.getStyleClass().setAll("arc");
|
||||
arc.setFill(Color.TRANSPARENT);
|
||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
@@ -192,24 +192,24 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 45 + startingAngle,
|
||||
-(angle + 45 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + startingAngle,
|
||||
-(angle + 90 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + startingAngle,
|
||||
-(angle + 135 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + startingAngle,
|
||||
-(angle + 435 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
}
|
||||
|
||||
@@ -222,21 +222,21 @@ public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
addKeyFrames(keyFrames, 900, 2.8);
|
||||
addKeyFrames(keyFrames, 1350, 4.2);
|
||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), -5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + startingAngle,
|
||||
-(1845 + startingAngle),
|
||||
Interpolator.LINEAR)));
|
||||
|
||||
timeline = new Timeline();
|
||||
timeline.getKeyFrames().setAll(keyFrames);
|
||||
} else {
|
||||
final double arcLength = 250;
|
||||
final double arcLength = -250;
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||
new KeyFrame(Duration.seconds(1.2),
|
||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(), -(45 + 360 + startingAngle), Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXSwitch;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.css.StyleOrigin;
|
||||
import javafx.css.StyleableProperty;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/// Renders a Material Design 3 switch and handles input.
|
||||
/// Geometry uses unscaled MD3 units and is mirrored in RTL layouts.
|
||||
@NotNullByDefault
|
||||
public final class JFXSwitchSkin extends SkinBase<JFXSwitch> {
|
||||
/// Track width in unscaled MD3 artwork coordinates.
|
||||
private static final double TRACK_WIDTH = 52;
|
||||
|
||||
/// Track height corresponding to the control's default `-jfx-size`.
|
||||
private static final double TRACK_HEIGHT = 32;
|
||||
|
||||
/// Unscaled interaction target height.
|
||||
private static final double TARGET_HEIGHT = 48;
|
||||
|
||||
/// Vertical center shared by the track, handle, state layer, and icon.
|
||||
private static final double CENTER_Y = TARGET_HEIGHT / 2;
|
||||
|
||||
/// Handle center at the leading end of the track.
|
||||
private static final double OFF_CENTER = TRACK_HEIGHT / 2;
|
||||
|
||||
/// Handle center at the trailing end of the track.
|
||||
private static final double ON_CENTER = TRACK_WIDTH - OFF_CENTER;
|
||||
|
||||
/// Diameter of the unselected handle without an icon.
|
||||
private static final double OFF_DIAMETER = 16;
|
||||
|
||||
/// Diameter of a selected handle or a handle containing an icon.
|
||||
private static final double ON_DIAMETER = 24;
|
||||
|
||||
/// Handle diameter while pressed or dragged.
|
||||
private static final double PRESSED_DIAMETER = 28;
|
||||
|
||||
/// Radius of the hover, focus, and pressed overlay.
|
||||
private static final double STATE_LAYER_RADIUS = 20;
|
||||
|
||||
/// Minimum pointer movement in control pixels before a press becomes a drag.
|
||||
private static final double DRAG_THRESHOLD = 3;
|
||||
|
||||
/// Indicates visible keyboard focus.
|
||||
private static final PseudoClass VISUAL_FOCUSED = PseudoClass.getPseudoClass("visual-focused");
|
||||
|
||||
/// The rendered switch.
|
||||
private final JFXSwitch control;
|
||||
|
||||
/// Unmanaged switch artwork.
|
||||
private final Pane artwork = new Pane();
|
||||
|
||||
/// Scales artwork from its leading top corner.
|
||||
private final Scale artworkScale = new Scale();
|
||||
|
||||
/// Rounded track with its optional outline.
|
||||
private final Rectangle track = new Rectangle(0, (TARGET_HEIGHT - TRACK_HEIGHT) / 2, TRACK_WIDTH, TRACK_HEIGHT);
|
||||
|
||||
/// Interaction feedback behind the handle.
|
||||
private final Circle stateLayer = new Circle(OFF_CENTER, CENTER_Y, STATE_LAYER_RADIUS);
|
||||
|
||||
/// Switch handle.
|
||||
private final Circle handle = new Circle(OFF_CENTER, CENTER_Y, OFF_DIAMETER / 2);
|
||||
|
||||
/// Optional handle icon.
|
||||
private @Nullable SVGPath icon;
|
||||
|
||||
/// Current logical handle center measured from the track's leading edge.
|
||||
private final DoubleProperty center = new SimpleDoubleProperty(OFF_CENTER) {
|
||||
/// Updates handle position.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
layoutHandle();
|
||||
}
|
||||
};
|
||||
|
||||
/// Unscaled handle diameter.
|
||||
private final DoubleProperty diameter = new SimpleDoubleProperty(OFF_DIAMETER) {
|
||||
/// Updates handle size.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
layoutHandle();
|
||||
}
|
||||
};
|
||||
|
||||
/// Optional movement and resize animation.
|
||||
private @Nullable Timeline geometryTransition;
|
||||
|
||||
/// Animated CSS paints.
|
||||
private final List<CssColor> colors = new ArrayList<>();
|
||||
|
||||
/// Optional shared color animation.
|
||||
private @Nullable Transition colorTransition;
|
||||
|
||||
/// Whether a selection color update is pending.
|
||||
private boolean animateColors;
|
||||
|
||||
/// Prevents animation writes from being interpreted as new CSS targets.
|
||||
private boolean applyingColors;
|
||||
|
||||
/// Pointer event handler.
|
||||
private final EventHandler<MouseEvent> mouseHandler = this::handleMouse;
|
||||
|
||||
/// Keyboard event handler.
|
||||
private final EventHandler<KeyEvent> keyHandler = this::handleKey;
|
||||
|
||||
/// Whether a primary press is awaiting click or drag completion.
|
||||
private boolean mouseDown;
|
||||
|
||||
/// Whether the handle is following the pointer instead of an animation.
|
||||
private boolean dragging;
|
||||
|
||||
/// Whether a Space press is awaiting release.
|
||||
private boolean keyDown;
|
||||
|
||||
/// Pointer position in unscaled artwork coordinates at press time.
|
||||
private double pressX;
|
||||
|
||||
/// Handle center at press time, preserving the pointer's grab offset.
|
||||
private double pressCenter;
|
||||
|
||||
/// Creates a skin for the switch.
|
||||
public JFXSwitchSkin(JFXSwitch control) {
|
||||
super(control);
|
||||
this.control = control;
|
||||
artwork.getStyleClass().setAll("switch-artwork");
|
||||
track.getStyleClass().setAll("switch-track");
|
||||
stateLayer.getStyleClass().setAll("switch-state-layer");
|
||||
handle.getStyleClass().setAll("switch-handle");
|
||||
track.setArcWidth(TRACK_HEIGHT);
|
||||
track.setArcHeight(TRACK_HEIGHT);
|
||||
artwork.setManaged(false);
|
||||
artwork.setMouseTransparent(true);
|
||||
artwork.getTransforms().add(artworkScale);
|
||||
artwork.getChildren().setAll(track, stateLayer, handle);
|
||||
for (var node : artwork.getChildren())
|
||||
node.setManaged(false);
|
||||
getChildren().add(artwork);
|
||||
registerChangeListener(control.selectedProperty(), value -> updateSelection());
|
||||
registerChangeListener(control.armedProperty(), value -> update(false));
|
||||
@Nullable ReadOnlyBooleanProperty focusVisible = FXUtils.focusVisibleProperty(control);
|
||||
ReadOnlyBooleanProperty focusIndicator = focusVisible != null ? focusVisible : control.focusedProperty();
|
||||
registerChangeListener(focusIndicator, value ->
|
||||
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed()));
|
||||
// Match the previous toggle skin: pointer press and release both dismiss focus feedback.
|
||||
registerChangeListener(control.pressedProperty(), value ->
|
||||
control.pseudoClassStateChanged(VISUAL_FOCUSED, false));
|
||||
Consumer<ObservableValue<?>> cancelGesture = value -> cancelGesture();
|
||||
registerChangeListener(control.disabledProperty(), cancelGesture);
|
||||
registerChangeListener(control.sceneProperty(), cancelGesture);
|
||||
registerChangeListener(control.focusedProperty(), value -> {
|
||||
if (!control.isFocused())
|
||||
cancelGesture();
|
||||
});
|
||||
control.addEventFilter(MouseEvent.ANY, mouseHandler);
|
||||
control.addEventFilter(KeyEvent.ANY, keyHandler);
|
||||
control.pseudoClassStateChanged(VISUAL_FOCUSED, focusIndicator.get() && !control.isPressed());
|
||||
update(true);
|
||||
}
|
||||
|
||||
/// Updates icon and animation options, optionally settling active animations.
|
||||
public void updateOptions(boolean finishAnimation) {
|
||||
if (finishAnimation)
|
||||
finishColors();
|
||||
update(finishAnimation);
|
||||
}
|
||||
|
||||
/// Stops animations and removes artwork, event handlers, and listeners.
|
||||
@Override
|
||||
public void dispose() {
|
||||
control.removeEventFilter(MouseEvent.ANY, mouseHandler);
|
||||
control.removeEventFilter(KeyEvent.ANY, keyHandler);
|
||||
cancelGesture();
|
||||
for (CssColor color : colors)
|
||||
color.dispose();
|
||||
control.pseudoClassStateChanged(VISUAL_FOCUSED, false);
|
||||
getChildren().remove(artwork);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Centers the scaled artwork within the control and starts pending color transitions.
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
double scale = sizeScale();
|
||||
artworkScale.setX(scale);
|
||||
artworkScale.setY(scale);
|
||||
// Keep animation and CSS geometry in MD3 units; scale the entire artwork consistently.
|
||||
artwork.resizeRelocate(x + (w - TRACK_WIDTH * scale) / 2,
|
||||
y + (h - TARGET_HEIGHT * scale) / 2, TRACK_WIDTH, TARGET_HEIGHT);
|
||||
layoutHandle();
|
||||
// JavaFX applies CSS before layout, so all changed paints now share the same start time.
|
||||
if (animateColors) {
|
||||
animateColors = false;
|
||||
for (CssColor color : colors) {
|
||||
if (color.target != null) {
|
||||
colorTransition().playFromStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserves the scaled track width and control insets.
|
||||
@Override
|
||||
protected double computeMinWidth(double h, double top, double right, double bottom, double left) {
|
||||
return left + TRACK_WIDTH * sizeScale() + right;
|
||||
}
|
||||
|
||||
/// Reserves the scaled interaction target height and control insets.
|
||||
@Override
|
||||
protected double computeMinHeight(double w, double top, double right, double bottom, double left) {
|
||||
return top + TARGET_HEIGHT * sizeScale() + bottom;
|
||||
}
|
||||
|
||||
/// Uses the same fixed width as the minimum size.
|
||||
@Override
|
||||
protected double computePrefWidth(double h, double top, double right, double bottom, double left) {
|
||||
return computeMinWidth(h, top, right, bottom, left);
|
||||
}
|
||||
|
||||
/// Uses the same fixed height as the minimum size.
|
||||
@Override
|
||||
protected double computePrefHeight(double w, double top, double right, double bottom, double left) {
|
||||
return computeMinHeight(w, top, right, bottom, left);
|
||||
}
|
||||
|
||||
/// Limits width to the preferred size.
|
||||
@Override
|
||||
protected double computeMaxWidth(double h, double top, double right, double bottom, double left) {
|
||||
return computePrefWidth(h, top, right, bottom, left);
|
||||
}
|
||||
|
||||
/// Limits height to the preferred size.
|
||||
@Override
|
||||
protected double computeMaxHeight(double w, double top, double right, double bottom, double left) {
|
||||
return computePrefHeight(w, top, right, bottom, left);
|
||||
}
|
||||
|
||||
/// Handles clicking and dragging.
|
||||
private void handleMouse(MouseEvent event) {
|
||||
if (event.getEventType() == MouseEvent.MOUSE_CLICKED && event.getButton() == MouseButton.PRIMARY) {
|
||||
// Activation happens on release; do not also activate the surrounding settings row.
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
|
||||
if (event.getButton() != MouseButton.PRIMARY || keyDown
|
||||
|| event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown()
|
||||
|| event.isMiddleButtonDown() || event.isSecondaryButtonDown())
|
||||
return;
|
||||
if (control.isDisabled() || control.selectedProperty().isBound())
|
||||
return;
|
||||
if (control.isFocusTraversable())
|
||||
control.requestFocus();
|
||||
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||
if (point != null) {
|
||||
mouseDown = true;
|
||||
pressX = point.getX();
|
||||
pressCenter = center.get();
|
||||
control.arm();
|
||||
}
|
||||
event.consume();
|
||||
} else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED && mouseDown && event.isPrimaryButtonDown()) {
|
||||
@Nullable Point2D point = artwork.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||
if (point == null)
|
||||
return;
|
||||
if (!dragging && Math.abs(point.getX() - pressX) * sizeScale() < DRAG_THRESHOLD)
|
||||
return;
|
||||
dragging = true;
|
||||
if (geometryTransition != null)
|
||||
geometryTransition.stop();
|
||||
control.arm();
|
||||
diameter.set(PRESSED_DIAMETER);
|
||||
center.set(dragCenter(point.getX()));
|
||||
event.consume();
|
||||
} else if (event.getEventType() == MouseEvent.MOUSE_RELEASED && event.getButton() == MouseButton.PRIMARY) {
|
||||
if (!mouseDown) {
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
@Nullable Point2D point = dragging
|
||||
? artwork.sceneToLocal(event.getSceneX(), event.getSceneY())
|
||||
: control.sceneToLocal(event.getSceneX(), event.getSceneY());
|
||||
if (dragging && point != null)
|
||||
center.set(dragCenter(point.getX()));
|
||||
boolean activate = point != null && (dragging
|
||||
? (center.get() >= (OFF_CENTER + ON_CENTER) / 2) != control.isSelected()
|
||||
: control.isArmed() && control.contains(point));
|
||||
mouseDown = false;
|
||||
dragging = false;
|
||||
control.disarm();
|
||||
event.consume();
|
||||
if (activate)
|
||||
control.fire();
|
||||
// Action handlers may replace or remove the skin synchronously.
|
||||
if (control.getSkin() == this)
|
||||
update(false);
|
||||
} else if (mouseDown && !dragging) {
|
||||
if (event.getEventType() == MouseEvent.MOUSE_ENTERED)
|
||||
control.arm();
|
||||
else if (event.getEventType() == MouseEvent.MOUSE_EXITED)
|
||||
control.disarm();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps pointer displacement to the track while preserving the initial grab offset.
|
||||
private double dragCenter(double pointerX) {
|
||||
return Math.max(OFF_CENTER, Math.min(ON_CENTER, pressCenter + pointerX - pressX));
|
||||
}
|
||||
|
||||
/// Handles Space activation and Escape cancellation.
|
||||
private void handleKey(KeyEvent event) {
|
||||
if (event.getCode() == KeyCode.ESCAPE && event.getEventType() == KeyEvent.KEY_PRESSED) {
|
||||
if (mouseDown || keyDown) {
|
||||
boolean awaitingRelease = keyDown;
|
||||
cancelGesture();
|
||||
// Ignore auto-repeat until the canceled Space press is released.
|
||||
keyDown = awaitingRelease;
|
||||
event.consume();
|
||||
}
|
||||
} else if (event.getCode() == KeyCode.SPACE) {
|
||||
if (event.getEventType() == KeyEvent.KEY_PRESSED) {
|
||||
if (event.isShiftDown() || event.isControlDown() || event.isAltDown() || event.isMetaDown())
|
||||
return;
|
||||
if (!keyDown) {
|
||||
if (mouseDown || control.isDisabled() || control.selectedProperty().isBound())
|
||||
return;
|
||||
keyDown = true;
|
||||
control.arm();
|
||||
}
|
||||
event.consume();
|
||||
} else if (event.getEventType() == KeyEvent.KEY_RELEASED && keyDown) {
|
||||
keyDown = false;
|
||||
boolean activate = control.isArmed();
|
||||
control.disarm();
|
||||
event.consume();
|
||||
if (activate)
|
||||
control.fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels input without changing selection.
|
||||
private void cancelGesture() {
|
||||
finishColors();
|
||||
mouseDown = false;
|
||||
keyDown = false;
|
||||
dragging = false;
|
||||
control.disarm();
|
||||
update(true);
|
||||
}
|
||||
|
||||
/// Updates geometry and schedules selection colors for the next CSS pass.
|
||||
private void updateSelection() {
|
||||
update(false);
|
||||
if (control.isDisableAnimation() || control.isDisabled() || control.getScene() == null)
|
||||
return;
|
||||
if (colors.isEmpty()) {
|
||||
colors.add(new CssColor(track.fillProperty()));
|
||||
colors.add(new CssColor(handle.fillProperty()));
|
||||
colors.add(new CssColor(stateLayer.fillProperty()));
|
||||
colors.add(new CssColor(track.strokeProperty()));
|
||||
if (icon != null)
|
||||
colors.add(new CssColor(icon.fillProperty()));
|
||||
}
|
||||
if (colorTransition != null)
|
||||
colorTransition.stop();
|
||||
// A reversal before the first frame may reapply the visible paint without emitting a change event.
|
||||
for (CssColor color : colors)
|
||||
color.target = null;
|
||||
animateColors = true;
|
||||
control.requestLayout();
|
||||
}
|
||||
|
||||
/// Updates handle geometry, with optional animation.
|
||||
private void update(boolean immediate) {
|
||||
if (dragging)
|
||||
return;
|
||||
double currentCenter = center.get();
|
||||
double currentDiameter = diameter.get();
|
||||
if (geometryTransition != null)
|
||||
geometryTransition.stop();
|
||||
boolean selected = control.isSelected();
|
||||
boolean pressed = control.isArmed() && !control.isDisabled();
|
||||
boolean hasIcon = selected ? control.isShowSelectedIcon() : control.isShowUnselectedIcon();
|
||||
double targetDiameter = pressed ? PRESSED_DIAMETER : selected || hasIcon ? ON_DIAMETER : OFF_DIAMETER;
|
||||
double targetCenter = selected ? ON_CENTER : OFF_CENTER;
|
||||
updateIcon(selected, hasIcon);
|
||||
if (immediate || control.isDisableAnimation() || control.getScene() == null
|
||||
|| currentCenter == targetCenter && currentDiameter == targetDiameter) {
|
||||
center.set(targetCenter);
|
||||
diameter.set(targetDiameter);
|
||||
} else {
|
||||
if (geometryTransition == null)
|
||||
geometryTransition = new Timeline();
|
||||
// Preserve the visible geometry when a press or reversal interrupts a transition.
|
||||
center.set(currentCenter);
|
||||
diameter.set(currentDiameter);
|
||||
geometryTransition.getKeyFrames().setAll(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(center, currentCenter),
|
||||
new KeyValue(diameter, currentDiameter)),
|
||||
new KeyFrame(pressed ? Motion.SHORT2 : Motion.MEDIUM1,
|
||||
new KeyValue(diameter, targetDiameter, pressed ? Motion.LINEAR : Motion.STANDARD)),
|
||||
new KeyFrame(Motion.MEDIUM2,
|
||||
new KeyValue(center, targetCenter, Motion.EASE_OUT_BACK)));
|
||||
geometryTransition.play();
|
||||
}
|
||||
layoutHandle();
|
||||
}
|
||||
|
||||
/// Updates the handle icon, creating it if needed.
|
||||
private void updateIcon(boolean selected, boolean visible) {
|
||||
if (icon == null && visible) {
|
||||
icon = new SVGPath();
|
||||
icon.getStyleClass().setAll("switch-icon");
|
||||
icon.setManaged(false);
|
||||
// The first CSS fill should appear immediately, without a transition from default black.
|
||||
icon.setFill(null);
|
||||
if (!colors.isEmpty())
|
||||
colors.add(new CssColor(icon.fillProperty()));
|
||||
artwork.getChildren().add(icon);
|
||||
}
|
||||
if (icon != null) {
|
||||
icon.setVisible(visible);
|
||||
if (visible)
|
||||
icon.setContent(selected
|
||||
? "M2 8l4 4 8-8-1.4-1.4L6 9.2 3.4 6.6Z"
|
||||
: "M4 2.6l4 4 4-4L13.4 4l-4 4 4 4-1.4 1.4-4-4-4 4L2.6 12l4-4-4-4Z");
|
||||
}
|
||||
}
|
||||
|
||||
/// Positions and sizes the handle and its overlays.
|
||||
private void layoutHandle() {
|
||||
double cx = center.get();
|
||||
stateLayer.setCenterX(cx);
|
||||
handle.setCenterX(cx);
|
||||
handle.setRadius(diameter.get() / 2);
|
||||
if (icon != null && icon.isVisible())
|
||||
icon.relocate(cx - icon.getLayoutBounds().getWidth() / 2,
|
||||
CENTER_Y - icon.getLayoutBounds().getHeight() / 2);
|
||||
}
|
||||
|
||||
/// Returns the size scale, or 1 for an invalid size.
|
||||
private double sizeScale() {
|
||||
double size = control.getSize();
|
||||
return Double.isFinite(size) && size > 0 ? size / TRACK_HEIGHT : 1;
|
||||
}
|
||||
|
||||
/// Returns the shared color animation, creating it if needed.
|
||||
private Transition colorTransition() {
|
||||
if (colorTransition == null) {
|
||||
colorTransition = new Transition() {
|
||||
{
|
||||
setCycleDuration(Motion.SHORT4);
|
||||
setInterpolator(Motion.STANDARD);
|
||||
}
|
||||
|
||||
/// Updates only paints with a pending CSS target.
|
||||
@Override
|
||||
protected void interpolate(double fraction) {
|
||||
for (CssColor color : colors)
|
||||
color.interpolate(fraction);
|
||||
}
|
||||
};
|
||||
colorTransition.setOnFinished(event -> finishColors());
|
||||
}
|
||||
return colorTransition;
|
||||
}
|
||||
|
||||
/// Stops color animation and applies pending targets.
|
||||
private void finishColors() {
|
||||
animateColors = false;
|
||||
if (colorTransition != null)
|
||||
colorTransition.stop();
|
||||
for (CssColor color : colors)
|
||||
color.finish();
|
||||
}
|
||||
|
||||
/// Animates a solid paint while preserving CSS precedence.
|
||||
private final class CssColor implements ChangeListener<@Nullable Paint> {
|
||||
|
||||
/// The animated paint property.
|
||||
private final ObjectProperty<@Nullable Paint> property;
|
||||
|
||||
/// Most recent solid CSS target; null means no transition is pending.
|
||||
private @Nullable Color target;
|
||||
|
||||
/// CSS origin of the target paint.
|
||||
private @Nullable StyleOrigin origin;
|
||||
|
||||
/// Starting color.
|
||||
private Color from = Color.TRANSPARENT;
|
||||
|
||||
/// Observes a styleable paint property.
|
||||
private CssColor(ObjectProperty<@Nullable Paint> property) {
|
||||
this.property = property;
|
||||
property.addListener(this);
|
||||
}
|
||||
|
||||
/// Animates selection colors and applies other paint changes immediately.
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends @Nullable Paint> observable,
|
||||
@Nullable Paint previous, @Nullable Paint next) {
|
||||
if (applyingColors)
|
||||
return;
|
||||
// CSS may reapply the same target while the displayed value is still interpolating.
|
||||
if (target != null && target.equals(next) && previous != null) {
|
||||
apply(previous);
|
||||
return;
|
||||
}
|
||||
target = null;
|
||||
if (!animateColors || !(previous instanceof Color start) || !(next instanceof Color end) || start.equals(end))
|
||||
return;
|
||||
from = start;
|
||||
target = end;
|
||||
origin = ((StyleableProperty<?>) property).getStyleOrigin();
|
||||
interpolate(0);
|
||||
}
|
||||
|
||||
/// Applies an animation frame.
|
||||
private void interpolate(double fraction) {
|
||||
if (target != null)
|
||||
apply(fraction == 1 ? target : from.interpolate(target, fraction));
|
||||
}
|
||||
|
||||
/// Writes a CSS value while suppressing this transition's own listener.
|
||||
@SuppressWarnings("unchecked")
|
||||
private void apply(Paint value) {
|
||||
applyingColors = true;
|
||||
try {
|
||||
((StyleableProperty<Paint>) property).applyStyle(origin, value);
|
||||
} finally {
|
||||
applyingColors = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies and clears the pending target.
|
||||
private void finish() {
|
||||
if (target != null) {
|
||||
apply(target);
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the paint listener.
|
||||
private void dispose() {
|
||||
property.removeListener(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXRippler;
|
||||
import com.jfoenix.controls.JFXRippler.RipplerMask;
|
||||
import com.jfoenix.controls.JFXRippler.RipplerPos;
|
||||
import com.jfoenix.controls.JFXToggleButton;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import com.jfoenix.transitions.JFXAnimationTimer;
|
||||
import com.jfoenix.transitions.JFXKeyFrame;
|
||||
import com.jfoenix.transitions.JFXKeyValue;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.control.skin.ToggleButtonSkin;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.scene.shape.StrokeLineCap;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* <h1>Material Design ToggleButton Skin</h1>
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXToggleButtonSkin extends ToggleButtonSkin {
|
||||
|
||||
|
||||
private Runnable releaseManualRippler = null;
|
||||
|
||||
private JFXAnimationTimer timer;
|
||||
private final Circle circle;
|
||||
private final Line line;
|
||||
|
||||
public JFXToggleButtonSkin(JFXToggleButton toggleButton) {
|
||||
super(toggleButton);
|
||||
|
||||
double circleRadius = toggleButton.getSize();
|
||||
|
||||
line = new Line();
|
||||
line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
||||
line.setStartX(0);
|
||||
line.setStartY(0);
|
||||
line.setEndX(circleRadius * 2 + 2);
|
||||
line.setEndY(0);
|
||||
line.setStrokeWidth(circleRadius * 1.5);
|
||||
line.setStrokeLineCap(StrokeLineCap.ROUND);
|
||||
line.setSmooth(true);
|
||||
|
||||
circle = new Circle();
|
||||
circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor());
|
||||
circle.setCenterX(-circleRadius);
|
||||
circle.setCenterY(0);
|
||||
circle.setRadius(circleRadius);
|
||||
circle.setSmooth(true);
|
||||
JFXDepthManager.setDepth(circle, 1);
|
||||
|
||||
StackPane circlePane = new StackPane();
|
||||
circlePane.getChildren().add(circle);
|
||||
circlePane.setPadding(new Insets(circleRadius * 1.5));
|
||||
|
||||
JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK);
|
||||
rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
||||
rippler.setTranslateX(computeTranslation(circleRadius, line));
|
||||
|
||||
final StackPane main = new StackPane();
|
||||
main.getChildren().setAll(line, rippler);
|
||||
main.setCursor(Cursor.HAND);
|
||||
|
||||
// show focus traversal effect
|
||||
getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
releaseManualRippler = rippler.createManualRipple();
|
||||
} else if (releaseManualRippler != null) {
|
||||
releaseManualRippler.run();
|
||||
}
|
||||
});
|
||||
toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (!toggleButton.isDisableVisualFocus()) {
|
||||
if (newVal) {
|
||||
if (!getSkinnable().isPressed()) {
|
||||
rippler.setOverlayVisible(true);
|
||||
}
|
||||
} else {
|
||||
rippler.setOverlayVisible(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false));
|
||||
|
||||
// add change listener to selected property
|
||||
getSkinnable().selectedProperty().addListener(observable -> {
|
||||
rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor());
|
||||
if (!toggleButton.isDisableAnimation()) {
|
||||
timer.reverseAndContinue();
|
||||
} else {
|
||||
rippler.setTranslateX(computeTranslation(circleRadius, line));
|
||||
}
|
||||
});
|
||||
|
||||
getSkinnable().setGraphic(main);
|
||||
|
||||
timer = new JFXAnimationTimer(
|
||||
new JFXKeyFrame(Duration.millis(100),
|
||||
JFXKeyValue.builder()
|
||||
.setTarget(rippler.translateXProperty())
|
||||
.setEndValueSupplier(() -> computeTranslation(circleRadius, line))
|
||||
.setInterpolator(Interpolator.EASE_BOTH)
|
||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
||||
.build(),
|
||||
|
||||
JFXKeyValue.builder()
|
||||
.setTarget(line.strokeProperty())
|
||||
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
|
||||
((JFXToggleButton) getSkinnable()).getToggleLineColor()
|
||||
: ((JFXToggleButton) getSkinnable()).getUnToggleLineColor())
|
||||
.setInterpolator(Interpolator.EASE_BOTH)
|
||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
||||
.build(),
|
||||
|
||||
JFXKeyValue.builder()
|
||||
.setTarget(circle.fillProperty())
|
||||
.setEndValueSupplier(() -> getSkinnable().isSelected() ?
|
||||
((JFXToggleButton) getSkinnable()).getToggleColor()
|
||||
: ((JFXToggleButton) getSkinnable()).getUnToggleColor())
|
||||
.setInterpolator(Interpolator.EASE_BOTH)
|
||||
.setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation())
|
||||
.build()
|
||||
)
|
||||
);
|
||||
timer.setCacheNodes(circle, line);
|
||||
|
||||
registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> {
|
||||
if (getSkinnable().isSelected()) {
|
||||
circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor());
|
||||
}
|
||||
});
|
||||
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
|
||||
if (!getSkinnable().isSelected()) {
|
||||
circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor());
|
||||
}
|
||||
});
|
||||
registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> {
|
||||
if (getSkinnable().isSelected()) {
|
||||
line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor());
|
||||
}
|
||||
});
|
||||
registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> {
|
||||
if (!getSkinnable().isSelected()) {
|
||||
line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private double computeTranslation(double circleRadius, Line line) {
|
||||
return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
timer.dispose();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
@@ -23,6 +8,7 @@ import org.jackhuang.hmcl.util.SwingUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.io.TLSSecurity;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
@@ -42,18 +28,18 @@ public final class EntryPoint {
|
||||
private EntryPoint() {
|
||||
}
|
||||
|
||||
/// Initializes TLS protection before downloading dependencies and starting the launcher.
|
||||
public static void main(String[] args) {
|
||||
System.getProperties().putIfAbsent("java.net.useSystemProxies", "true");
|
||||
System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true");
|
||||
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
|
||||
|
||||
createHMCLDirectories();
|
||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
||||
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
|
||||
TLSSecurity.install();
|
||||
|
||||
checkWine();
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||
if (!isInsideMacAppBundle())
|
||||
@@ -74,95 +60,30 @@ public final class EntryPoint {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
if (uiScale != null) {
|
||||
uiScale = uiScale.trim();
|
||||
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
float scaleValue;
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
|
||||
float lowerBound;
|
||||
float upperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
lowerBound = 0.25f;
|
||||
upperBound = 4f;
|
||||
} else {
|
||||
lowerBound = 0.01f;
|
||||
upperBound = 10f;
|
||||
}
|
||||
|
||||
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void createHMCLDirectories() {
|
||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
try {
|
||||
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
|
||||
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Logger has not been started yet, so print directly to System.err
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME);
|
||||
e.printStackTrace(System.err);
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME));
|
||||
}
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_USER_HOME);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
|
||||
LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,15 +146,8 @@ public final class EntryPoint {
|
||||
|
||||
private static void checkWine() {
|
||||
if (OperatingSystem.isRunningUnderWine()) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
LOG.warning("HMCL is running under Wine or its distributions!");
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
showWarning(i18n("fatal.wine_warning"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,10 +187,21 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
static void showWarning(String message) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
||||
*/
|
||||
private static void showErrorAndExit(String message) {
|
||||
static void showErrorAndExit(String message) {
|
||||
SwingUtils.showErrorDialog(message);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,49 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.WString;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SambaException;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.CrashReporter;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryPoolMXBean;
|
||||
import java.net.CookieHandler;
|
||||
@@ -59,16 +53,19 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// The main JavaFX application entry point.
|
||||
public final class Launcher extends Application {
|
||||
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
||||
|
||||
@@ -96,21 +93,11 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
ConfigHolder.init();
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
initializeSettingsRuntime();
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& ConfigHolder.isNewlyCreated()
|
||||
&& SettingsManager.isNewlyCreated()
|
||||
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
||||
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
||||
return;
|
||||
@@ -118,27 +105,26 @@ public final class Launcher extends Application {
|
||||
checkConfigInTempDir();
|
||||
}
|
||||
|
||||
if (ConfigHolder.isOwnerChanged()) {
|
||||
if (SettingsManager.isOwnerChanged()) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfigHolder.isUnsupportedVersion()) {
|
||||
if (SettingsManager.hasReadOnlyCoreSettings()) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
|
||||
}
|
||||
|
||||
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
|
||||
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
// runLater to ensure SettingsManager.init() finished initialization
|
||||
Platform.runLater(() -> {
|
||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
||||
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
UpdateChecker.init();
|
||||
|
||||
primaryStage.show();
|
||||
});
|
||||
@@ -147,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) {
|
||||
Rectangle2D bounds = screen.getBounds();
|
||||
double scale = screen.getOutputScaleX();
|
||||
@@ -205,7 +211,7 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static boolean isConfigInTempDir() {
|
||||
String configPath = ConfigHolder.configLocation().toString();
|
||||
String configPath = SettingsManager.localConfigDirectory().toString();
|
||||
|
||||
String tmpdir = System.getProperty("java.io.tmpdir");
|
||||
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
||||
@@ -242,64 +248,20 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static void checkConfigInTempDir() {
|
||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
||||
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(ConfigHolder.configLocation().toString());
|
||||
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
|
||||
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
|
||||
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
|
||||
files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
|
||||
|
||||
if (showAlert(AlertType.ERROR,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
|
||||
Clipboard.getSystemClipboard()
|
||||
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
Controllers.onApplicationStop();
|
||||
FileSaver.shutdown();
|
||||
SettingsManager.shutdown();
|
||||
LOG.shutdown();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (UpdateHandler.processArguments(args)) {
|
||||
LOG.shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER);
|
||||
AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false));
|
||||
|
||||
@@ -312,6 +274,10 @@ public final class Launcher extends Application {
|
||||
LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER"));
|
||||
}
|
||||
LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName());
|
||||
try {
|
||||
LOG.info("Process PID: " + ProcessHandle.current().pid());
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
}
|
||||
LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET);
|
||||
LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding"));
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
@@ -322,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 Home: " + System.getProperty("java.home"));
|
||||
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
|
||||
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
||||
try {
|
||||
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
|
||||
@@ -343,8 +309,27 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
@Nullable Boolean isRoot = isRootUser();
|
||||
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
try {
|
||||
SettingsManager.init();
|
||||
} catch (SambaException e) {
|
||||
EntryPoint.showWarning(i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigOwner();
|
||||
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
setupWindowsAppUserModelID();
|
||||
|
||||
launch(Launcher.class, args);
|
||||
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
||||
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
||||
@@ -377,5 +362,283 @@ public final class Launcher extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
|
||||
private static void setupWindowsAppUserModelID() {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
|
||||
return;
|
||||
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||
return;
|
||||
@Nullable Path thisJar = JarUtils.thisJarPath();
|
||||
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
|
||||
return;
|
||||
|
||||
try {
|
||||
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||
if (hr < 0) {
|
||||
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
|
||||
} else {
|
||||
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to set AppUserModelID", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets JavaFX VM options before the toolkit starts.
|
||||
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
setUpAnimationFrameRate:
|
||||
{
|
||||
if (System.getProperty("javafx.animation.pulse") != null) {
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
int value = Integer.parseInt(animationFrameRate);
|
||||
if (value <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
if (value != 60)
|
||||
System.setProperty("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
|
||||
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
|
||||
break setUpAnimationFrameRate;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
|
||||
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
|
||||
if (pointer != null) {
|
||||
try {
|
||||
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
|
||||
|
||||
if (refreshRate > 0) {
|
||||
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
|
||||
|
||||
if (refreshRate >= 90) {
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float uiScaleLowerBound;
|
||||
float uiScaleUpperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
uiScaleLowerBound = 0.25f;
|
||||
uiScaleUpperBound = 4f;
|
||||
} else {
|
||||
uiScaleLowerBound = 0.01f;
|
||||
uiScaleUpperBound = 10f;
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
|
||||
float scaleValue = Float.NaN;
|
||||
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
|
||||
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
|
||||
|
||||
if ("wayland".equals(xdgSessionType)
|
||||
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
|
||||
try {
|
||||
@Nullable Path xrdb = SystemUtils.which("xrdb");
|
||||
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
|
||||
@Nullable String dpiLine;
|
||||
|
||||
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
|
||||
BufferedReader bufferedReader = new BufferedReader(reader);
|
||||
Stream<String> lines = bufferedReader.lines()) {
|
||||
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
return dpiLine != null
|
||||
? dpiLine.substring("Xft.dpi:".length()).trim()
|
||||
: null;
|
||||
}, java.time.Duration.ofSeconds(1)) : null;
|
||||
|
||||
if (xftDpi != null) {
|
||||
float dpiValue = Float.parseFloat(xftDpi);
|
||||
float scale = dpiValue / 96f;
|
||||
|
||||
if (scale > 1 && scale <= 10) {
|
||||
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
|
||||
scaleValue = scale;
|
||||
uiScale = Float.toString(scale);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read Xft.dpi from xrdb", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Float.isFinite(scaleValue)) {
|
||||
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(configDirectory.toString());
|
||||
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
|
||||
int result = JOptionPane.showOptionDialog(null,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
i18n("message.error"),
|
||||
JOptionPane.DEFAULT_OPTION,
|
||||
JOptionPane.ERROR_MESSAGE,
|
||||
null,
|
||||
options,
|
||||
options[0]);
|
||||
|
||||
if (result == 0) {
|
||||
try {
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to copy command to clipboard", e);
|
||||
}
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
private static @Nullable Boolean isRootUser() {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
|
||||
if (reg == null)
|
||||
return null;
|
||||
try {
|
||||
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to check Windows administrator privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case LINUX -> {
|
||||
Path selfStatus = Paths.get("/proc/self/status");
|
||||
if (Files.exists(selfStatus)) {
|
||||
try (var reader = Files.newBufferedReader(selfStatus)) {
|
||||
@Nullable String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("Uid:")) {
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length >= 3) {
|
||||
String uid = parts[2].trim();
|
||||
return "0".equals(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read " + selfStatus, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
case MACOS -> {
|
||||
if (NativeUtils.USE_JNA) {
|
||||
LibSystem libSystem = LibSystem.INSTANCE;
|
||||
if (libSystem != null) {
|
||||
try {
|
||||
return libSystem.geteuid() == 0;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to check macOS root privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
|
||||
@Nullable Path id = SystemUtils.which("id");
|
||||
if (id != null) {
|
||||
try {
|
||||
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to run id -u", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
@@ -23,8 +8,8 @@ import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
@@ -38,56 +23,56 @@ public final class Metadata {
|
||||
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
|
||||
|
||||
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
|
||||
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
|
||||
|
||||
public static final String TITLE = NAME + " " + VERSION;
|
||||
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
|
||||
|
||||
/// Whether the build metadata identifies this launcher as an official build.
|
||||
public static final boolean OFFICIAL_BUILD = Boolean.parseBoolean(JarUtils.getAttribute("hmcl.official", "false"));
|
||||
|
||||
public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17;
|
||||
public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17;
|
||||
public static final int RECOMMENDED_JAVA_VERSION = 21;
|
||||
|
||||
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
|
||||
public static final String ABOUT_URL = PUBLISH_URL + "/about";
|
||||
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
|
||||
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
|
||||
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
|
||||
public static final int RECOMMENDED_JAVA_VERSION = 25;
|
||||
|
||||
public static final String DOCS_URL = "https://docs.hmcl.net";
|
||||
public static final String CONTACT_URL = DOCS_URL + "/help.html";
|
||||
public static final String CHANGELOG_URL = DOCS_URL + "/changelog/";
|
||||
public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html";
|
||||
public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529";
|
||||
|
||||
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
||||
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
||||
|
||||
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
public static final Path HMCL_GLOBAL_DIRECTORY;
|
||||
public static final Path HMCL_CURRENT_DIRECTORY;
|
||||
public static final Path HMCL_USER_HOME;
|
||||
public static final Path HMCL_LOCAL_HOME;
|
||||
public static final Path DEPENDENCIES_DIRECTORY;
|
||||
|
||||
static {
|
||||
String hmclHome = System.getProperty("hmcl.home");
|
||||
if (hmclHome == null) {
|
||||
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
|
||||
if (StringUtils.isBlank(hmclHome)) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||
if (StringUtils.isNotBlank(xdgData)) {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir");
|
||||
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
|
||||
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
|
||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
|
||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||
: HMCL_LOCAL_HOME.resolve("dependencies");
|
||||
}
|
||||
|
||||
public static boolean isStable() {
|
||||
@@ -104,7 +89,7 @@ public final class Metadata {
|
||||
|
||||
public static @Nullable String getSuggestedJavaDownloadLink() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW)
|
||||
return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html";
|
||||
return "https://www.loongnix.cn/zh/api/java/downloads-jdk25/index.html";
|
||||
else {
|
||||
EnumSet<Architecture> supportedArchitectures;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
@@ -128,4 +113,71 @@ public final class Metadata {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
|
||||
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
|
||||
|
||||
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
|
||||
public static Path getBundledModpackDirectory() {
|
||||
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
|
||||
///
|
||||
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
|
||||
/// signal to offer automatic install; the file is removed after a successful install.
|
||||
///
|
||||
/// @return the modpack path, or `null` when no package is present
|
||||
public static @Nullable Path findBundledModpackFile() {
|
||||
Path directory = getBundledModpackDirectory();
|
||||
Path zipModpack = directory.resolve("modpack.zip");
|
||||
if (Files.isRegularFile(zipModpack)) {
|
||||
return zipModpack;
|
||||
}
|
||||
Path mrpackModpack = directory.resolve("modpack.mrpack");
|
||||
if (Files.isRegularFile(mrpackModpack)) {
|
||||
return mrpackModpack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Default client Server Modpack fileApi published by TDLMPack (Gitea generic package `latest`).
|
||||
public static final String DEFAULT_CLIENT_FILE_API =
|
||||
"https://git.nest.potatolab.uk:8443/api/packages/TDLM/generic/tdlm-client/latest";
|
||||
|
||||
/// Stable client update root (`server-manifest.json` and `overrides/` live under this URL).
|
||||
///
|
||||
/// Override with system property `hmcl.client.fileApi` or jar attribute `hmcl.client.fileApi`.
|
||||
public static String getClientFileApi() {
|
||||
String fromProperty = System.getProperty("hmcl.client.fileApi", "").trim();
|
||||
if (StringUtils.isNotBlank(fromProperty)) {
|
||||
return StringUtils.removeSuffix(fromProperty, "/");
|
||||
}
|
||||
String fromJar = JarUtils.getAttribute("hmcl.client.fileApi", null);
|
||||
if (StringUtils.isNotBlank(fromJar)) {
|
||||
return StringUtils.removeSuffix(fromJar.trim(), "/");
|
||||
}
|
||||
return DEFAULT_CLIENT_FILE_API;
|
||||
}
|
||||
|
||||
/// Absolute URL of the client `server-manifest.json` used for first install and differential updates.
|
||||
public static String getClientManifestUrl() {
|
||||
return getClientFileApi() + "/server-manifest.json";
|
||||
}
|
||||
|
||||
/// 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; defaults to `TDLM`.
|
||||
public static String getServerName() {
|
||||
String name = System.getProperty("hmcl.server.name", "TDLM").trim();
|
||||
return name.isEmpty() ? "TDLM" : name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.addon;
|
||||
|
||||
import org.jackhuang.hmcl.addon.mod.ModProjectInfo;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProjectInfo;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public abstract class AddonProjectInfoRegistry<I extends AddonProjectInfo> {
|
||||
public static final AddonProjectInfoRegistry<ModProjectInfo> MOD = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/mod.csv",
|
||||
32768
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return MOD_IDS_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModProjectInfo parseAddonProjectInfo(String[] fields) throws Exception {
|
||||
return new ModProjectInfo(
|
||||
Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX],
|
||||
fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX],
|
||||
fields[SUB_NAME_INDEX],
|
||||
fields[ABBR_NAME_INDEX],
|
||||
fields[MOD_IDS_INDEX]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// Provides metadata for modpacks.
|
||||
public static final AddonProjectInfoRegistry<ModpackProjectInfo> MODPACK = new AddonProjectInfoRegistry<>(
|
||||
"/assets/addon/modpack.csv", 2048
|
||||
) {
|
||||
@Override
|
||||
protected int getFieldsCount() {
|
||||
return ABBR_NAME_INDEX + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModpackProjectInfo parseAddonProjectInfo(String[] fields) {
|
||||
return new ModpackProjectInfo(Integer.parseInt(fields[MCMOD_ID_INDEX]),
|
||||
fields[CURSEFORGE_ID_INDEX], fields[MODRINTH_ID_INDEX],
|
||||
fields[MAIN_NAME_INDEX], fields[SUB_NAME_INDEX], fields[ABBR_NAME_INDEX]);
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the registry for the addon type, or `null` for null or unsupported types.
|
||||
public static @Nullable AddonProjectInfoRegistry<?> getByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return null;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
protected static final int MCMOD_ID_INDEX = 0;
|
||||
protected static final int CURSEFORGE_ID_INDEX = 1;
|
||||
protected static final int MODRINTH_ID_INDEX = 2;
|
||||
protected static final int MAIN_NAME_INDEX = 3;
|
||||
protected static final int SUB_NAME_INDEX = 4;
|
||||
protected static final int ABBR_NAME_INDEX = 5;
|
||||
protected static final int MOD_IDS_INDEX = 6;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final String assetsPath;
|
||||
private final int initialCapacity;
|
||||
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<I>> infosRef;
|
||||
|
||||
/// Softly caches projects indexed by mod ID.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> modIdMapRef;
|
||||
|
||||
/// Softly caches projects indexed by cleaned secondary name.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> subnameMapRef;
|
||||
|
||||
/// Softly caches projects indexed by CurseForge identifier.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable Map<String, I>> curseForgeMapRef;
|
||||
|
||||
/// Softly caches searchable names in resource and field order.
|
||||
private volatile @Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> keywordsRef;
|
||||
|
||||
/// Creates a registry reading records from the given classpath resource.
|
||||
AddonProjectInfoRegistry(String assetsPath, int initialCapacity) {
|
||||
this.assetsPath = assetsPath;
|
||||
this.initialCapacity = initialCapacity;
|
||||
}
|
||||
|
||||
protected abstract int getFieldsCount();
|
||||
|
||||
protected abstract I parseAddonProjectInfo(String[] fields) throws Exception;
|
||||
|
||||
private @Unmodifiable List<I> loadInfos() throws IOException {
|
||||
@Nullable InputStream inputStream = AddonProjectInfoRegistry.class.getResourceAsStream(assetsPath);
|
||||
if (inputStream == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
ArrayList<I> infos = new ArrayList<>(initialCapacity);
|
||||
@Unmodifiable List<I> result = Collections.unmodifiableList(infos);
|
||||
|
||||
int fieldsCount = getFieldsCount();
|
||||
String[] fields = new String[fieldsCount];
|
||||
|
||||
@Nullable StringBuilder fieldBuilder = null;
|
||||
|
||||
@Nullable String line;
|
||||
loop:
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
for (int fieldIndex = 0; fieldIndex < fieldsCount; fieldIndex++) {
|
||||
if (offset > line.length()) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
|
||||
int end = line.indexOf(',', offset);
|
||||
if (end < 0)
|
||||
end = line.length();
|
||||
|
||||
|
||||
String field;
|
||||
if (offset == end) {
|
||||
field = "";
|
||||
} else {
|
||||
int cursor = offset;
|
||||
while (cursor < end) {
|
||||
if (line.charAt(cursor) == '\\') {
|
||||
break;
|
||||
} else {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursor == end) {
|
||||
field = line.substring(offset, end);
|
||||
} else {
|
||||
if (fieldBuilder == null) {
|
||||
fieldBuilder = new StringBuilder(32);
|
||||
} else {
|
||||
fieldBuilder.setLength(0);
|
||||
}
|
||||
|
||||
fieldBuilder.append(line, offset, cursor);
|
||||
while (cursor < end) {
|
||||
char c = line.charAt(cursor);
|
||||
if (c == '\\') {
|
||||
if (cursor < end - 1) {
|
||||
char next = line.charAt(cursor + 1);
|
||||
switch (next) {
|
||||
case '0' -> {
|
||||
fieldBuilder.append('\0');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'n' -> {
|
||||
fieldBuilder.append('\n');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'r' -> {
|
||||
fieldBuilder.append('\r');
|
||||
cursor += 2;
|
||||
}
|
||||
case 't' -> {
|
||||
fieldBuilder.append('\t');
|
||||
cursor += 2;
|
||||
}
|
||||
case '\\' -> {
|
||||
fieldBuilder.append('\\');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'c' -> {
|
||||
fieldBuilder.append(',');
|
||||
cursor += 2;
|
||||
}
|
||||
case 'u' -> {
|
||||
if (cursor < end - 5) {
|
||||
try {
|
||||
int codePoint = Integer.parseInt(line, cursor + 2, cursor + 6, 16);
|
||||
fieldBuilder.append((char) codePoint);
|
||||
cursor += 6;
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
LOG.warning("Invalid line in " + assetsPath + ": " + line);
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
fieldBuilder.append(c);
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
field = fieldBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
fields[fieldIndex] = field;
|
||||
offset = end + 1;
|
||||
}
|
||||
|
||||
try {
|
||||
I info = parseAddonProjectInfo(fields);
|
||||
info.setKeepAlive(result);
|
||||
infos.add(info);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to parse line in " + assetsPath + ": " + line, e);
|
||||
}
|
||||
}
|
||||
|
||||
infos.trimToSize();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getModIdMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.modIdMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.modIdMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
if (info instanceof ModProjectInfo mod) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (!id.isEmpty())
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.modIdMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getSubnameMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.subnameMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.subnameMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String name = cleanSubname(info.subName());
|
||||
if (!name.isEmpty())
|
||||
map.putIfAbsent(name, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.subnameMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an unmodifiable index, rebuilding it after its soft reference is cleared.
|
||||
/// Duplicate keys retain the first project in resource order.
|
||||
private @Unmodifiable Map<String, I> getCurseForgeMap() {
|
||||
@Nullable SoftReference<@Unmodifiable Map<String, I>> reference = this.curseForgeMapRef;
|
||||
@Unmodifiable Map<String, I> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = this.curseForgeMapRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
Map<String, I> map = new HashMap<>();
|
||||
for (I info : getInfos()) {
|
||||
String id = info.curseforgeId();
|
||||
if (StringUtils.isNotBlank(id))
|
||||
map.putIfAbsent(id, info);
|
||||
}
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
this.curseForgeMapRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the first project with the exact CurseForge identifier, or `null` if the
|
||||
/// identifier is null, blank or unknown.
|
||||
public @Nullable I getInfoByCurseForgeId(@Nullable String id) {
|
||||
return StringUtils.isBlank(id) ? null : getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
/// Finds a project by its cleaned secondary name, falling back to its exact mod ID.
|
||||
/// A name match is accepted only when the ID is blank or belongs to that mod project.
|
||||
/// Null and blank inputs are absent; returns `null` if neither lookup matches.
|
||||
/// Duplicate keys resolve to the first project in resource order.
|
||||
public @Nullable I getInfo(@Nullable String id, @Nullable String subname) {
|
||||
String name = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
@Nullable I info = getSubnameMap().get(name);
|
||||
if (info != null && (StringUtils.isBlank(id)
|
||||
|| info instanceof ModProjectInfo mod && mod.getModIds().contains(id)))
|
||||
return info;
|
||||
}
|
||||
return StringUtils.isBlank(id) ? null : getModIdMap().get(id);
|
||||
}
|
||||
|
||||
private static boolean isSupportedSubnameChar(int ch) {
|
||||
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0;
|
||||
}
|
||||
|
||||
/// Removes supported separators from a secondary name.
|
||||
/// Returns an empty string for null, blank or unsupported names; matching is case-sensitive.
|
||||
private static String cleanSubname(@Nullable String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
int offset = 0;
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.charAt(offset);
|
||||
if (!isSupportedSubnameChar(ch))
|
||||
break;
|
||||
offset++;
|
||||
}
|
||||
|
||||
if (offset == subname.length())
|
||||
return subname;
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
builder.append(subname, 0, offset);
|
||||
|
||||
while (offset < subname.length()) {
|
||||
int ch = subname.codePointAt(offset);
|
||||
if (isSupportedSubnameChar(ch)) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (!(Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0)) {
|
||||
return "";
|
||||
}
|
||||
offset += Character.charCount(ch);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/// Returns searchable names, rebuilding the index after its soft reference is cleared.
|
||||
private @Unmodifiable List<Pair<String, I>> getKeywords() {
|
||||
@Nullable SoftReference<@Unmodifiable List<Pair<String, I>>> reference = keywordsRef;
|
||||
@Unmodifiable List<Pair<String, I>> cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
lock.lock();
|
||||
try {
|
||||
reference = keywordsRef;
|
||||
cached = reference != null ? reference.get() : null;
|
||||
if (cached != null)
|
||||
return cached;
|
||||
List<Pair<String, I>> keywords = new ArrayList<>();
|
||||
for (I info : getInfos()) {
|
||||
if (!info.mainName().isEmpty())
|
||||
keywords.add(Pair.pair(info.mainName(), info));
|
||||
if (!info.subName().isEmpty())
|
||||
keywords.add(Pair.pair(info.subName(), info));
|
||||
if (!info.abbrName().isEmpty())
|
||||
keywords.add(Pair.pair(info.abbrName(), info));
|
||||
}
|
||||
cached = List.copyOf(keywords);
|
||||
keywordsRef = new SoftReference<>(cached);
|
||||
return cached;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns projects ranked by descending longest common subsequence length after removing
|
||||
/// space characters from the query. Comparison is case-sensitive and uses UTF-16 units.
|
||||
/// Matches require at least one unit and at least query length minus three units.
|
||||
/// Each matching name contributes a result, so projects may occur more than once.
|
||||
/// Ties preserve resource order, then primary name, secondary name and abbreviation order.
|
||||
public @Unmodifiable List<I> search(String query) {
|
||||
query = query.chars().filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, ch) -> sb.append((char) ch), StringBuilder::append).toString();
|
||||
@Unmodifiable List<Pair<String, I>> keywords = getKeywords();
|
||||
int maxLength = keywords.stream().mapToInt(keyword -> keyword.getKey().length()).max().orElse(0);
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxLength);
|
||||
List<Pair<Integer, I>> matches = new ArrayList<>();
|
||||
for (Pair<String, I> keyword : keywords) {
|
||||
int score = lcs.calc(query, keyword.getKey());
|
||||
if (score >= Math.max(1, query.length() - 3))
|
||||
matches.add(Pair.pair(score, keyword.getValue()));
|
||||
}
|
||||
return matches.stream().sorted((a, b) -> Integer.compare(b.getKey(), a.getKey()))
|
||||
.map(Pair::getValue).toList();
|
||||
}
|
||||
|
||||
/// Returns cached project metadata, loading it on demand. Missing resources and I/O failures
|
||||
/// produce an empty list; malformed records are skipped. The cache is softly referenced.
|
||||
public @Unmodifiable List<I> getInfos() {
|
||||
@Nullable SoftReference<@Unmodifiable List<I>> infosRef = this.infosRef;
|
||||
@Unmodifiable List<I> infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
infosRef = this.infosRef;
|
||||
infos = infosRef != null ? infosRef.get() : null;
|
||||
if (infos != null)
|
||||
return infos;
|
||||
|
||||
try {
|
||||
infos = loadInfos();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load addon project info from " + assetsPath, e);
|
||||
infos = List.of();
|
||||
}
|
||||
this.infosRef = new SoftReference<>(infos);
|
||||
return infos;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
|
||||
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
|
||||
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile<?>> addons) {
|
||||
dependents = addons.stream().map(addon ->
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
AddonUpdate candidate = null;
|
||||
AddonUpdate candidateRelease = null;
|
||||
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
|
||||
AddonUpdate update = null;
|
||||
AddonUpdate updateRelease = null;
|
||||
try {
|
||||
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||
if (u != null) {
|
||||
update = u.key();
|
||||
updateRelease = u.value();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
|
||||
}
|
||||
if (update == null) continue;
|
||||
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
|
||||
candidate = update;
|
||||
|
||||
if (updateRelease == null) continue;
|
||||
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
|
||||
candidateRelease = updateRelease;
|
||||
}
|
||||
|
||||
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
|
||||
return Pair.pair(candidate, candidateRelease);
|
||||
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
|
||||
).toList();
|
||||
|
||||
setStage("update.checking");
|
||||
getProperties().put("total", dependents.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preExecute() {
|
||||
notifyPropertiesChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends Task<?>> getDependents() {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
|
||||
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
|
||||
.forEachOrdered(pair -> {
|
||||
commonUpdates.add(pair.key());
|
||||
if (pair.value() != null) releaseUpdates.add(pair.value());
|
||||
});
|
||||
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents = new ArrayList<>();
|
||||
private final List<LocalAddonFile<?>> failedAddons = new ArrayList<>();
|
||||
|
||||
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
|
||||
setStage("addon.check_update.confirm");
|
||||
getProperties().put("total", addons.size());
|
||||
|
||||
for (AddonUpdate addon : addons) {
|
||||
LocalAddonFile<?> local = addon.localAddonFile();
|
||||
RemoteAddon.Version remote = addon.targetVersion();
|
||||
boolean isDisabled = local.isDisabled();
|
||||
String fileName = remote.file().filename();
|
||||
if (isDisabled)
|
||||
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
|
||||
String newFileName = fileName;
|
||||
Path targetFile = addonDirectory.resolve(newFileName);
|
||||
|
||||
dependents.add(Task
|
||||
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
|
||||
.thenComposeAsync(tempFile ->
|
||||
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
|
||||
.setName(remote.name())
|
||||
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
|
||||
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
|
||||
.whenComplete(Schedulers.javafx(), exception -> {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to delete temp file " + tempFile, e);
|
||||
}
|
||||
if (exception != null) {
|
||||
// restore state if failed
|
||||
local.setOld(false);
|
||||
if (isDisabled)
|
||||
local.markDisabled();
|
||||
} else {
|
||||
local.onUpdated(newFileName);
|
||||
if (!local.keepOldFiles()) {
|
||||
try {
|
||||
local.delete();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to update addon", exception);
|
||||
failedAddons.add(local);
|
||||
}
|
||||
}).withCounter("addon.check_update.confirm"));
|
||||
}
|
||||
}
|
||||
|
||||
public List<LocalAddonFile<?>> getFailedAddons() {
|
||||
return failedAddons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preExecute() {
|
||||
notifyPropertiesChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (!isDependentsSucceeded())
|
||||
throw getException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.download;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.AssetObject;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.RegionUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
private volatile DownloadSource versionListSource = DownloadSource.MIRROR;
|
||||
private volatile DownloadSource fileSource = DownloadSource.MIRROR;
|
||||
|
||||
private static boolean preferMirror(DownloadSource source) {
|
||||
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
|
||||
return source != DownloadSource.OFFICIAL;
|
||||
}
|
||||
return source == DownloadSource.MIRROR;
|
||||
}
|
||||
|
||||
private static DownloadCandidates getCandidates(
|
||||
DownloadSource source,
|
||||
WebURL officialUrl,
|
||||
List<WebURL> mirrorUrls
|
||||
) {
|
||||
if (preferMirror(source)) {
|
||||
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
|
||||
candidates.addAll(spreadPrimary(mirrorUrls.stream().map(DownloadCandidate::of).toList(), officialUrl.href()));
|
||||
candidates.add(DownloadCandidate.of(officialUrl));
|
||||
return DownloadCandidates.of(List.copyOf(candidates));
|
||||
} else {
|
||||
var candidates = new ArrayList<DownloadCandidate>(mirrorUrls.size() + 1);
|
||||
candidates.add(DownloadCandidate.of(officialUrl));
|
||||
for (WebURL mirrorUrl : mirrorUrls) {
|
||||
candidates.add(DownloadCandidate.of(mirrorUrl));
|
||||
}
|
||||
return DownloadCandidates.of(List.copyOf(candidates));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotates the first mirror among the top entries using a stable hash of the URL, so concurrent
|
||||
/// file downloads spread across healthy mirrors instead of stampeding one entry point.
|
||||
private static List<DownloadCandidate> spreadPrimary(List<DownloadCandidate> mirrors, String key) {
|
||||
if (mirrors.size() <= 1)
|
||||
return mirrors;
|
||||
|
||||
int top = Math.min(2, mirrors.size());
|
||||
int primary = Math.floorMod(key.hashCode(), top);
|
||||
if (primary == 0)
|
||||
return mirrors;
|
||||
|
||||
var rotated = new ArrayList<DownloadCandidate>(mirrors.size());
|
||||
rotated.add(mirrors.get(primary));
|
||||
for (int i = 0; i < mirrors.size(); i++) {
|
||||
if (i != primary)
|
||||
rotated.add(mirrors.get(i));
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
|
||||
if (urls.isEmpty()) {
|
||||
throw new IllegalArgumentException("urls cannot be empty");
|
||||
}
|
||||
|
||||
// true means prefer mirror, false means prefer official, null means do not use mirror
|
||||
@Nullable Boolean mirrorFirst;
|
||||
if ("CN".equals(RegionUtils.MACHINE_REGION)) {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, MIRROR -> true;
|
||||
case OFFICIAL -> false;
|
||||
};
|
||||
} else {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, OFFICIAL -> null;
|
||||
case MIRROR -> true;
|
||||
};
|
||||
}
|
||||
|
||||
if (mirrorFirst == null) {
|
||||
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
|
||||
}
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 3);
|
||||
for (String url : urls) {
|
||||
DownloadCandidate candidate = DownloadCandidate.of(url);
|
||||
if (candidate.url() == null) {
|
||||
// Invalid URL, just add it to the list and let the download task handle it.
|
||||
candidates.add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean added = false;
|
||||
for (MirrorRule rule : rules) {
|
||||
@Nullable List<DownloadCandidate> list = rule.getCandidates(candidate, mirrorFirst);
|
||||
if (list != null) {
|
||||
candidates.addAll(list);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!added)
|
||||
candidates.add(candidate);
|
||||
}
|
||||
|
||||
return DownloadCandidates.of(List.copyOf(candidates));
|
||||
}
|
||||
|
||||
private final Supplier<@Unmodifiable List<String>> mirrorRoots;
|
||||
private final List<MirrorRule> rules;
|
||||
|
||||
public HMCLDownloadProvider(Supplier<@Unmodifiable List<String>> mirrorRoots) {
|
||||
this.mirrorRoots = Objects.requireNonNull(mirrorRoots);
|
||||
this.rules = List.of(
|
||||
new MultiRootRule("https://bmclapi2.bangbang93.com", Function.identity()),
|
||||
new MultiRootRule("https://bmclapi.bangbang93.com", Function.identity()),
|
||||
new MultiRootRule("https://launchermeta.mojang.com", Function.identity()),
|
||||
new MultiRootRule("https://piston-meta.mojang.com", Function.identity()),
|
||||
new MultiRootRule("https://piston-data.mojang.com", Function.identity()),
|
||||
new MultiRootRule("https://launcher.mojang.com", Function.identity()),
|
||||
new MultiRootRule("https://libraries.minecraft.net", root -> root + "/libraries"),
|
||||
new MultiRootRule("http://files.minecraftforge.net/maven", root -> root + "/maven"),
|
||||
new MultiRootRule("https://files.minecraftforge.net/maven", root -> root + "/maven"),
|
||||
new MultiRootRule("https://maven.minecraftforge.net", root -> root + "/maven"),
|
||||
(source, mirrorFirst) -> {
|
||||
String neoforgedMaven = "https://maven.neoforged.net/";
|
||||
String neoforgedMavenRelease = "https://maven.neoforged.net/releases/";
|
||||
|
||||
if (source.url() == null || !source.url().href().startsWith(neoforgedMaven))
|
||||
return null;
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>();
|
||||
List<String> roots = mirrorRoots.get();
|
||||
|
||||
if (source.url().href().startsWith(neoforgedMavenRelease)) {
|
||||
String suffix = source.url().href().substring(neoforgedMavenRelease.length());
|
||||
List<DownloadCandidate> mirrors = roots.stream()
|
||||
.map(root -> DownloadCandidate.of(root + "/maven/" + suffix))
|
||||
.toList();
|
||||
if (mirrorFirst) {
|
||||
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
|
||||
candidates.add(source);
|
||||
} else {
|
||||
candidates.add(source);
|
||||
candidates.addAll(mirrors);
|
||||
}
|
||||
} else {
|
||||
candidates.add(source);
|
||||
}
|
||||
|
||||
// https://neoforged.forgecdn.net
|
||||
candidates.add(DownloadCandidate.of("https://neoforged.forgecdn.net/" + source.url().href().substring(neoforgedMaven.length())));
|
||||
return candidates;
|
||||
},
|
||||
new MultiRootRule("http://dl.liteloader.com/versions", root -> root + "/maven"),
|
||||
new MultiRootRule("https://dl.liteloader.com/versions", root -> root + "/maven"),
|
||||
new MultiRootRule("https://meta.fabricmc.net", root -> root + "/fabric-meta"),
|
||||
new MultiRootRule("https://maven.fabricmc.net", root -> root + "/maven"),
|
||||
new MultiRootRule("https://authlib-injector.yushi.moe", root -> root + "/mirrors/authlib-injector"),
|
||||
new SimpleRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new SimpleRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new SimpleRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
|
||||
new SimpleRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
||||
new SimpleRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
||||
|
||||
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
||||
new SimpleRule("https://api.modrinth.com", null, "https://mod.mcimirror.top/modrinth"),
|
||||
new SimpleRule("https://cdn.modrinth.com", null, "https://mod.mcimirror.top"),
|
||||
new SimpleRule("https://api.curseforge.com", null, "https://mod.mcimirror.top/curseforge"),
|
||||
new SimpleRule("https://edge.forgecdn.net", null, "https://mod.mcimirror.top")
|
||||
);
|
||||
}
|
||||
|
||||
private String primaryMirrorRoot() {
|
||||
List<String> roots = mirrorRoots.get();
|
||||
return roots.isEmpty() ? "https://bmclapi2.bangbang93.com" : roots.get(0);
|
||||
}
|
||||
|
||||
private List<WebURL> mirrorUrls(String suffix) {
|
||||
return mirrorRoots.get().stream()
|
||||
.map(root -> WebURL.parse(root + suffix))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
|
||||
assert (gameVersion == null) == (type == GameComponentType.GAME);
|
||||
String bmclRoot = primaryMirrorRoot();
|
||||
switch (type) {
|
||||
case FORGE -> {
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
if (preferMirror(source)) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case NEO_FORGE -> {
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (source == DownloadSource.OFFICIAL) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
|
||||
if (preferMirror(source)) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case OPTIFINE -> {
|
||||
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return super.fetchVersionsAsync(type, gameVersion);
|
||||
}
|
||||
|
||||
public void setFileSource(DownloadSource fileSource) {
|
||||
this.fileSource = Objects.requireNonNull(fileSource);
|
||||
}
|
||||
|
||||
public void setVersionListSource(DownloadSource versionListSource) {
|
||||
this.versionListSource = Objects.requireNonNull(versionListSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLCacheRepository getCacheRepository() {
|
||||
return HMCLCacheRepository.REPOSITORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getGameVersionListCandidates() {
|
||||
return getCandidates(
|
||||
versionListSource,
|
||||
GameRemoteVersion.VERSION_MANIFEST_URL,
|
||||
mirrorUrls("/mc/game/version_manifest.json")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
|
||||
DownloadSource source = versionListSource;
|
||||
return new FabricLikeVersionListCandidates(
|
||||
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, mirrorUrls("/fabric-meta/v2/versions/loader")),
|
||||
getCandidates(source, FabricRemoteVersion.GAME_META_URL, mirrorUrls("/fabric-meta/v2/versions/game"))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getCleanroomVersionListCandidates() {
|
||||
return "CN".equals(RegionUtils.MACHINE_REGION)
|
||||
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
|
||||
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getDownloadCandidates(List<String> urls) {
|
||||
return getCandidates(fileSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getVersionListCandidates(List<String> urls) {
|
||||
return getCandidates(versionListSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
||||
String location = assetObject.getLocation();
|
||||
return getCandidates(
|
||||
fileSource,
|
||||
WebURL.parse("https://resources.download.minecraft.net/" + location),
|
||||
mirrorUrls("/assets/" + location)
|
||||
);
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class FallbackTask<T> extends Task<T> {
|
||||
|
||||
private final Task<? extends T> task;
|
||||
private final Task<? extends T> fallback;
|
||||
|
||||
private List<Task<?>> dependencies = List.of();
|
||||
|
||||
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
|
||||
this.task = task;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends Task<?>> getDependents() {
|
||||
return List.of(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (isDependentsSucceeded()) {
|
||||
setResult(task.getResult());
|
||||
} else {
|
||||
dependencies = List.of(fallback);
|
||||
fallback.storeTo(this::setResult);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Task<?>> getDependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
@FunctionalInterface
|
||||
private interface MirrorRule {
|
||||
@Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst);
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private final class MultiRootRule implements MirrorRule {
|
||||
private final String source;
|
||||
private final Function<String, String> rootMapper;
|
||||
|
||||
private MultiRootRule(String source, Function<String, String> rootMapper) {
|
||||
this.source = source;
|
||||
this.rootMapper = rootMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
|
||||
if (source.url() == null || !source.url().href().startsWith(this.source))
|
||||
return null;
|
||||
|
||||
String urlWithoutPrefix = source.url().href().substring(this.source.length());
|
||||
List<DownloadCandidate> mirrors = mirrorRoots.get().stream()
|
||||
.map(root -> DownloadCandidate.of(rootMapper.apply(root) + urlWithoutPrefix))
|
||||
.toList();
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>(mirrors.size() + 1);
|
||||
if (mirrorFirst) {
|
||||
candidates.addAll(spreadPrimary(mirrors, source.url().href()));
|
||||
candidates.add(source);
|
||||
} else {
|
||||
candidates.add(source);
|
||||
candidates.addAll(mirrors);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private record SimpleRule(String source, @Nullable String first, @Nullable String fallback) implements MirrorRule {
|
||||
public SimpleRule(String source, String target) {
|
||||
this(source, target, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable List<DownloadCandidate> getCandidates(DownloadCandidate source, boolean mirrorFirst) {
|
||||
if (source.url() == null || !source.url().href().startsWith(this.source))
|
||||
return null;
|
||||
|
||||
boolean addSourceFirst = !mirrorFirst || first == null;
|
||||
String urlWithoutPrefix = source.url().href().substring(this.source.length());
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>();
|
||||
|
||||
if (addSourceFirst)
|
||||
candidates.add(source);
|
||||
|
||||
if (first != null)
|
||||
candidates.add(DownloadCandidate.of(first + urlWithoutPrefix));
|
||||
|
||||
if (!addSourceFirst)
|
||||
candidates.add(source);
|
||||
|
||||
if (fallback != null)
|
||||
candidates.add(DownloadCandidate.of(fallback + urlWithoutPrefix));
|
||||
|
||||
return candidates;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultCacheRepository;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Provides HMCL-specific game builders for an HMCL game repository.
|
||||
@NotNullByDefault
|
||||
public class HMCLDependencyManager extends DefaultDependencyManager {
|
||||
/// Creates a dependency manager for a repository and download context.
|
||||
///
|
||||
/// @param repository the associated game repository
|
||||
/// @param downloadProvider the remote download provider
|
||||
/// @param cacheRepository the artifact cache
|
||||
public HMCLDependencyManager(
|
||||
HMCLGameRepository repository,
|
||||
DownloadProvider downloadProvider,
|
||||
DefaultCacheRepository cacheRepository) {
|
||||
super(repository, downloadProvider, cacheRepository);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameRepository getGameRepository() {
|
||||
return (HMCLGameRepository) super.getGameRepository();
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
|
||||
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
|
||||
validateGameInstance(instance);
|
||||
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
|
||||
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
|
||||
return new HMCLGameBuilder(
|
||||
this, updateTarget.getId(), updateTarget, draft, initialManifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultGameBuilder;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Builds game instances and applies HMCL-specific post-installation settings.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameBuilder extends DefaultGameBuilder {
|
||||
/// Creates a builder around an HMCL target already reserved by its dependency manager.
|
||||
///
|
||||
/// @param dependencyManager the dependency manager for the target repository
|
||||
/// @param instanceId the id of the reserved instance
|
||||
/// @param updateTarget the exact update target retained by the draft, or `null` for install
|
||||
/// @param draft the open draft containing `initialManifest`
|
||||
/// @param initialManifest the empty target manifest retained by `draft`
|
||||
HMCLGameBuilder(
|
||||
HMCLDependencyManager dependencyManager,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable HMCLGameInstance updateTarget,
|
||||
DefaultGameRepositoryDraft draft,
|
||||
GameInstanceManifest initialManifest) {
|
||||
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
///
|
||||
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
|
||||
/// launches use the instance root. A read-only instance retains its existing setting.
|
||||
@Override
|
||||
protected void onInstanceCommitted(DefaultGameInstance instance) {
|
||||
if (isolationEnabled) {
|
||||
((HMCLGameInstance) instance).enableIsolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectPropertyBase;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameInstance extends DefaultGameInstance {
|
||||
|
||||
/// Whether the instance-local game settings file has already been inspected.
|
||||
private boolean gameSettingsLoaded;
|
||||
|
||||
/// Whether the instance-local game settings file cannot be overwritten safely.
|
||||
private boolean gameSettingsReadOnly;
|
||||
|
||||
/// Cached instance-local game settings, or `null` when none exist after loading.
|
||||
private GameSettings.@Nullable Instance gameSettings;
|
||||
|
||||
/// Creates a registered instance bound to the given repository snapshot.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
|
||||
this(snapshot, id, manifest, (Path) null);
|
||||
}
|
||||
|
||||
/// Creates a registered instance with an optional non-conventional manifest path.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
|
||||
protected HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
@Nullable Path manifestFile) {
|
||||
super(snapshot, id, manifest, manifestFile);
|
||||
}
|
||||
|
||||
/// Creates an instance that shares mutable instance-local state with another instance.
|
||||
///
|
||||
/// Used when the repository clones a snapshot so that settings and the icon property remain
|
||||
/// available on the new wrapper.
|
||||
private HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
HMCLGameInstance shareState) {
|
||||
super(snapshot, id, manifest, shareState);
|
||||
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
|
||||
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
|
||||
this.gameSettings = shareState.gameSettings;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
/// Returns the HMCL modpack configuration file for this instance.
|
||||
///
|
||||
/// @return the `modpack.cfg` path in the instance root
|
||||
@Override
|
||||
public Path getModpackConfigurationFile() {
|
||||
return getLayout().getModpackConfigurationFile(getId());
|
||||
}
|
||||
|
||||
/// Returns whether this instance has an HMCL modpack configuration file.
|
||||
///
|
||||
/// @return whether [#getModpackConfigurationFile()] exists
|
||||
public boolean isModpack() {
|
||||
return Files.exists(getModpackConfigurationFile());
|
||||
}
|
||||
|
||||
/// Reads this instance's HMCL modpack configuration.
|
||||
///
|
||||
/// @return the parsed configuration, or `null` when the file does not exist
|
||||
/// @throws IOException if the configuration cannot be read
|
||||
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
|
||||
Path file = getModpackConfigurationFile();
|
||||
if (Files.notExists(file)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed modpack configuration: " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory() {
|
||||
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
|
||||
}
|
||||
|
||||
/// Returns the loaded instance-local game settings, loading them while this instance is current.
|
||||
///
|
||||
/// @return the settings, or `null` when no local settings exist after loading
|
||||
public @Nullable GameSettings.Instance getSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettings;
|
||||
}
|
||||
|
||||
/// Returns the instance-local game settings, creating an empty settings object when absent.
|
||||
///
|
||||
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
|
||||
public @Nullable GameSettings.Instance getSettingsOrCreate() {
|
||||
GameSettings.Instance setting = getSettings();
|
||||
if (setting == null) {
|
||||
setting = createSettings();
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Resolves this instance's effective settings against its selected parent preset.
|
||||
///
|
||||
/// @return the effective settings
|
||||
public GameSettings.Effective getEffectiveSettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
|
||||
}
|
||||
|
||||
/// Creates empty instance-local game settings when none are loaded.
|
||||
///
|
||||
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
|
||||
public @Nullable GameSettings.Instance createSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (gameSettingsReadOnly) {
|
||||
return null;
|
||||
}
|
||||
if (gameSettings != null) {
|
||||
return gameSettings;
|
||||
}
|
||||
return initSettings(new GameSettings.Instance(), true);
|
||||
}
|
||||
|
||||
/// Returns whether the instance-local game settings file cannot be overwritten safely.
|
||||
///
|
||||
/// @return whether the settings are loaded in read-only mode
|
||||
public boolean isSettingsReadOnly() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettingsReadOnly;
|
||||
}
|
||||
|
||||
/// Enables instance-local running-directory selection for this instance.
|
||||
///
|
||||
/// A blank local running directory resolves to the instance root. This operation is idempotent
|
||||
/// and schedules a settings save only when it adds the override. It leaves the instance
|
||||
/// unchanged when its local settings cannot be written safely.
|
||||
public void enableIsolation() {
|
||||
if (isSettingsReadOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
|
||||
if (setting != null
|
||||
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the settings file when this instance still owns its settings.
|
||||
public void forceOverwriteSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (!ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
if (setting == null) {
|
||||
setting = new GameSettings.Instance();
|
||||
gameSettings = setting;
|
||||
gameSettingsLoaded = true;
|
||||
}
|
||||
|
||||
boolean installAutoSave = !setting.isSavable();
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
setting.setSavable(true);
|
||||
setting.setBackupOnNextSave(false);
|
||||
gameSettingsReadOnly = false;
|
||||
saveSettings();
|
||||
if (installAutoSave) {
|
||||
setting.addListener(a -> saveSettings());
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the settings asynchronously when writable and still owned by the current instance.
|
||||
public void saveSettings() {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Saves the settings synchronously when writable and still owned by the current instance.
|
||||
///
|
||||
/// @throws IOException if saving the file fails
|
||||
public void saveSettingsSync() throws IOException {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Initializes this instance with the given settings object.
|
||||
///
|
||||
/// @param setting the settings to install
|
||||
/// @param allowSave whether the settings may be written back to disk
|
||||
/// @return the installed settings
|
||||
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
|
||||
normalizeRunningDirectoryOverride(setting);
|
||||
setting.setSavable(allowSave);
|
||||
gameSettingsLoaded = true;
|
||||
gameSettings = setting;
|
||||
setting.iconProperty().addListener(observable -> invalidateIconImage());
|
||||
if (allowSave) {
|
||||
gameSettingsReadOnly = false;
|
||||
setting.addListener(a -> saveSettings());
|
||||
} else {
|
||||
gameSettingsReadOnly = true;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
|
||||
/// the effective parent preset when no local settings exist.
|
||||
///
|
||||
/// @return a detached copy suitable for installing into another instance
|
||||
public GameSettings.Instance copySettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
if (setting != null) {
|
||||
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
|
||||
}
|
||||
|
||||
GameSettings.Instance copied = new GameSettings.Instance();
|
||||
copied.parentProperty().setValue(
|
||||
getEffectiveSettings().getPreset().idProperty().getValue());
|
||||
return copied;
|
||||
}
|
||||
|
||||
/// Returns the first custom icon file found in this instance's root directory.
|
||||
///
|
||||
/// @return the icon file, or empty when no supported icon file exists
|
||||
public @Nullable Path getIconFile() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
if (Files.exists(file)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Replaces this instance's custom icon file.
|
||||
///
|
||||
/// Existing supported icon files are removed before `iconFile` is copied.
|
||||
///
|
||||
/// @param iconFile the source icon file
|
||||
/// @throws IOException if the icon cannot be copied
|
||||
/// @throws IllegalArgumentException if the file extension is unsupported
|
||||
public void setIconFile(Path iconFile) throws IOException {
|
||||
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new IllegalArgumentException("Unsupported icon file: " + extension);
|
||||
}
|
||||
|
||||
clearIconFiles();
|
||||
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
/// Deletes all supported custom icon files for this instance.
|
||||
///
|
||||
/// Individual deletion failures are logged and do not stop later files from being attempted.
|
||||
public void deleteIconFile() {
|
||||
clearIconFiles();
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
private void clearIconFiles() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete instance icon file: " + file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft-cached icon image for this instance id.
|
||||
///
|
||||
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
|
||||
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
|
||||
private @Nullable WeakCachedIconImageProperty iconImage;
|
||||
|
||||
/// Returns the observable icon image for this instance.
|
||||
///
|
||||
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
|
||||
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
|
||||
/// pressure. The next [#getIconImage] reloads it.
|
||||
///
|
||||
/// @return the icon image property
|
||||
public ReadOnlyObjectProperty<Image> iconImageProperty() {
|
||||
if (iconImage == null) {
|
||||
iconImage = new WeakCachedIconImageProperty();
|
||||
}
|
||||
return iconImage;
|
||||
}
|
||||
|
||||
/// Returns the icon image selected for this instance.
|
||||
///
|
||||
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
public Image getIconImage() {
|
||||
return iconImageProperty().get();
|
||||
}
|
||||
|
||||
/// Drops the soft-cached icon image and notifies observers.
|
||||
public void invalidateIconImage() {
|
||||
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
|
||||
}
|
||||
|
||||
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
|
||||
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
|
||||
private @Nullable WeakReference<Image> cache;
|
||||
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return HMCLGameInstance.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "iconImage";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Image get() {
|
||||
WeakReference<Image> current = cache;
|
||||
Image image = current != null ? current.get() : null;
|
||||
if (image != null) {
|
||||
return image;
|
||||
}
|
||||
|
||||
image = computeIconImage();
|
||||
cache = new WeakReference<>(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
/// Computes the icon image from settings, custom files, and the launch manifest.
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
private Image computeIconImage() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
GameInstanceIconType iconType = setting != null
|
||||
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
|
||||
: GameInstanceIconType.DEFAULT;
|
||||
if (iconType != GameInstanceIconType.DEFAULT) {
|
||||
return iconType.getIcon();
|
||||
}
|
||||
|
||||
@Nullable Path iconFile = getIconFile();
|
||||
if (iconFile != null) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile, 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load instance icon for " + getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
for (ModLoaderType modLoader : getModLoaders()) {
|
||||
return GameInstanceIconType.getIconType(modLoader).getIcon();
|
||||
}
|
||||
|
||||
if (hasComponent(GameComponentType.OPTIFINE))
|
||||
return GameInstanceIconType.OPTIFINE.getIcon();
|
||||
|
||||
GameVersionNumber version = getVersion();
|
||||
if (version instanceof GameVersionNumber.LegacySnapshot
|
||||
|| version.isAprilFools()
|
||||
|| version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
|
||||
return GameInstanceIconType.COMMAND.getIcon();
|
||||
else if (version instanceof GameVersionNumber.Old)
|
||||
return GameInstanceIconType.CRAFT_TABLE.getIcon();
|
||||
else
|
||||
return GameInstanceIconType.GRASS.getIcon();
|
||||
}
|
||||
|
||||
/// Clears the weak cache and notifies listeners.
|
||||
void invalidate() {
|
||||
cache = null;
|
||||
fireValueChangedEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the marker indicating that the most recent launch ended abnormally.
|
||||
public void markLaunchedAbnormally() {
|
||||
try {
|
||||
Files.createFile(getInstanceRoot().resolve(".abnormal"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the abnormal-launch marker when present.
|
||||
///
|
||||
/// @return whether a regular marker file was present
|
||||
public boolean unmarkLaunchedAbnormally() {
|
||||
Path file = getInstanceRoot().resolve(".abnormal");
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Loads settings once while this object is the repository's current instance.
|
||||
private void ensureGameSettingsLoaded() {
|
||||
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
|
||||
loadGameSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this instance still owns the settings object used by the current snapshot.
|
||||
private boolean ownsGameSettings() {
|
||||
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
|
||||
boolean owns = current == this
|
||||
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
|
||||
if (!owns && gameSettings != null) {
|
||||
gameSettings.setSavable(false);
|
||||
}
|
||||
return owns;
|
||||
}
|
||||
|
||||
private void loadGameSettings() {
|
||||
gameSettingsLoaded = true;
|
||||
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
|
||||
if (result.setting() != null) {
|
||||
initSettings(result.setting(), result.allowSave());
|
||||
return;
|
||||
}
|
||||
if (!result.allowSave()) {
|
||||
gameSettingsReadOnly = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
|
||||
getRepository(), id, legacyParent);
|
||||
if (migrationResult != null) {
|
||||
initSettings(migrationResult.setting(), true);
|
||||
try {
|
||||
saveSettingsSync();
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path getGameSettingsFile() {
|
||||
return getLayout().getInstanceGameSettingsFile(id);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(
|
||||
JavaRuntime javaRuntime,
|
||||
Path javaExecutable,
|
||||
Path gameDir,
|
||||
List<String> javaAgents,
|
||||
List<String> javaArguments,
|
||||
boolean makeLaunchScript) {
|
||||
GameSettings.Effective vs = getEffectiveSettings();
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
GameVersionNumber gameVersionNumber = getVersion();
|
||||
|
||||
@Nullable Integer maxMemory;
|
||||
if (autoMemory) {
|
||||
maxMemory = noJVMOptions
|
||||
? null
|
||||
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||
SystemInfo.getPhysicalMemoryStatus(),
|
||||
javaRuntime.getPlatform()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setInstanceId(getId())
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaRuntime)
|
||||
.setJavaExecutable(javaExecutable)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(getId().id())
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
|
||||
.setMaxMemory(maxMemory)
|
||||
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
|
||||
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
|
||||
.setProxyOption(getProxyOption())
|
||||
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
|
||||
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
|
||||
.setNoGeneratedJVMArgs(noJVMOptions)
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
|
||||
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
|
||||
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
|
||||
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
|
||||
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
|
||||
.setRenderer(vs.getRenderer(gameVersionNumber))
|
||||
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
|
||||
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
|
||||
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
|
||||
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
|
||||
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
|
||||
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
|
||||
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
|
||||
if (quickPlayOption != null) {
|
||||
builder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
Path json = getModpackConfigurationFile();
|
||||
if (Files.exists(json)) {
|
||||
try {
|
||||
String jsonText = Files.readString(json);
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static ProxyOption getProxyOption() {
|
||||
return switch (settings().proxyTypeProperty().get()) {
|
||||
case SYSTEM -> ProxyOption.Default.INSTANCE;
|
||||
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||
case HTTP, SOCKS -> {
|
||||
String proxyHost = settings().proxyHostProperty().get();
|
||||
int proxyPort = settings().proxyPortProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||
yield ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
String proxyUser = settings().proxyUserProperty().get();
|
||||
String proxyPass = settings().proxyPasswordProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyUser)) {
|
||||
proxyUser = null;
|
||||
proxyPass = null;
|
||||
} else if (proxyPass == null) {
|
||||
proxyPass = "";
|
||||
}
|
||||
|
||||
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
|
||||
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
} else {
|
||||
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Loads a new-format instance game settings file.
|
||||
private static LoadResult loadGameSettingsFile(Path file) {
|
||||
if (!Files.exists(file)) {
|
||||
return new LoadResult(null, true);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning("Instance game settings are empty: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
|
||||
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setSavable(false);
|
||||
return new LoadResult(fallback, false);
|
||||
}
|
||||
|
||||
GameSettings.@Nullable Instance setting =
|
||||
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
|
||||
if (setting == null) {
|
||||
LOG.warning("Instance game settings deserialized to null: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
}
|
||||
return new LoadResult(setting, schemaResult.allowSave());
|
||||
} catch (JsonParseException ex) {
|
||||
LOG.warning("Failed to parse game setting " + file, ex);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load game setting " + file, ex);
|
||||
return new LoadResult(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps old local custom running directories effective under the new source-selection model.
|
||||
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
|
||||
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of loading an instance-specific game settings file.
|
||||
///
|
||||
/// @param setting the loaded instance settings, or `null` when unavailable
|
||||
/// @param allowSave whether the file may be overwritten
|
||||
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
|
||||
}
|
||||
|
||||
/// Optional reference to an HMCL game instance bound to a repository.
|
||||
///
|
||||
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
|
||||
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
|
||||
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
|
||||
/// from the current snapshot while preserving repository context.
|
||||
@NotNullByDefault
|
||||
public static final class Optional {
|
||||
private final HMCLGameRepository repository;
|
||||
private final @Nullable HMCLGameInstance instance;
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
public Optional(HMCLGameRepository repository) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.instance = null;
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
public Optional(HMCLGameInstance instance) {
|
||||
this.repository = instance.getRepository();
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @param instanceId the instance id, or `null` for an empty optional
|
||||
/// @return an optional that is empty when `instanceId` is null or not registered
|
||||
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
if (instanceId == null) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
HMCLGameInstance instance = repository.findInstance(instanceId);
|
||||
return instance != null ? new Optional(instance) : new Optional(repository);
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
/// @return the optional
|
||||
public static Optional of(HMCLGameInstance instance) {
|
||||
return new Optional(instance);
|
||||
}
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @return the empty optional
|
||||
public static Optional empty(HMCLGameRepository repository) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
|
||||
/// Returns the repository associated with this optional.
|
||||
///
|
||||
/// @return the repository
|
||||
public HMCLGameRepository repository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Returns the held instance, if any.
|
||||
///
|
||||
/// @return the instance, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable HMCLGameInstance instance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// Returns the held instance id, if any.
|
||||
///
|
||||
/// @return the instance id, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable GameInstanceID instanceId() {
|
||||
return instance != null ? instance.getId() : null;
|
||||
}
|
||||
|
||||
/// Returns whether an instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is non-null
|
||||
public boolean isPresent() {
|
||||
return instance != null;
|
||||
}
|
||||
|
||||
/// Returns whether no instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is null
|
||||
public boolean isEmpty() {
|
||||
return instance == null;
|
||||
}
|
||||
|
||||
/// Re-resolves the held instance id from the repository's current snapshot.
|
||||
///
|
||||
/// @return this optional when empty; otherwise a fresh optional for the same id
|
||||
public Optional refreshed() {
|
||||
if (instance == null) {
|
||||
return this;
|
||||
}
|
||||
return of(repository, instance.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -35,7 +20,6 @@ import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -43,16 +27,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
|
||||
this(repository, version, authInfo, options, null);
|
||||
/// Creates a launcher with daemon process monitors.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(instance, manifest, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(repository, version, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(repository, version, authInfo, options, listener, daemon);
|
||||
/// Creates a launcher for the given instance and launch plan.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
/// @param daemon whether monitors should be daemon threads
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(instance, manifest, authInfo, options, listener, daemon);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,10 +59,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
if (config().isDisableAutoGameOptions())
|
||||
if (options.isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(version.getId());
|
||||
Path runDir = instance.getRunDirectory();
|
||||
Path optionsFile = runDir.resolve("options.txt");
|
||||
Path configFolder = runDir.resolve("config");
|
||||
|
||||
@@ -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.13+ : zh_cn works fine, zh_CN will automatically switch to English
|
||||
*/
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
|
||||
if (gameVersion.compareTo("1.1") < 0)
|
||||
GameVersionNumber gameVersion = instance.getVersion();
|
||||
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
|
||||
return;
|
||||
|
||||
// These snapshots lack the external language resources needed for non-English locales.
|
||||
// Selecting an unavailable language causes a NullPointerException during startup.
|
||||
if (gameVersion instanceof GameVersionNumber.LegacySnapshot
|
||||
&& gameVersion.compareTo("13w16a") >= 0 && gameVersion.compareTo("13w23b") <= 0)
|
||||
return;
|
||||
|
||||
String lang = normalizedLanguageTag(locale, gameVersion);
|
||||
@@ -159,10 +160,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
protected void appendJvmArgs(CommandBuilder result) {
|
||||
super.appendJvmArgs(result);
|
||||
|
||||
if (config().getAllowAutoAgent()
|
||||
if (options.isAllowAutoAgent()
|
||||
&& !options.isNoGeneratedJVMArgs()
|
||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
|
||||
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
|
||||
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
||||
try {
|
||||
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
||||
@@ -179,9 +180,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||
String fileName = library.getArtifact().getFileName();
|
||||
String fileName = library.artifact().getFileName();
|
||||
|
||||
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
|
||||
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
|
||||
if (agentPath.toString().contains("=")) {
|
||||
throw new IOException("Invalid library path: " + agentPath);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
|
||||
/// Directory under the instance root that stores HMCL-managed instance metadata.
|
||||
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance state files.
|
||||
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||
|
||||
/// Current file name for instance-specific game settings.
|
||||
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||
|
||||
public HMCLGameRepositoryLayout(Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed metadata directory under the instance root.
|
||||
///
|
||||
/// This directory stores instance-scoped files owned by HMCL.
|
||||
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
|
||||
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed state directory under the instance metadata directory.
|
||||
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the current local game settings path under the instance configuration directory.
|
||||
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
|
||||
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
|
||||
/// Creates an empty unsealed HMCL snapshot.
|
||||
///
|
||||
/// @param repository the owning repository
|
||||
/// @param layout the HMCL layout for this snapshot
|
||||
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
|
||||
super(repository, layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameRepositorySnapshot newEmpty() {
|
||||
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public Collection<HMCLGameInstance> getInstances() {
|
||||
return (Collection<HMCLGameInstance>) super.getInstances();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,121 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.download.GameBuilder;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
|
||||
@NotNullByDefault
|
||||
public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
private final Path zipFile;
|
||||
private final String name;
|
||||
private final GameInstanceID instanceId;
|
||||
|
||||
/// Existing instance selecting update mode, or `null` for a new installation.
|
||||
private final @Nullable DefaultGameInstance updateTarget;
|
||||
|
||||
private final HMCLGameRepository repository;
|
||||
private final DefaultDependencyManager dependency;
|
||||
private final Modpack modpack;
|
||||
private final List<Task<?>> dependencies = new ArrayList<>(1);
|
||||
private final List<Task<?>> dependents = new ArrayList<>(4);
|
||||
|
||||
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
|
||||
dependency = profile.getDependency();
|
||||
repository = profile.getRepository();
|
||||
/// Creates a task that installs a new HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the id of the new instance
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId) {
|
||||
this(repository, zipFile, modpack, instanceId, null);
|
||||
}
|
||||
|
||||
/// Creates a task that updates an existing HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instance the existing instance to update
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
|
||||
/// modpack configuration, or records another provider type
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
DefaultGameInstance instance) {
|
||||
this(repository, zipFile, modpack, instance.getId(), instance);
|
||||
}
|
||||
|
||||
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the target instance id
|
||||
/// @param updateTarget the existing instance selecting update mode, or `null` for install
|
||||
/// @throws IllegalArgumentException if an update target belongs to another repository or has
|
||||
/// no compatible configuration
|
||||
private HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable DefaultGameInstance updateTarget) {
|
||||
this.repository = repository;
|
||||
this.dependency = repository.getDependency();
|
||||
this.zipFile = zipFile;
|
||||
this.name = name;
|
||||
this.instanceId = instanceId;
|
||||
this.updateTarget = updateTarget;
|
||||
this.modpack = modpack;
|
||||
if (this.updateTarget != null) {
|
||||
dependency.validateGameInstance(this.updateTarget);
|
||||
}
|
||||
|
||||
Path run = repository.getRunDirectory(name);
|
||||
Path json = repository.getModpackConfiguration(name);
|
||||
if (repository.hasVersion(name) && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Version " + name + " already exists");
|
||||
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
|
||||
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
|
||||
if (this.updateTarget != null && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
|
||||
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
|
||||
|
||||
onDone().register(event -> {
|
||||
if (event.isFailed()) repository.removeVersionFromDisk(name);
|
||||
});
|
||||
|
||||
ModpackConfiguration<Modpack> config = null;
|
||||
@Nullable ModpackConfiguration<Modpack> config = null;
|
||||
try {
|
||||
if (Files.exists(json)) {
|
||||
if (this.updateTarget != null && Files.exists(json)) {
|
||||
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
|
||||
|
||||
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
|
||||
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
}
|
||||
} catch (JsonParseException | IOException ignore) {
|
||||
}
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
|
||||
|
||||
onDone().register(event -> {
|
||||
if (this.updateTarget == null && event.isFailed()) {
|
||||
repository.removeInstanceFromDisk(this.instanceId);
|
||||
}
|
||||
});
|
||||
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, 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
|
||||
@@ -87,20 +128,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
|
||||
///
|
||||
/// The pack manifest is used only for component detection. The builder creates a new
|
||||
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
|
||||
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
|
||||
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
|
||||
// reinstall libraries
|
||||
// libraries of Forge and OptiFine should be obtained by installation.
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
|
||||
continue;
|
||||
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
|
||||
}
|
||||
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
|
||||
.withId(instanceId)
|
||||
.withJar(null);
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
|
||||
|
||||
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
|
||||
try (GameBuilder builder = updateTarget == null
|
||||
? dependency.newGameBuilder(instanceId)
|
||||
: dependency.newGameBuilder(updateTarget)) {
|
||||
builder.enableIsolation();
|
||||
|
||||
String gameVersion = modpack.getGameVersion();
|
||||
builder.component(GameComponentType.GAME, gameVersion);
|
||||
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||
if (mark.componentType() == GameComponentType.GAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable String componentVersion = mark.version();
|
||||
if (componentVersion != null) {
|
||||
if (mark.componentType() == GameComponentType.OPTIFINE) {
|
||||
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
|
||||
if (matcher.matches()) {
|
||||
componentVersion = matcher.group("optifine");
|
||||
}
|
||||
}
|
||||
builder.component(mark.componentType(), componentVersion);
|
||||
}
|
||||
}
|
||||
dependencies.add(builder.buildAsync());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +1,9 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.mod.ModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
|
||||
public final class HMCLModpackManifest implements ModpackManifest {
|
||||
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2022 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
public final class HMCLModpackProvider implements ModpackProvider {
|
||||
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
||||
@@ -43,12 +28,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
|
||||
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
|
||||
public Task<?> createUpdateTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
DefaultGameInstance instance,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
|
||||
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||
|
||||
@@ -56,31 +46,34 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||
}
|
||||
|
||||
Profile profile = repository.getProfile();
|
||||
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
|
||||
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
|
||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
|
||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
|
||||
if (game.getJar() == null)
|
||||
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||
if (game.jar() == null)
|
||||
if (StringUtils.isBlank(manifest.getVersion()))
|
||||
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE);
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
|
||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
||||
public Task<?> getInstallTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
@@ -25,21 +10,21 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.MaintainTask;
|
||||
import org.jackhuang.hmcl.download.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.launch.*;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
@@ -47,55 +32,63 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static javafx.application.Platform.runLater;
|
||||
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.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
|
||||
|
||||
public final class LauncherHelper {
|
||||
|
||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||
|
||||
private final Profile profile;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private Account account;
|
||||
private final String selectedVersion;
|
||||
private Path scriptFile;
|
||||
private final VersionSetting setting;
|
||||
private final GameSettings.Effective setting;
|
||||
private LauncherVisibility launcherVisibility;
|
||||
private boolean showLogs;
|
||||
private QuickPlayOption quickPlayOption;
|
||||
private boolean disableOfflineSkin = false;
|
||||
|
||||
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
|
||||
this.profile = Objects.requireNonNull(profile);
|
||||
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
|
||||
this.gameInstance = Objects.requireNonNull(gameInstance);
|
||||
this.account = Objects.requireNonNull(account);
|
||||
this.selectedVersion = Objects.requireNonNull(selectedVersion);
|
||||
this.setting = profile.getVersionSetting(selectedVersion);
|
||||
this.launcherVisibility = setting.getLauncherVisibility();
|
||||
this.showLogs = setting.isShowLogs();
|
||||
this.launchingStepsPane.setTitle(i18n("version.launch"));
|
||||
this.setting = gameInstance.getEffectiveSettings();
|
||||
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
|
||||
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
|
||||
this.launchingStepsPane.setTitle(i18n("instance.launch"));
|
||||
}
|
||||
|
||||
public HMCLGameInstance getGameInstance() {
|
||||
return gameInstance;
|
||||
}
|
||||
|
||||
private HMCLGameRepository repository() {
|
||||
return gameInstance.getRepository();
|
||||
}
|
||||
|
||||
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
|
||||
@@ -128,7 +121,7 @@ public final class LauncherHelper {
|
||||
public void launch() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
LOG.info("Launching game version: " + selectedVersion);
|
||||
LOG.info("Launching game instance: " + gameInstance.getId());
|
||||
|
||||
Controllers.dialog(launchingStepsPane);
|
||||
launch0();
|
||||
@@ -139,49 +132,72 @@ public final class LauncherHelper {
|
||||
launch();
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
@SuppressWarnings("NonAtomicOperationOnVolatileField")
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
DefaultDependencyManager dependencyManager = profile.getDependency();
|
||||
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
|
||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
||||
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
|
||||
final var context = new Object() {
|
||||
volatile GameInstanceManifest launchManifest;
|
||||
volatile JavaRuntime javaRuntime;
|
||||
volatile Path javaExecutable;
|
||||
};
|
||||
|
||||
HMCLGameRepository repository = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||
List<String> javaAgents = new ArrayList<>(0);
|
||||
List<String> javaArguments = new ArrayList<>(0);
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
context.launchManifest = LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest());
|
||||
|
||||
TaskExecutor executor = checkGameState(profile, setting, version.get())
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, context.launchManifest)
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
|
||||
if (setting.isNotCheckGame())
|
||||
context.javaRuntime = Objects.requireNonNull(java);
|
||||
context.javaExecutable = java.getBinary();
|
||||
if (java.getPlatform().os() == OperatingSystem.WINDOWS
|
||||
&& scriptFile == null
|
||||
&& !setting.getInheritable(GameSettings::notUseJavawProperty)
|
||||
&& FileUtils.getName(java.getBinary()).equalsIgnoreCase("java.exe")) {
|
||||
Path javawBinary = java.getBinary().resolveSibling("javaw.exe");
|
||||
if (Files.isRegularFile(javawBinary)) {
|
||||
context.javaExecutable = javawBinary;
|
||||
}
|
||||
}
|
||||
|
||||
context.launchManifest = NativePatcher.patchNative(gameInstance, context.launchManifest, java, setting, javaArguments);
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, context.launchManifest, integrityCheck),
|
||||
Task.composeAsync(() -> {
|
||||
try {
|
||||
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
|
||||
@Nullable ModpackConfiguration<?> configuration =
|
||||
gameInstance.readModpackConfiguration();
|
||||
if (configuration == null) return null;
|
||||
@Nullable ModpackProvider provider =
|
||||
ModpackHelper.getProviderByType(configuration.getType());
|
||||
if (provider == null) return null;
|
||||
else return provider.createCompletionTask(dependencyManager, selectedVersion);
|
||||
else return provider.createCompletionTask(
|
||||
dependencyManager,
|
||||
gameInstance);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer() instanceof Renderer.Driver renderer)
|
||||
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|
||||
|| renderer.mesaDriverName() == null)
|
||||
return null;
|
||||
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
@@ -189,7 +205,7 @@ public final class LauncherHelper {
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, context.launchManifest, lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
@@ -199,23 +215,23 @@ public final class LauncherHelper {
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), context.launchManifest))
|
||||
.thenComposeAsync(() -> {
|
||||
if (config().getAllowAutoAgent()
|
||||
|| setting.isNoJVMArgs()
|
||||
|| setting.isNoOptimizingJVMArgs()
|
||||
|| Boolean.TRUE.equals(config().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(context.launchManifest, context.javaRuntime.getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
config().setAllowAutoAgent(true);
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
enableAutoAgentForCurrentSetting();
|
||||
future.complete(null);
|
||||
}, () -> {
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
future.complete(null);
|
||||
});
|
||||
});
|
||||
@@ -224,8 +240,13 @@ public final class LauncherHelper {
|
||||
})
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
context.javaRuntime,
|
||||
context.javaExecutable,
|
||||
repository.getBaseDirectory(),
|
||||
javaAgents,
|
||||
javaArguments,
|
||||
scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -233,18 +254,48 @@ public final class LauncherHelper {
|
||||
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(context.javaExecutable);
|
||||
try {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg != null) {
|
||||
Object current = reg.queryValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg
|
||||
);
|
||||
if (current == null) {
|
||||
boolean result = reg.setValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg,
|
||||
"GpuPreference=2;"
|
||||
);
|
||||
if (result)
|
||||
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
|
||||
else
|
||||
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
|
||||
} else {
|
||||
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to apply high performance GPU preference", e);
|
||||
}
|
||||
}
|
||||
|
||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
repository,
|
||||
version.get(),
|
||||
gameInstance,
|
||||
context.launchManifest,
|
||||
authInfo,
|
||||
launchOptions,
|
||||
launcherVisibility == LauncherVisibility.CLOSE
|
||||
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
|
||||
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
|
||||
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
|
||||
);
|
||||
}).thenComposeAsync(launcher -> { // launcher is prev task's result
|
||||
if (scriptFile == null) {
|
||||
@@ -261,14 +312,14 @@ public final class LauncherHelper {
|
||||
if (launcherVisibility == LauncherVisibility.CLOSE)
|
||||
Launcher.stopApplication();
|
||||
else
|
||||
launchingStepsPane.setCancel(new TaskCancellationAction(it -> {
|
||||
launchingStepsPane.setCancel(it -> {
|
||||
process.stop();
|
||||
it.fireEvent(new DialogCloseEvent());
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
runLater(() -> {
|
||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
||||
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
});
|
||||
}
|
||||
}).withFakeProgress(
|
||||
@@ -307,31 +358,29 @@ public final class LauncherHelper {
|
||||
} else if (ex instanceof NotDecompressingNativesException) {
|
||||
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
||||
} else if (ex instanceof LibraryDownloadException) {
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", uri);
|
||||
message += i18n("download.code.404", url);
|
||||
else
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
} else if (ex instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
message = i18n("install.failed.downloading.timeout", url);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||
message = i18n("assets.index.malformed");
|
||||
@@ -339,14 +388,13 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.injector_download_failure");
|
||||
} else if (ex instanceof CharacterDeletedException) {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message = i18n("download.code.404", uri);
|
||||
message = i18n("download.code.404", url);
|
||||
else
|
||||
message = i18n("download.failed", uri, responseCode);
|
||||
message = i18n("download.failed", url, responseCode);
|
||||
} else if (ex instanceof CommandTooLongException) {
|
||||
message = i18n("launch.failed.command_too_long");
|
||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||
@@ -370,7 +418,7 @@ public final class LauncherHelper {
|
||||
message = StringUtils.getStackTrace(ex);
|
||||
}
|
||||
Controllers.dialog(message,
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
|
||||
MessageType.ERROR);
|
||||
}
|
||||
}
|
||||
@@ -380,24 +428,26 @@ public final class LauncherHelper {
|
||||
}
|
||||
});
|
||||
|
||||
executor.start();
|
||||
launchingStepsPane.startExecutor();
|
||||
}
|
||||
|
||||
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
|
||||
/// Checks launch requirements, allowing required Java downloads to share this launch's execution slot.
|
||||
private Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
|
||||
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||
|
||||
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
||||
try {
|
||||
return setting.getJava(gameVersion, version);
|
||||
return setting.getJava(gameVersion, manifest);
|
||||
} catch (InterruptedException e) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
});
|
||||
Task<JavaRuntime> task;
|
||||
if (setting.isNotCheckJVM()) {
|
||||
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
|
||||
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
|
||||
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
|
||||
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
|
||||
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
|
||||
if (java != null) {
|
||||
return Task.completed(java);
|
||||
@@ -410,14 +460,14 @@ public final class LauncherHelper {
|
||||
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
||||
|
||||
GameJavaVersion targetJavaVersion = null;
|
||||
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
try {
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
|
||||
GameJavaVersion minimumJavaVersion = null;
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,18 +489,18 @@ public final class LauncherHelper {
|
||||
}
|
||||
} else {
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetJavaVersion == null)
|
||||
targetJavaVersion = version.getJavaVersion();
|
||||
targetJavaVersion = manifest.javaVersion();
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||
downloadJava(targetJavaVersion, profile)
|
||||
downloadJava(targetJavaVersion, gameInstance.getRepository())
|
||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(downloadedJava);
|
||||
@@ -476,8 +526,8 @@ public final class LauncherHelper {
|
||||
|
||||
if (java != null) {
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violatedMandatoryConstraints.add(constraint);
|
||||
} else {
|
||||
@@ -493,7 +543,7 @@ public final class LauncherHelper {
|
||||
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
|
||||
|
||||
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
|
||||
if (suggestedJava != null) {
|
||||
FXUtils.runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
|
||||
@@ -513,21 +563,20 @@ public final class LauncherHelper {
|
||||
} else {
|
||||
GameJavaVersion gameJavaVersion;
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
|
||||
.orElse("");
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
|
||||
gameJavaVersion = !cleanroomVersion.isEmpty()
|
||||
gameJavaVersion = cleanroomVersion != null
|
||||
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
||||
: GameJavaVersion.JAVA_21;
|
||||
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
gameJavaVersion = version.getJavaVersion();
|
||||
gameJavaVersion = manifest.javaVersion();
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
||||
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
else
|
||||
gameJavaVersion = null;
|
||||
|
||||
if (gameJavaVersion != null) {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
if (throwable == null) {
|
||||
setting.setJavaAutoSelected();
|
||||
future.complete(downloadedJava);
|
||||
@@ -540,7 +589,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
|
||||
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
|
||||
return result;
|
||||
} else {
|
||||
@@ -577,7 +626,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
// 32-bit JVM cannot make use of too much memory.
|
||||
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
// 1.5 * 1024 is an inaccurate number.
|
||||
// Actual memory limit depends on operating system and memory.
|
||||
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
|
||||
@@ -597,7 +646,7 @@ public final class LauncherHelper {
|
||||
break;
|
||||
case MODDED_JAVA_16:
|
||||
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
|
||||
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
|
||||
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
|
||||
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
|
||||
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
||||
else
|
||||
@@ -610,8 +659,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||
break;
|
||||
case CLEANROOM: {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
|
||||
if (!cleanroomVersion.isEmpty())
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null)
|
||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||
else
|
||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||
@@ -624,8 +673,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
@@ -636,24 +685,24 @@ public final class LauncherHelper {
|
||||
|
||||
// Cannot allocate too much memory exceeding free space.
|
||||
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
|
||||
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
|
||||
}
|
||||
|
||||
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
|
||||
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
|
||||
.map(VersionNumber::asVersion)
|
||||
.orElse(null);
|
||||
|
||||
// Forge 2760~2773 will crash game with LiteLoader.
|
||||
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
|
||||
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
|
||||
}
|
||||
|
||||
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
|
||||
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
|
||||
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
|
||||
}
|
||||
@@ -685,14 +734,19 @@ public final class LauncherHelper {
|
||||
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<>();
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
||||
i18n("message.warning"),
|
||||
MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
|
||||
if (!launchingStepsPane.canStartChild()) {
|
||||
future.completeExceptionally(new CancellationException());
|
||||
return;
|
||||
}
|
||||
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
|
||||
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
@@ -701,11 +755,11 @@ public final class LauncherHelper {
|
||||
Throwable resolvedException = resolveException(exception);
|
||||
LOG.warning("Failed to download java", exception);
|
||||
if (!(resolvedException instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
}
|
||||
future.completeExceptionally(new CancellationException());
|
||||
}
|
||||
}), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException())));
|
||||
}), i18n("download.java"), it -> future.completeExceptionally(new CancellationException()), launchingStepsPane);
|
||||
}, () -> future.completeExceptionally(new CancellationException())).build());
|
||||
|
||||
return future;
|
||||
@@ -756,7 +810,10 @@ public final class LauncherHelper {
|
||||
case HIDE_AND_REOPEN:
|
||||
runLater(() -> {
|
||||
Optional.ofNullable(Controllers.getStage())
|
||||
.ifPresent(Stage::show);
|
||||
.ifPresent(stage -> {
|
||||
stage.setIconified(false);
|
||||
stage.show();
|
||||
});
|
||||
});
|
||||
break;
|
||||
case KEEP:
|
||||
@@ -775,16 +832,22 @@ public final class LauncherHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed process listener.
|
||||
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
|
||||
* Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
*/
|
||||
private void enableAutoAgentForCurrentSetting() {
|
||||
GameSettings.Instance instance = setting.getInstance();
|
||||
if (instance != null
|
||||
&& instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) {
|
||||
instance.allowAutoAgentProperty().setValue(true);
|
||||
} else {
|
||||
setting.getPreset().allowAutoAgentProperty().setValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The managed process listener.
|
||||
/// Guarantee that one Java [Process], one [HMCLProcessListener].
|
||||
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
private final class HMCLProcessListener implements ProcessListener {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final HMCLGameRepository repository;
|
||||
private final Version version;
|
||||
private final LaunchOptions launchOptions;
|
||||
private ManagedProcess process;
|
||||
private volatile boolean lwjgl;
|
||||
@@ -796,9 +859,7 @@ public final class LauncherHelper {
|
||||
private Thread submitLogThread;
|
||||
private LinkedBlockingQueue<Log> logBuffer;
|
||||
|
||||
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.repository = repository;
|
||||
this.version = version;
|
||||
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.launchOptions = launchOptions;
|
||||
this.launchingLatch = launchingLatch;
|
||||
this.detectWindow = detectWindow;
|
||||
@@ -881,8 +942,10 @@ public final class LauncherHelper {
|
||||
runLater(() -> {
|
||||
// If application was stopped and execution services did not finish termination,
|
||||
// these codes will be executed.
|
||||
if (Controllers.getStage() != null) {
|
||||
Controllers.getStage().hide();
|
||||
@Nullable Stage stage = Controllers.getStage();
|
||||
if (stage != null) {
|
||||
stage.setIconified(false);
|
||||
stage.hide();
|
||||
launchingLatch.countDown();
|
||||
}
|
||||
});
|
||||
@@ -923,7 +986,7 @@ public final class LauncherHelper {
|
||||
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
||||
if (showLogs) {
|
||||
if (level == null)
|
||||
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
logBuffer.add(new Log(log, level));
|
||||
} else {
|
||||
lock.lock();
|
||||
@@ -955,7 +1018,7 @@ public final class LauncherHelper {
|
||||
@Override
|
||||
public void onExit(int exitCode, ExitType exitType) {
|
||||
if (showLogs) {
|
||||
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
submitLogThread.interrupt();
|
||||
try {
|
||||
submitLogThread.join();
|
||||
@@ -981,8 +1044,8 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (exitType != ExitType.NORMAL) {
|
||||
repository.markVersionLaunchedAbnormally(version.getId());
|
||||
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
|
||||
gameInstance.markLaunchedAbnormally();
|
||||
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||
}
|
||||
|
||||
checkExit();
|
||||
|
||||
@@ -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,131 +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.LocalModFile;
|
||||
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 HashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
||||
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
|
||||
if (englishSearchFiltersSet.contains(englishWord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
englishSearchFiltersSet.add(englishWord);
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count >= 3) break;
|
||||
}
|
||||
|
||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||
{
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
|
||||
if (chineseIndex > englishIndex) {
|
||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoteMod remoteMod = iterator.next();
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
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(LocalModFile localModFile, Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, 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,37 +1,21 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
|
||||
public final class Log {
|
||||
public static final int DEFAULT_LOG_LINES = 2000;
|
||||
|
||||
public static int getLogLines() {
|
||||
Integer lines = config().getLogLines();
|
||||
Integer lines = settings().logLinesProperty().get();
|
||||
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
||||
}
|
||||
|
||||
private final String log;
|
||||
private Log4jLevel level;
|
||||
private boolean selected = false;
|
||||
|
||||
public Log(String log) {
|
||||
this.log = log;
|
||||
@@ -57,14 +41,6 @@ public final class Log {
|
||||
return level;
|
||||
}
|
||||
|
||||
public boolean isSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return log;
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -42,22 +30,24 @@ public final class LogExporter {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> exportLogs(
|
||||
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
|
||||
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
|
||||
PathMatcher logMatcher) {
|
||||
Path runDirectory = gameRepository.getRunDirectory(versionId);
|
||||
Path baseDirectory = gameRepository.getBaseDirectory();
|
||||
List<String> versions = new ArrayList<>();
|
||||
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
|
||||
Path runDirectory = options.getGameDir();
|
||||
List<GameInstanceID> instances = new ArrayList<>();
|
||||
|
||||
String currentVersionId = versionId;
|
||||
HashSet<String> resolvedSoFar = new HashSet<>();
|
||||
GameInstanceID currentInstanceId = instance.id;
|
||||
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
|
||||
while (true) {
|
||||
if (resolvedSoFar.contains(currentVersionId)) break;
|
||||
resolvedSoFar.add(currentVersionId);
|
||||
Version currentVersion = gameRepository.getVersion(currentVersionId);
|
||||
versions.add(currentVersionId);
|
||||
if (resolvedSoFar.contains(currentInstanceId)) break;
|
||||
resolvedSoFar.add(currentInstanceId);
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
|
||||
if (currentInstance == null)
|
||||
break;
|
||||
instances.add(currentInstanceId);
|
||||
|
||||
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
|
||||
currentVersionId = currentVersion.getInheritsFrom();
|
||||
if (currentInstance.getManifest().inheritsFrom() != null) {
|
||||
currentInstanceId = currentInstance.getManifest().inheritsFrom();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -74,10 +64,12 @@ public final class LogExporter {
|
||||
zipper.putTextFile(logs, "minecraft.log");
|
||||
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
||||
|
||||
for (String id : versions) {
|
||||
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
|
||||
if (Files.exists(versionJson)) {
|
||||
zipper.putFile(versionJson, id + ".json");
|
||||
for (GameInstanceID id : instances) {
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
|
||||
if (currentInstance != null) {
|
||||
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
|
||||
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@@ -1,61 +1,38 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Profile profile;
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
|
||||
this.profile = profile;
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
Path subdirectory;
|
||||
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
|
||||
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
|
||||
}
|
||||
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
|
||||
|
||||
Path dest = Paths.get("externalgames").resolve(name);
|
||||
|
||||
setResult(dest);
|
||||
|
||||
new Unzipper(zipFile, dest)
|
||||
.setSubDirectory(subdirectory.toString())
|
||||
.setSubDirectory(subdirectory)
|
||||
.setTerminateIfSubDirectoryNotExists()
|
||||
.setEncoding(charset)
|
||||
.unzip();
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,38 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.mod.*;
|
||||
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.modpack.*;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -55,12 +40,13 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.toIterable;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/// Utilities for reading, installing, and applying modpack-specific game settings.
|
||||
@NotNullByDefault
|
||||
public final class ModpackHelper {
|
||||
private ModpackHelper() {
|
||||
}
|
||||
@@ -75,7 +61,7 @@ public final class ModpackHelper {
|
||||
);
|
||||
|
||||
static {
|
||||
MultiMCComponents.setImplementation(Metadata.FULL_TITLE);
|
||||
MultiMCComponents.setImplementation(Metadata.FULL_NAME + " v" + Metadata.VERSION);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -88,8 +74,8 @@ public final class ModpackHelper {
|
||||
return "zip".equals(ext) || "mrpack".equals(ext);
|
||||
}
|
||||
|
||||
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
|
||||
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
|
||||
// Order for trying detecting manifest is necessary here.
|
||||
// Do not change to iterating providers.
|
||||
for (ModpackProvider provider : new ModpackProvider[]{
|
||||
@@ -100,15 +86,15 @@ public final class ModpackHelper {
|
||||
MultiMCModpackProvider.INSTANCE,
|
||||
ServerModpackProvider.INSTANCE}) {
|
||||
try {
|
||||
return provider.readManifest(zipFile, file, charset);
|
||||
return provider.readManifest(zipFile, file);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
||||
try {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||
throw new ManuallyCreatedModpackException(file);
|
||||
} catch (IOException e) {
|
||||
// ignore it
|
||||
@@ -117,28 +103,35 @@ public final class ModpackHelper {
|
||||
throw new UnsupportedModpackException(file.toString());
|
||||
}
|
||||
|
||||
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
|
||||
Path root = fs.getPath("/");
|
||||
if (isMinecraftDirectory(root)) return root;
|
||||
try (Stream<Path> firstLayer = Files.list(root)) {
|
||||
for (Path dir : toIterable(firstLayer)) {
|
||||
if (isMinecraftDirectory(dir)) return dir;
|
||||
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
|
||||
throws IOException, UnsupportedModpackException {
|
||||
|
||||
try (Stream<Path> secondLayer = Files.list(dir)) {
|
||||
for (Path subdir : toIterable(secondLayer)) {
|
||||
if (isMinecraftDirectory(subdir)) return subdir;
|
||||
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
|
||||
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
|
||||
|
||||
if (isMinecraftDirectory(rootDir)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(firstLayer)) {
|
||||
return firstLayer.getName();
|
||||
}
|
||||
|
||||
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(secondLayer)) {
|
||||
return firstLayer.getName() + "/" + secondLayer.getName();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
throw new UnsupportedModpackException(modpackName);
|
||||
}
|
||||
|
||||
private static boolean isMinecraftDirectory(Path path) {
|
||||
return Files.isDirectory(path.resolve("versions")) &&
|
||||
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
|
||||
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
|
||||
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
|
||||
}
|
||||
|
||||
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
|
||||
@@ -149,16 +142,10 @@ public final class ModpackHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -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)
|
||||
.withStage("hmcl.modpack")
|
||||
.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));
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
Profile newProfile = new Profile(name, location);
|
||||
newProfile.setUseRelativePath(true);
|
||||
Profiles.getProfiles().add(newProfile);
|
||||
Profiles.setSelectedProfile(newProfile);
|
||||
});
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
LocalizedText.plain(name),
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
})
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints("hmcl.modpack");
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
|
||||
}
|
||||
|
||||
/// Creates an install task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the new instance id
|
||||
/// @param modpack the parsed modpack
|
||||
/// @param iconUrl the optional icon URL, or `null`
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the install task
|
||||
public static Task<?> getInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
Modpack modpack,
|
||||
@Nullable String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -211,114 +218,181 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(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()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||
return new ModpackUpdateTask(
|
||||
instance,
|
||||
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the update task
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
|
||||
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
vs.setUsesGlobal(false);
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
if (c.isOverrideJavaLocation()) {
|
||||
vs.setJavaDir(Lang.nonNull(c.getJavaPath(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE);
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_CUSTOM_JAVA_PATH);
|
||||
setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM);
|
||||
setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideMemory()) {
|
||||
vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY
|
||||
));
|
||||
setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
if (c.getMaxMemory() != null)
|
||||
vs.setMaxMemory(c.getMaxMemory());
|
||||
vs.setMinMemory(c.getMinMemory());
|
||||
setting.maxMemoryProperty().setValue(c.getMaxMemory());
|
||||
setting.minMemoryProperty().setValue(c.getMinMemory());
|
||||
}
|
||||
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideConsole()) {
|
||||
vs.setShowLogs(c.isShowConsole());
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
|
||||
setting.showLogsProperty().setValue(c.isShowConsole());
|
||||
}
|
||||
|
||||
if (c.isOverrideWindow()) {
|
||||
vs.setFullscreen(c.isFullscreen());
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_WINDOW_TYPE,
|
||||
GameSettings.PROPERTY_WIDTH,
|
||||
GameSettings.PROPERTY_HEIGHT
|
||||
));
|
||||
setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
|
||||
if (c.getWidth() != null)
|
||||
vs.setWidth(c.getWidth());
|
||||
setting.widthProperty().setValue(c.getWidth().doubleValue());
|
||||
if (c.getHeight() != null)
|
||||
vs.setHeight(c.getHeight());
|
||||
setting.heightProperty().setValue(c.getHeight().doubleValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.toVersionSetting(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.toGameSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
|
||||
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
|
||||
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
|
||||
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
|
||||
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE
|
||||
));
|
||||
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
|
||||
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
|
||||
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
|
||||
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD;
|
||||
@@ -30,7 +15,10 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@@ -50,7 +38,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
private String idToken;
|
||||
|
||||
private OAuthServer(int port) {
|
||||
super(port);
|
||||
super("localhost", port);
|
||||
|
||||
this.port = port;
|
||||
|
||||
@@ -143,7 +131,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
String html;
|
||||
try {
|
||||
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
|
||||
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||
.replace("%style%", Themes.getColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||
.replace("%lang%", Locale.getDefault().toLanguageTag())
|
||||
.replace("%success%", i18n("message.success"))
|
||||
.replace("%ok%", i18n("button.ok"))
|
||||
@@ -172,6 +160,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
|
||||
public static class Factory implements OAuth.Callback {
|
||||
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
|
||||
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
|
||||
|
||||
@@ -199,6 +188,11 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loginCompletedDeviceCode() {
|
||||
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
@@ -235,6 +229,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoginCompletedDeviceCodeEvent extends Event {
|
||||
public LoginCompletedDeviceCodeEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
|
||||
public static class OpenBrowserEvent extends Event {
|
||||
private final String url;
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Resolves the routes that the one-click join button can connect through.
|
||||
@NotNullByDefault
|
||||
public final class ServerEndpoint {
|
||||
private ServerEndpoint() {
|
||||
}
|
||||
|
||||
/// One selectable connection route.
|
||||
///
|
||||
/// @param name the display name
|
||||
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
|
||||
public record Route(String name, String address) {
|
||||
}
|
||||
|
||||
/// Lists the routes configured for an instance.
|
||||
///
|
||||
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
|
||||
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
|
||||
/// used, each as a single route.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @return the routes in display order; empty when nothing is configured
|
||||
public static @Unmodifiable List<Route> routes(Path configurationFile) {
|
||||
List<Route> result = new ArrayList<>();
|
||||
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
|
||||
if (manifest != null) {
|
||||
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
|
||||
if (StringUtils.isNotBlank(route.getAddress())) {
|
||||
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
|
||||
}
|
||||
}
|
||||
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
|
||||
result.add(new Route("default", manifest.getServerAddress().trim()));
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
String fallback = Metadata.getDefaultServerAddress();
|
||||
if (!fallback.isEmpty()) {
|
||||
result.add(new Route("default", fallback));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @return the route to connect through, or `null` when nothing is configured
|
||||
public static @Nullable Route selected(Path configurationFile) {
|
||||
List<Route> routes = routes(configurationFile);
|
||||
if (routes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@Nullable String saved = readSelectedName(configurationFile);
|
||||
for (Route route : routes) {
|
||||
if (route.name().equals(saved)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return routes.get(0);
|
||||
}
|
||||
|
||||
/// Remembers the player's route choice next to the instance's modpack configuration.
|
||||
///
|
||||
/// @param configurationFile the instance's `modpack.json` path
|
||||
/// @param route the chosen route
|
||||
public static void select(Path configurationFile, Route route) {
|
||||
try {
|
||||
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to save the selected route", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path selectionFile(Path configurationFile) {
|
||||
return configurationFile.resolveSibling("server-route.txt");
|
||||
}
|
||||
|
||||
private static @Nullable String readSelectedName(Path configurationFile) {
|
||||
try {
|
||||
Path file = selectionFile(configurationFile);
|
||||
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
|
||||
if (!Files.isRegularFile(configurationFile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ModpackConfiguration<ServerModpackManifest> configuration =
|
||||
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
|
||||
return configuration != null ? configuration.getManifest() : null;
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Unable to read server routes from " + configurationFile, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
@@ -33,6 +18,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.Skin;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -45,7 +31,10 @@ import java.io.InputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -65,29 +54,18 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
// ==== Texture Loading ====
|
||||
public static class LoadedTexture {
|
||||
private final Image image;
|
||||
private final Map<String, String> metadata;
|
||||
|
||||
public LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
this.image = requireNonNull(image);
|
||||
this.metadata = requireNonNull(metadata);
|
||||
}
|
||||
|
||||
public Image getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata;
|
||||
public record LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
public LoadedTexture {
|
||||
requireNonNull(image);
|
||||
requireNonNull(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins");
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_USER_HOME.resolve("skins");
|
||||
|
||||
private static Path getTexturePath(Texture texture) {
|
||||
String url = texture.getUrl();
|
||||
String url = texture.url();
|
||||
int slash = url.lastIndexOf('/');
|
||||
int dot = url.lastIndexOf('.');
|
||||
if (dot < slash) {
|
||||
@@ -99,7 +77,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
|
||||
if (StringUtils.isBlank(texture.getUrl())) {
|
||||
if (StringUtils.isBlank(texture.url())) {
|
||||
throw new IOException("Texture url is empty");
|
||||
}
|
||||
|
||||
@@ -107,14 +85,14 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(texture.getUrl(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.getUrl());
|
||||
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.url());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
// concurrency conflict?
|
||||
LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e);
|
||||
LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e);
|
||||
} else {
|
||||
throw new IOException("Failed to download texture " + texture.getUrl());
|
||||
throw new IOException("Failed to download texture " + texture.url());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +105,7 @@ public final class TexturesLoader {
|
||||
if (img.isError())
|
||||
throw img.getException();
|
||||
|
||||
Map<String, String> metadata = texture.getMetadata();
|
||||
Map<String, String> metadata = texture.metadata();
|
||||
if (metadata == null) {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
@@ -158,7 +136,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static TextureModel getDefaultModel(UUID uuid) {
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model"))
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model"))
|
||||
? TextureModel.WIDE
|
||||
: TextureModel.SLIM;
|
||||
}
|
||||
@@ -176,7 +154,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
})
|
||||
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
|
||||
.filter(it -> StringUtils.isNotBlank(it.getUrl())))
|
||||
.filter(it -> StringUtils.isNotBlank(it.url())))
|
||||
.asyncMap(it -> {
|
||||
if (it.isPresent()) {
|
||||
Texture texture = it.get();
|
||||
@@ -184,7 +162,7 @@ public final class TexturesLoader {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -195,29 +173,29 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getUUID());
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
|
||||
if (account instanceof OfflineAccount) {
|
||||
OfflineAccount offlineAccount = (OfflineAccount) account;
|
||||
|
||||
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
|
||||
InvalidationListener listener = o -> {
|
||||
Skin skin = offlineAccount.getSkin();
|
||||
String username = offlineAccount.getUsername();
|
||||
String profileName = offlineAccount.getProfileName();
|
||||
|
||||
binding.set(uuidFallback);
|
||||
if (skin != null) {
|
||||
skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
skin.load(profileName).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to load texture", exception);
|
||||
} else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) {
|
||||
} else if (result != null && result.skin() != null && result.skin().image() != null) {
|
||||
Map<String, String> metadata;
|
||||
if (result.getModel() != null) {
|
||||
metadata = singletonMap("model", result.getModel().modelName);
|
||||
if (result.model() != null) {
|
||||
metadata = singletonMap("model", result.model().modelName);
|
||||
} else {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
|
||||
binding.set(new LoadedTexture(result.getSkin().getImage(), metadata));
|
||||
binding.set(new LoadedTexture(result.skin().image(), metadata));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -234,12 +212,12 @@ public final class TexturesLoader {
|
||||
.asyncMap(textures -> {
|
||||
if (textures.isPresent()) {
|
||||
Texture texture = textures.get().get(TextureType.SKIN);
|
||||
if (texture != null && StringUtils.isNotBlank(texture.getUrl())) {
|
||||
if (texture != null && StringUtils.isNotBlank(texture.url())) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -318,7 +296,7 @@ public final class TexturesLoader {
|
||||
fxAvatarBinding(canvas, skinBinding(account));
|
||||
else {
|
||||
unbindAvatar(canvas);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getUUID()).image);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getProfileID()).image);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
@@ -168,7 +153,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
update.put("component", gameJavaVersion.component());
|
||||
|
||||
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
|
||||
result.remoteFiles().getFiles().forEach((path, file) -> {
|
||||
result.remoteFiles().files().forEach((path, file) -> {
|
||||
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
|
||||
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
|
||||
if (downloadInfo != null) {
|
||||
@@ -190,12 +175,12 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
|
||||
Path javaDir = getJavaDir(platform, name);
|
||||
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
|
||||
if (!result.getInfo().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
|
||||
if (!result.info().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
|
||||
|
||||
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
||||
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
||||
return JavaRuntime.of(executable, result.getInfo(), true);
|
||||
return JavaRuntime.of(executable, result.info(), true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import kala.compress.archivers.ArchiveEntry;
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.*;
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -74,8 +61,12 @@ public final class JavaManager {
|
||||
"Semeru"
|
||||
};
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
|
||||
/// Managed Mojang/Disco Java installs for this workspace (beside `.minecraft`), not the user-global HMCL profile.
|
||||
public static final HMCLJavaRepository REPOSITORY =
|
||||
new HMCLJavaRepository(Metadata.CURRENT_DIRECTORY.resolve("java"));
|
||||
/// Optional portable installs under the launcher-local `.hmcl` directory.
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY =
|
||||
new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
|
||||
|
||||
public static String getMojangJavaPlatform(Platform platform) {
|
||||
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
||||
@@ -122,16 +113,27 @@ public final class JavaManager {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
// Windows x86-64 platform is compatible with x86 programs
|
||||
return architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
|
||||
// Since Windows 10 Build 21277, Windows Arm64 has been compatible with x86-64 programs via translation
|
||||
if (architecture == Architecture.X86_64 && Platform.isSupportedTranslationX86_64())
|
||||
return true;
|
||||
|
||||
// Windows Arm64 is compatible with x86 programs via translation
|
||||
if (architecture == Architecture.X86)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
return architecture == Architecture.X86;
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
return architecture == Architecture.X86_64;
|
||||
break;
|
||||
}
|
||||
@@ -202,9 +204,11 @@ public final class JavaManager {
|
||||
|
||||
String pathString = javaRuntime.getBinary().toString();
|
||||
|
||||
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
|
||||
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
if (!SettingsManager.isUserSettingsReadOnly()) {
|
||||
SettingsManager.userSettings().getDisabledJava().remove(pathString);
|
||||
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
}
|
||||
}
|
||||
return javaRuntime;
|
||||
});
|
||||
@@ -256,7 +260,7 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void addJava(JavaRuntime java) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (!oldMap.containsKey(java.getBinary())) {
|
||||
@@ -267,12 +271,12 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(JavaRuntime java) throws InterruptedException {
|
||||
removeJava(java.getBinary());
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(Path realPath) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (oldMap.containsKey(realPath)) {
|
||||
@@ -295,13 +299,13 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, version);
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, manifest);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
|
||||
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
|
||||
|
||||
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
@@ -322,8 +326,8 @@ public final class JavaManager {
|
||||
boolean violationSuggested = false;
|
||||
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violationMandatory = true;
|
||||
} else {
|
||||
@@ -354,7 +358,7 @@ public final class JavaManager {
|
||||
// search java
|
||||
|
||||
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)
|
||||
searcher.loadCache();
|
||||
|
||||
@@ -364,13 +368,13 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
@@ -418,14 +422,14 @@ public final class JavaManager {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
}
|
||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
|
||||
|
||||
// Search in PATH.
|
||||
if (System.getenv("PATH") != null) {
|
||||
@@ -457,7 +461,7 @@ public final class JavaManager {
|
||||
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
|
||||
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
|
||||
try {
|
||||
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
||||
} catch (InvalidPathException e) {
|
||||
@@ -468,7 +472,7 @@ public final class JavaManager {
|
||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||
if (currentJava != null
|
||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||
}
|
||||
|
||||
@@ -479,7 +483,7 @@ public final class JavaManager {
|
||||
it.isJDK() ? "JDK" : "JRE",
|
||||
it.getVersion(),
|
||||
it.getPlatform().getArchitecture().getDisplayName(),
|
||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
it.getBinary()))
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||
return searcher.javaRuntimes;
|
||||
@@ -674,7 +678,7 @@ public final class JavaManager {
|
||||
|
||||
if (javaRuntimes.containsKey(executable)
|
||||
|| failed.contains(executable)
|
||||
|| ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -784,13 +788,15 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
if (Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
}
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,7 +826,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(programFiles);
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.*;
|
||||
@@ -34,33 +19,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
* @author Glavo
|
||||
*/
|
||||
@JsonAdapter(JavaManifest.Serializer.class)
|
||||
public final class JavaManifest {
|
||||
|
||||
private final JavaInfo info;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, Object> update;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, JavaLocalFiles.Local> files;
|
||||
|
||||
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
this.info = info;
|
||||
this.update = update;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public JavaInfo getInfo() {
|
||||
return info;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUpdate() {
|
||||
return update;
|
||||
}
|
||||
|
||||
public Map<String, JavaLocalFiles.Local> getFiles() {
|
||||
return files;
|
||||
}
|
||||
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
|
||||
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
||||
|
||||
@@ -69,16 +28,16 @@ public final class JavaManifest {
|
||||
@Override
|
||||
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.getInfo().getVersion());
|
||||
res.addProperty("java.vendor", src.getInfo().getVendor());
|
||||
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.info().getVersion());
|
||||
res.addProperty("java.vendor", src.info().getVendor());
|
||||
|
||||
if (src.getUpdate() != null)
|
||||
res.add("update", context.serialize(src.getUpdate()));
|
||||
if (src.update() != null)
|
||||
res.add("update", context.serialize(src.update()));
|
||||
|
||||
if (src.getFiles() != null)
|
||||
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
|
||||
if (src.files() != null)
|
||||
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -35,28 +20,22 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import org.jackhuang.hmcl.game.OAuthServer;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -74,7 +53,14 @@ public final class Accounts {
|
||||
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
|
||||
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
|
||||
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
|
||||
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||
/// Account factories offered when creating a new account from the UI.
|
||||
///
|
||||
/// Primary: Offline and Microsoft. Authlib-injector remains available for external Yggdrasil servers.
|
||||
public static final List<AccountFactory<?>> FACTORIES = List.of(
|
||||
FACTORY_OFFLINE,
|
||||
FACTORY_MICROSOFT,
|
||||
FACTORY_AUTHLIB_INJECTOR
|
||||
);
|
||||
|
||||
// ==== login type / account factory mapping ====
|
||||
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
||||
@@ -120,9 +106,6 @@ public final class Accounts {
|
||||
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
||||
}
|
||||
|
||||
private static final String GLOBAL_PREFIX = "$GLOBAL:";
|
||||
private static final ObservableList<Map<Object, Object>> globalAccountStorages = FXCollections.observableArrayList();
|
||||
|
||||
private static final ObservableList<Account> accounts = observableArrayList(account -> new Observable[]{account});
|
||||
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
|
||||
|
||||
@@ -131,120 +114,219 @@ public final class Accounts {
|
||||
*/
|
||||
private static boolean initialized = false;
|
||||
|
||||
private static Map<Object, Object> getAccountStorage(Account account) {
|
||||
Map<Object, Object> storage = account.toStorage();
|
||||
storage.put("type", getLoginType(getAccountFactory(account)));
|
||||
return storage;
|
||||
private static SerializedAccount serializeAccount(Account account) {
|
||||
JsonObject metadata = new JsonObject();
|
||||
metadata.addProperty("type", getLoginType(getAccountFactory(account)));
|
||||
account.writeMetadata(metadata);
|
||||
JsonObject privateData = new JsonObject();
|
||||
account.writePrivateData(privateData);
|
||||
return new SerializedAccount(metadata, privateData);
|
||||
}
|
||||
|
||||
private static void updateAccountStorages() {
|
||||
// don't update the underlying storage before data loading is completed
|
||||
/// Ensures account IDs are unique across local and shared account metadata records before accounts are instantiated.
|
||||
private static AccountIDNormalization ensureUniqueAccountIDs() {
|
||||
Set<String> usedAccountIDs = new HashSet<>();
|
||||
boolean localChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.gameAccounts(),
|
||||
usedAccountIDs,
|
||||
false);
|
||||
boolean sharedChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.userGameAccounts(),
|
||||
usedAccountIDs,
|
||||
true);
|
||||
return new AccountIDNormalization(localChanged, sharedChanged);
|
||||
}
|
||||
|
||||
/// Result of normalizing account IDs across loaded metadata stores.
|
||||
///
|
||||
/// @param localChanged whether the per-workspace account metadata changed
|
||||
/// @param sharedChanged whether the shared account metadata changed
|
||||
private record AccountIDNormalization(boolean localChanged, boolean sharedChanged) {
|
||||
}
|
||||
|
||||
/// Returns account IDs from metadata records.
|
||||
///
|
||||
/// @param first the first metadata record list
|
||||
/// @param second the second metadata record list
|
||||
/// @return account IDs found in the metadata records
|
||||
private static List<AccountID> getAccountIDs(List<JsonObject> first, List<JsonObject> second) {
|
||||
ArrayList<AccountID> accountIDs = new ArrayList<>(first.size() + second.size());
|
||||
addAccountIDs(accountIDs, first);
|
||||
addAccountIDs(accountIDs, second);
|
||||
return accountIDs;
|
||||
}
|
||||
|
||||
/// Adds account IDs from metadata records to a target list.
|
||||
///
|
||||
/// @param accountIDs the target account ID list
|
||||
/// @param metadataRecords the metadata records to read
|
||||
private static void addAccountIDs(List<AccountID> accountIDs, List<JsonObject> metadataRecords) {
|
||||
for (JsonObject metadata : metadataRecords) {
|
||||
@Nullable AccountID accountID = Account.getAccountID(metadata);
|
||||
if (accountID != null && !accountIDs.contains(accountID)) {
|
||||
accountIDs.add(accountID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateAccountMetadataRecords() {
|
||||
// don't update the underlying account records before data loading is completed
|
||||
// otherwise it might cause data loss
|
||||
if (!initialized)
|
||||
return;
|
||||
// update storage
|
||||
|
||||
ArrayList<Map<Object, Object>> global = new ArrayList<>();
|
||||
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
|
||||
ArrayList<JsonObject> globalMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> globalPrivateData = new LinkedHashMap<>();
|
||||
ArrayList<JsonObject> portableMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> portablePrivateData = new LinkedHashMap<>();
|
||||
|
||||
for (Account account : accounts) {
|
||||
Map<Object, Object> storage = getAccountStorage(account);
|
||||
if (account.isPortable())
|
||||
portable.add(storage);
|
||||
else
|
||||
global.add(storage);
|
||||
}
|
||||
|
||||
if (!global.equals(globalAccountStorages))
|
||||
globalAccountStorages.setAll(global);
|
||||
if (!portable.equals(config().getAccountStorages()))
|
||||
config().getAccountStorages().setAll(portable);
|
||||
}
|
||||
|
||||
private static void loadGlobalAccountStorages() {
|
||||
Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json");
|
||||
if (Files.exists(globalAccountsFile)) {
|
||||
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
|
||||
globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load global accounts", e);
|
||||
SerializedAccount serialized = serializeAccount(account);
|
||||
if (account.isPortable()) {
|
||||
portableMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
portablePrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
} else {
|
||||
globalMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
globalPrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalAccountStorages.addListener(onInvalidating(() ->
|
||||
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
|
||||
List<AccountID> retainedAccountIDs = getAccountIDs(globalMetadata, portableMetadata);
|
||||
if (!SettingsManager.isUserGameAccountsReadOnly())
|
||||
SettingsManager.updateUserGameAccounts(globalMetadata, globalPrivateData, retainedAccountIDs);
|
||||
if (!SettingsManager.isGameAccountsReadOnly())
|
||||
SettingsManager.updateGameAccounts(portableMetadata, portablePrivateData, retainedAccountIDs);
|
||||
}
|
||||
|
||||
private static Account parseAccount(Map<Object, Object> storage) {
|
||||
AccountFactory<?> factory = type2factory.get(storage.get("type"));
|
||||
/// Returns whether the account metadata and credential files selected by the portability flag are read-only.
|
||||
///
|
||||
/// @param portable whether the account is stored in the local account file
|
||||
public static boolean isAccountFilesReadOnly(boolean portable) {
|
||||
return portable ? SettingsManager.isGameAccountsReadOnly() : SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Returns whether the files containing the given account are read-only.
|
||||
public static boolean isAccountFilesReadOnly(Account account) {
|
||||
return isAccountFilesReadOnly(account.isPortable());
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be removed from its current account files.
|
||||
public static boolean canRemoveAccount(Account account) {
|
||||
return !isAccountFilesReadOnly(account);
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be moved between local and user account files.
|
||||
public static boolean canMoveAccount(Account account) {
|
||||
return !SettingsManager.isGameAccountsReadOnly() && !SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account metadata and credential files selected by the portability flag.
|
||||
///
|
||||
/// @param portable whether the target account files are local
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(boolean portable) throws IOException {
|
||||
if (portable) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account files containing the given account.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(Account account) throws IOException {
|
||||
forceOverwriteAccountFiles(account.isPortable());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites both local and user account metadata and credential files.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles() throws IOException {
|
||||
if (SettingsManager.isGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
}
|
||||
if (SettingsManager.isUserGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
private static Account parseAccount(JsonObject record, boolean portable) {
|
||||
AccountFactory<?> factory = type2factory.get(JsonUtils.getString(record, "type"));
|
||||
if (factory == null) {
|
||||
LOG.warning("Unrecognized account type: " + storage);
|
||||
LOG.warning("Unrecognized account type: " + describeAccountRecord(record));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return factory.fromStorage(storage);
|
||||
AccountID accountID = Account.readAccountID(record);
|
||||
return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load account: " + storage, e);
|
||||
LOG.warning("Failed to load account: " + describeAccountRecord(record), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when it's ready to load accounts from {@link ConfigHolder#config()}.
|
||||
*/
|
||||
static void init() {
|
||||
/// Serialized account metadata and private data.
|
||||
///
|
||||
/// @param metadata public metadata stored in `accounts.json`
|
||||
/// @param privateData private account data stored in account private data
|
||||
private record SerializedAccount(JsonObject metadata, JsonObject privateData) {
|
||||
}
|
||||
|
||||
/// Returns a safe account record description for diagnostics.
|
||||
private static String describeAccountRecord(JsonObject record) {
|
||||
AccountID accountID = Account.getAccountID(record);
|
||||
if (accountID != null) {
|
||||
return accountID.toString();
|
||||
}
|
||||
|
||||
String type = JsonUtils.getString(record, "type");
|
||||
return type != null ? "{type=" + type + "}" : "<unknown>";
|
||||
}
|
||||
|
||||
/// Called when it's ready to load accounts from [SettingsManager#settings()].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
if (!config().isAddedLittleSkin()) {
|
||||
AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/");
|
||||
|
||||
if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) {
|
||||
config().getAuthlibInjectorServers().add(0, littleSkin);
|
||||
}
|
||||
|
||||
config().setAddedLittleSkin(true);
|
||||
AccountIDNormalization accountIDNormalization = ensureUniqueAccountIDs();
|
||||
if (accountIDNormalization.localChanged()) {
|
||||
SettingsManager.saveGameAccountMetadataRecords();
|
||||
}
|
||||
if (accountIDNormalization.sharedChanged()) {
|
||||
SettingsManager.saveUserGameAccountMetadataRecords();
|
||||
}
|
||||
|
||||
loadGlobalAccountStorages();
|
||||
|
||||
// load accounts
|
||||
Account selected = null;
|
||||
for (Map<Object, Object> storage : config().getAccountStorages()) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, true);
|
||||
if (account != null) {
|
||||
account.setPortable(true);
|
||||
accounts.add(account);
|
||||
if (Boolean.TRUE.equals(storage.get("selected"))) {
|
||||
if (JsonUtils.getBoolean(record, "selected", false)) {
|
||||
selected = account;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Map<Object, Object> storage : globalAccountStorages) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getUserAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, false);
|
||||
if (account != null) {
|
||||
accounts.add(account);
|
||||
}
|
||||
}
|
||||
|
||||
String selectedAccountIdentifier = config().getSelectedAccount();
|
||||
if (selected == null && selectedAccountIdentifier != null) {
|
||||
boolean portable = true;
|
||||
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
|
||||
portable = false;
|
||||
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
|
||||
}
|
||||
|
||||
AccountID selectedAccountID = settings().selectedAccountProperty().get();
|
||||
if (selected == null && selectedAccountID != null) {
|
||||
for (Account account : accounts) {
|
||||
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
|
||||
if (portable == account.isPortable()) {
|
||||
selected = account;
|
||||
break;
|
||||
} else if (selected == null) {
|
||||
selected = account;
|
||||
}
|
||||
if (account.getAccountID().equals(selectedAccountID)) {
|
||||
selected = account;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,15 +335,18 @@ public final class Accounts {
|
||||
selected = accounts.get(0);
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
for (Account account : accounts) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
accounts.addListener(new ListChangeListener<Account>() {
|
||||
@Override
|
||||
public void onChanged(Change<? extends Account> change) {
|
||||
@@ -269,7 +354,8 @@ public final class Accounts {
|
||||
for (Account account : change.getAddedSubList()) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
accounts.removeListener(this);
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,7 +367,7 @@ public final class Accounts {
|
||||
|
||||
InvalidationListener listener = o -> {
|
||||
// this method first checks whether the current selection is valid
|
||||
// if it's valid, the underlying storage will be updated
|
||||
// if it's valid, the underlying account records will be updated
|
||||
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
|
||||
Account account = selectedAccount.get();
|
||||
if (accounts.isEmpty()) {
|
||||
@@ -304,16 +390,16 @@ public final class Accounts {
|
||||
selectedAccount.addListener(onInvalidating(() -> {
|
||||
Account account = selectedAccount.get();
|
||||
if (account != null)
|
||||
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
|
||||
settings().selectedAccountProperty().set(account.getAccountID());
|
||||
else
|
||||
config().setSelectedAccount(null);
|
||||
settings().selectedAccountProperty().set(null);
|
||||
}));
|
||||
accounts.addListener(listener);
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountMetadataRecords));
|
||||
|
||||
initialized = true;
|
||||
|
||||
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
|
||||
if (selected != null) {
|
||||
Account finalSelected = selected;
|
||||
@@ -326,7 +412,7 @@ public final class Accounts {
|
||||
});
|
||||
}
|
||||
|
||||
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
|
||||
for (AuthlibInjectorServer server : getAuthlibInjectorServers()) {
|
||||
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
||||
continue;
|
||||
Schedulers.io().execute(() -> {
|
||||
@@ -373,12 +459,12 @@ public final class Accounts {
|
||||
}
|
||||
|
||||
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
||||
return config().getAuthlibInjectorServers().stream()
|
||||
return getAuthlibInjectorServers().stream()
|
||||
.filter(server -> url.equals(server.getUrl()))
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
return server;
|
||||
});
|
||||
}
|
||||
@@ -391,7 +477,8 @@ public final class Accounts {
|
||||
accounts.stream()
|
||||
.filter(AuthlibInjectorAccount.class::isInstance)
|
||||
.map(AuthlibInjectorAccount.class::cast)
|
||||
.filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(it -> !getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(Accounts::canRemoveAccount)
|
||||
.collect(toList())
|
||||
.forEach(accounts::remove);
|
||||
}
|
||||
@@ -468,7 +555,9 @@ public final class Accounts {
|
||||
}
|
||||
} else if (exception instanceof MicrosoftService.XBox400Exception) {
|
||||
return i18n("account.methods.microsoft.error.wrong_verify_method");
|
||||
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_license");
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_character");
|
||||
} else if (exception instanceof MicrosoftService.NoXuiException) {
|
||||
return i18n("account.methods.microsoft.error.add_family");
|
||||
|
||||
@@ -0,0 +1,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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
@@ -35,7 +20,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonSerializable
|
||||
@@ -71,7 +57,7 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
configLocation = Paths.get(CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
AuthlibInjectorServers configInstance;
|
||||
try {
|
||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||
@@ -81,11 +67,11 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
}
|
||||
|
||||
if (!configInstance.urls.isEmpty()) {
|
||||
config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
for (String url : configInstance.urls) {
|
||||
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
||||
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
servers.add(server);
|
||||
})
|
||||
.start();
|
||||
|
||||
@@ -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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2021 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.HMCLDownloadProvider;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
|
||||
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
|
||||
private static final MirrorRoots MIRROR_ROOTS =
|
||||
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;
|
||||
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
|
||||
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
|
||||
DownloadProvider mojang = new MojangDownloadProvider();
|
||||
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
|
||||
|
||||
DEFAULT_PROVIDER = mojang;
|
||||
DIRECT_PROVIDERS = Lang.mapOf(
|
||||
pair("mojang", mojang),
|
||||
pair("bmclapi", bmclapi)
|
||||
);
|
||||
|
||||
AUTO_PROVIDERS = Lang.mapOf(
|
||||
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
|
||||
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
|
||||
List.of(mojang, bmclapiRaw),
|
||||
List.of(bmclapiRaw, mojang)
|
||||
) : mojang),
|
||||
pair("mirror", bmclapi)
|
||||
);
|
||||
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Initializes download provider settings, mirror probing, and download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
|
||||
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
|
||||
? DEFAULT_CONCURRENCY
|
||||
: config().getDownloadThreads());
|
||||
: settings().downloadThreadsProperty().get());
|
||||
};
|
||||
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
if (config().isAutoChooseDownloadType()) {
|
||||
String versionListSource = config().getVersionListSource();
|
||||
DownloadProvider downloadProvider = versionListSource != null
|
||||
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER;
|
||||
PROVIDER_WRAPPER.setProvider(downloadProvider);
|
||||
} else {
|
||||
String downloadType = config().getDownloadType();
|
||||
PROVIDER_WRAPPER.setProvider(downloadType != null
|
||||
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER);
|
||||
}
|
||||
};
|
||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
config().downloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
|
||||
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
|
||||
|
||||
MIRROR_ROOTS.maybeProbeAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
/// Get current primary preferred download provider
|
||||
public static DownloadProvider getDownloadProvider() {
|
||||
return PROVIDER_WRAPPER;
|
||||
MIRROR_ROOTS.maybeProbeAsync();
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
if (exception instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
return i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
||||
return i18n("download.code.404", uri);
|
||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
|
||||
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
|
||||
}
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception instanceof ArtifactMalformedException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
} else if (exception instanceof CancellationException) {
|
||||
return i18n("message.cancelled");
|
||||
}
|
||||
return StringUtils.getStackTrace(exception);
|
||||
/// Returns the current ranked BMCLAPI-compatible mirror roots (fastest first).
|
||||
public static @Unmodifiable List<String> getRankedMirrorRoots() {
|
||||
return MIRROR_ROOTS.getRankedRoots();
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
public enum EnumCommonDirectory {
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
@@ -37,7 +22,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -56,17 +41,17 @@ public final class FontManager {
|
||||
|
||||
// Recommended
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
// Legacy
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -74,7 +59,7 @@ public final class FontManager {
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -97,13 +82,34 @@ public final class FontManager {
|
||||
|
||||
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
||||
|
||||
/// Bundled OFL fonts: Noto Sans SC for headings and controls, Noto Serif SC for body text.
|
||||
private static final String[] BUNDLED_FONTS = {
|
||||
"/assets/fonts/NotoSansSC-Regular.ttf",
|
||||
"/assets/fonts/NotoSansSC-Bold.ttf",
|
||||
"/assets/fonts/NotoSerifSC-Regular.ttf"
|
||||
};
|
||||
|
||||
/// Registers the bundled fonts with JavaFX so style sheets can refer to them by family name.
|
||||
private static void loadBundledFonts() {
|
||||
for (String resource : BUNDLED_FONTS) {
|
||||
try (var in = FontManager.class.getResourceAsStream(resource)) {
|
||||
if (in == null || Font.loadFont(in, DEFAULT_FONT_SIZE) == null) {
|
||||
LOG.warning("Failed to load bundled font " + resource);
|
||||
}
|
||||
} catch (java.io.IOException e) {
|
||||
LOG.warning("Failed to load bundled font " + resource, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
loadBundledFonts();
|
||||
updateFont();
|
||||
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
||||
}
|
||||
|
||||
private static void updateFont() {
|
||||
String fontFamily = config().getLauncherFontFamily();
|
||||
String fontFamily = settings().launcherFontFamilyProperty().get();
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getProperty("hmcl.font.override");
|
||||
if (fontFamily == null)
|
||||
@@ -236,7 +242,7 @@ public final class FontManager {
|
||||
}
|
||||
|
||||
public static void setFontFamily(String fontFamily) {
|
||||
config().setLauncherFontFamily(fontFamily);
|
||||
settings().launcherFontFamilyProperty().set(fontFamily);
|
||||
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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2024 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2024 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Selects how HMCL resolves the Java runtime used to launch a game.
|
||||
///
|
||||
/// Additional payload for these modes is stored in `GameSettings`:
|
||||
/// - [VERSION] uses `customJavaVersion` as the user-entered Java version selector.
|
||||
/// - [DETECTED] uses `detectedJava` as the detected runtime reference.
|
||||
/// - [CUSTOM] uses `customJavaPath` as the executable path selected by the user.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum JavaVersionType {
|
||||
DEFAULT, AUTO, VERSION, DETECTED, CUSTOM
|
||||
/// Automatically selects a suitable Java runtime for the target game version.
|
||||
AUTO,
|
||||
|
||||
/// Selects an installed Java runtime by major version.
|
||||
VERSION,
|
||||
|
||||
/// Selects one runtime from the Java runtimes detected by HMCL.
|
||||
DETECTED,
|
||||
|
||||
/// Uses a user-provided Java executable path.
|
||||
CUSTOM
|
||||
}
|
||||
@@ -0,0 +1,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 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user