Compare commits
638
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcc17472f | ||
|
|
7b85f0dfce | ||
|
|
b8a830dd40 | ||
|
|
ce368caa67 | ||
|
|
850ad82aa1 | ||
|
|
552276e673 | ||
|
|
e79855685e | ||
|
|
ad5afe7e06 | ||
|
|
8210c77de9 | ||
|
|
b11b298312 | ||
|
|
5c605c58d2 | ||
|
|
01d4100a2f | ||
|
|
d07eb2f748 | ||
|
|
345bb176fd | ||
|
|
83a38fadf0 | ||
|
|
9980dc8aea | ||
|
|
7dfbf76b69 | ||
|
|
7f2f77f54f | ||
|
|
2dccd8099d | ||
|
|
d8a03cfed8 | ||
|
|
26a874a030 | ||
|
|
7c5c3e2fb4 | ||
|
|
62d7b36248 | ||
|
|
dec6837528 | ||
|
|
3484792e81 | ||
|
|
2b310e035d | ||
|
|
6e960612dd | ||
|
|
73a776b021 | ||
|
|
31a2f49ed2 | ||
|
|
ab268cfe48 | ||
|
|
721aeccfc0 | ||
|
|
e4348f727a | ||
|
|
dfc96f10da | ||
|
|
a5c7fe08d8 | ||
|
|
a6e0acca45 | ||
|
|
3d312f9247 | ||
|
|
18dc5b69c0 | ||
|
|
98af2c85b6 | ||
|
|
c4c0b1a1ce | ||
|
|
5e621ce340 | ||
|
|
1d1c8dfdc9 | ||
|
|
9b68b8825d | ||
|
|
5c17783223 | ||
|
|
a921be45f1 | ||
|
|
2dd1c0f85e | ||
|
|
7ba229b3e5 | ||
|
|
59a299630b | ||
|
|
b41b89e233 | ||
|
|
af137e5ff1 | ||
|
|
74ddbf5623 | ||
|
|
accef4a93f | ||
|
|
9c3a955fad | ||
|
|
1c0e506d34 | ||
|
|
6374ec0de7 | ||
|
|
a6b3872af4 | ||
|
|
ad98a5a6d4 | ||
|
|
05393af537 | ||
|
|
3438da2ab9 | ||
|
|
3163b465ec | ||
|
|
573dac471c | ||
|
|
12f54f353a | ||
|
|
246699d571 | ||
|
|
3480e6974b | ||
|
|
6d14351833 | ||
|
|
fd0d83257d | ||
|
|
0eb1d3cf4b | ||
|
|
f9b8dad3d2 | ||
|
|
57fbd0910e | ||
|
|
7767a57f50 | ||
|
|
f024dba940 | ||
|
|
52736d3c86 | ||
|
|
f75b472068 | ||
|
|
d8e0c9e9c7 | ||
|
|
9601c1c1ea | ||
|
|
d5c7f9bef3 | ||
|
|
1714ffd260 | ||
|
|
fa06be865c | ||
|
|
9dc69dbc2b | ||
|
|
43b6fdb5ee | ||
|
|
e6598f23de | ||
|
|
0f51934e1c | ||
|
|
122d474eeb | ||
|
|
6223ceedff | ||
|
|
823b74b7e5 | ||
|
|
31642f9185 | ||
|
|
c8f66a457d | ||
|
|
65c76c53d5 | ||
|
|
7580c6da82 | ||
|
|
d8938e1fcb | ||
|
|
c0f699b90d | ||
|
|
3bc8d441a1 | ||
|
|
6c7681d015 | ||
|
|
24c36c8f20 | ||
|
|
c023242afc | ||
|
|
194ed4c255 | ||
|
|
28022461f7 | ||
|
|
55316c18bc | ||
|
|
beb6882921 | ||
|
|
2ba56a1137 | ||
|
|
a127254341 | ||
|
|
6086e6ba6e | ||
|
|
4baf983514 | ||
|
|
4dc8e2a6ff | ||
|
|
cf9dcf04c3 | ||
|
|
677c29e232 | ||
|
|
1d35f49af8 | ||
|
|
9e2f1d3151 | ||
|
|
8f9f800c51 | ||
|
|
1f8ba73bb1 | ||
|
|
415fcc1ac7 | ||
|
|
da3cf3056b | ||
|
|
d81d88d3f9 | ||
|
|
d5dee7146c | ||
|
|
436f664a1c | ||
|
|
0e04566b6c | ||
|
|
f9e9939b1f | ||
|
|
5d1e6fdc25 | ||
|
|
8011eee586 | ||
|
|
90eb622903 | ||
|
|
f0fcc4ac5e | ||
|
|
d35d1fc81e | ||
|
|
e9a9efd0cc | ||
|
|
30acc53ee8 | ||
|
|
f7b1c7cd8c | ||
|
|
506f1c1d60 | ||
|
|
ca2f8a5cda | ||
|
|
1f48b10eb9 | ||
|
|
40d21bbe24 | ||
|
|
d0be7efca3 | ||
|
|
3bed03c535 | ||
|
|
d295e606a9 | ||
|
|
36afe32146 | ||
|
|
87dbbdd96f | ||
|
|
9b8e67b1b3 | ||
|
|
f5a5e6a6da | ||
|
|
f0466c1d8e | ||
|
|
903ecbb127 | ||
|
|
4c6f3aeb99 | ||
|
|
d6a4b3480f | ||
|
|
5e84e5bcd6 | ||
|
|
6ce7af05b2 | ||
|
|
0d62a12353 | ||
|
|
880aab0ba5 | ||
|
|
0b64e89aaa | ||
|
|
7b6379674b | ||
|
|
55f4b0d8fc | ||
|
|
863c3c9c7f | ||
|
|
1f5fb983b0 | ||
|
|
856d5ec5af | ||
|
|
20a5670876 | ||
|
|
bdf6a58be8 | ||
|
|
7c74dd438f | ||
|
|
5803235407 | ||
|
|
afbf567fef | ||
|
|
72b3c1981a | ||
|
|
72ee82543b | ||
|
|
996c9f4c86 | ||
|
|
f8c1e97376 | ||
|
|
f146040ff3 | ||
|
|
0da28173ab | ||
|
|
be822666a7 | ||
|
|
8c99ee3b0d | ||
|
|
0e3b4a7853 | ||
|
|
615b1dbaae | ||
|
|
0fa51f830d | ||
|
|
007bb345d0 | ||
|
|
46536f11d3 | ||
|
|
b1232b5dc8 | ||
|
|
f8f047d67e | ||
|
|
86529c6f15 | ||
|
|
d63829c344 | ||
|
|
34359a7e86 | ||
|
|
aa335d8365 | ||
|
|
a565d3b7ce | ||
|
|
3bff3c7f23 | ||
|
|
4e6e832e9c | ||
|
|
d6648a840e | ||
|
|
f8c2af716f | ||
|
|
bd812ccb67 | ||
|
|
dad190f7e6 | ||
|
|
1b1ba64a38 | ||
|
|
e31268c39d | ||
|
|
cb9d32dc91 | ||
|
|
36568df5b1 | ||
|
|
b4ac42d789 | ||
|
|
32aafb11a3 | ||
|
|
36dab70b7a | ||
|
|
ebf253b1a7 | ||
|
|
539dcca5ca | ||
|
|
659b6c2a70 | ||
|
|
3dcc7ab311 | ||
|
|
e0ccb58272 | ||
|
|
d4ec8ebec8 | ||
|
|
f3b21e5099 | ||
|
|
202fd38180 | ||
|
|
f5768f2f95 | ||
|
|
2d377d06de | ||
|
|
42d1581ba0 | ||
|
|
995a23c2bc | ||
|
|
8969f55a1f | ||
|
|
312a285792 | ||
|
|
d85b052195 | ||
|
|
cd0d3d1b97 | ||
|
|
6ccc7df998 | ||
|
|
73e5909a69 | ||
|
|
ade487277d | ||
|
|
e755d17977 | ||
|
|
292288de9e | ||
|
|
57f6dec462 | ||
|
|
8e1a549c9d | ||
|
|
1d18eae377 | ||
|
|
6ef04d2fa5 | ||
|
|
1778e9d916 | ||
|
|
dbb63e9faa | ||
|
|
6b1c3182eb | ||
|
|
82460ebbcb | ||
|
|
3e8a9bf1c4 | ||
|
|
a2db925e86 | ||
|
|
fe70cc3daf | ||
|
|
5991112cd8 | ||
|
|
084d91c62a | ||
|
|
4963f7a5f0 | ||
|
|
f42d5219c2 | ||
|
|
101a9b5d5e | ||
|
|
7cd79a4e2a | ||
|
|
30c3acfb23 | ||
|
|
31f04db555 | ||
|
|
7d41f7bdd3 | ||
|
|
8e6ac947ed | ||
|
|
20e321f502 | ||
|
|
00b323e507 | ||
|
|
333b593c8a | ||
|
|
665f6df142 | ||
|
|
9e16130537 | ||
|
|
5c98d6734d | ||
|
|
c3801a3445 | ||
|
|
1d137a3a88 | ||
|
|
2e8b9ae3ca | ||
|
|
fb750e8717 | ||
|
|
f9ae0902d8 | ||
|
|
8d69cbc02f | ||
|
|
90c2372c92 | ||
|
|
84c4943de5 | ||
|
|
057943b6c0 | ||
|
|
e943cb0aec | ||
|
|
70290e205b | ||
|
|
a73b8c7172 | ||
|
|
a5942c8e91 | ||
|
|
dc6a97520b | ||
|
|
8be3f04cc8 | ||
|
|
8d85b1537d | ||
|
|
b16f2a6ce6 | ||
|
|
e19f175e29 | ||
|
|
f3a5b2a99d | ||
|
|
eac2740ea8 | ||
|
|
a06da53ec9 | ||
|
|
765ea24d22 | ||
|
|
bd24ab37f5 | ||
|
|
c689272c42 | ||
|
|
952cc5dd37 | ||
|
|
356df5d630 | ||
|
|
0e433c5f4b | ||
|
|
a28509de85 | ||
|
|
e45601ecda | ||
|
|
5bbf5cbfc8 | ||
|
|
afb98c20ce | ||
|
|
6b2bc4606b | ||
|
|
6b57cec576 | ||
|
|
83111b83f2 | ||
|
|
420e337c8c | ||
|
|
028e43c959 | ||
|
|
ef01acca6d | ||
|
|
110d5f5630 | ||
|
|
94497a073d | ||
|
|
ff540a95ee | ||
|
|
ff6a32cdc3 | ||
|
|
969644635a | ||
|
|
a053736e95 | ||
|
|
879f82c462 | ||
|
|
e2d8d9f93f | ||
|
|
da4ed04697 | ||
|
|
5d44df6b40 | ||
|
|
114503e0bf | ||
|
|
8bd88aa8d8 | ||
|
|
cc7d1e1aae | ||
|
|
56d4effda0 | ||
|
|
7c2dbe3dae | ||
|
|
99469077d2 | ||
|
|
ec930843da | ||
|
|
969d314802 | ||
|
|
3b11541b0a | ||
|
|
c58d5a3a81 | ||
|
|
cf9b4aacb2 | ||
|
|
e430db0cd6 | ||
|
|
1b31e96629 | ||
|
|
5c19239ddd | ||
|
|
c181c7825b | ||
|
|
80d2fefa1f | ||
|
|
8701030934 | ||
|
|
ae4c152a8b | ||
|
|
e436163be7 | ||
|
|
9b8e65449b | ||
|
|
fbb52f4e8a | ||
|
|
704eb32048 | ||
|
|
730d9b4ce8 | ||
|
|
c7b316ecba | ||
|
|
6df5a02c3f | ||
|
|
1b9fde3d60 | ||
|
|
f435cda6a3 | ||
|
|
4a69fde6e6 | ||
|
|
294a0cd7da | ||
|
|
e057e20b3c | ||
|
|
8c0ca7c731 | ||
|
|
0d591a5429 | ||
|
|
900d7595d5 | ||
|
|
5e18c0aad4 | ||
|
|
f51f982f80 | ||
|
|
7c5430424f | ||
|
|
5909c73c48 | ||
|
|
3166c1d405 | ||
|
|
27c73b4434 | ||
|
|
705f978c43 | ||
|
|
67432bf614 | ||
|
|
378c06656d | ||
|
|
e2d6904269 | ||
|
|
8ef2f8be1b | ||
|
|
584f0f39bc | ||
|
|
6529a3bb03 | ||
|
|
0e6f43ccba | ||
|
|
94b10a8b29 | ||
|
|
dc60af6d96 | ||
|
|
272c5d1b57 | ||
|
|
5bfe945fd0 | ||
|
|
3e8a295cf2 | ||
|
|
0d717b84c4 | ||
|
|
13672a57ff | ||
|
|
b19151a811 | ||
|
|
c7d9bc696a | ||
|
|
f426ea48fa | ||
|
|
8b9eeb68f7 | ||
|
|
413069d334 | ||
|
|
bab81da466 | ||
|
|
1138d063d2 | ||
|
|
041eedb765 | ||
|
|
48046cd416 | ||
|
|
9372c0903c | ||
|
|
ee79c50dea | ||
|
|
ef3c1b8926 | ||
|
|
ade057b819 | ||
|
|
2b1de479e5 | ||
|
|
5502a4efad | ||
|
|
5c36c00479 | ||
|
|
bb92b5ff8a | ||
|
|
8b90464d30 | ||
|
|
aaea473325 | ||
|
|
e8f9f093f4 | ||
|
|
9dc493dd02 | ||
|
|
5cf142df0e | ||
|
|
2a6cd17a3c | ||
|
|
285e361a5f | ||
|
|
0b70dc6d22 | ||
|
|
c8e2802373 | ||
|
|
9ac32a308a | ||
|
|
85cc11be1e | ||
|
|
568fa354ee | ||
|
|
2728be1014 | ||
|
|
388473850d | ||
|
|
1a5b19e7fe | ||
|
|
8d3fa105f7 | ||
|
|
ed43c07d88 | ||
|
|
586dee4f1b | ||
|
|
917d11009f | ||
|
|
4fa07661bb | ||
|
|
b1b90e0855 | ||
|
|
76370099e3 | ||
|
|
2e5e21affa | ||
|
|
cceef4f8f8 | ||
|
|
68c291e23a | ||
|
|
1e42034a0b | ||
|
|
53e90ea0f4 | ||
|
|
cd989b1058 | ||
|
|
5bc5ae8d15 | ||
|
|
c8183eaa85 | ||
|
|
ae522e0027 | ||
|
|
b6fcbb831c | ||
|
|
f3fed74fbc | ||
|
|
010abf037e | ||
|
|
9202cd6d61 | ||
|
|
e54fca4acc | ||
|
|
f6658567ea | ||
|
|
ffb1300081 | ||
|
|
3187c28df0 | ||
|
|
fb0919f3fb | ||
|
|
b418c9d33b | ||
|
|
d2a9024fc8 | ||
|
|
5e5d777dae | ||
|
|
e25e7c4dbd | ||
|
|
313eec4c10 | ||
|
|
a0fc43ca1e | ||
|
|
b37ec8dd1f | ||
|
|
167577b9c6 | ||
|
|
1c1fd8b1a4 | ||
|
|
77a86ef963 | ||
|
|
32a741e2de | ||
|
|
d1ead74177 | ||
|
|
80ba1ec1a7 | ||
|
|
25fef6b5c2 | ||
|
|
64ce1854f5 | ||
|
|
fb4f0be899 | ||
|
|
eea072cea5 | ||
|
|
b0cd184e22 | ||
|
|
8b3e801a44 | ||
|
|
04bb089f3a | ||
|
|
4bd0cbc88f | ||
|
|
9c4c7bd786 | ||
|
|
f79d560605 | ||
|
|
90e6782193 | ||
|
|
ca071fd924 | ||
|
|
2e26b14322 | ||
|
|
b7fbebae43 | ||
|
|
33dc39e59e | ||
|
|
a70b8d7baa | ||
|
|
64821600c5 | ||
|
|
dc993b871f | ||
|
|
a0bd64ad1f | ||
|
|
10df7a862b | ||
|
|
9ea6a97aef | ||
|
|
dec2e4ec7a | ||
|
|
8d504cd194 | ||
|
|
2918f19482 | ||
|
|
18f3e2870b | ||
|
|
9f2874c12b | ||
|
|
3fdf21fcd5 | ||
|
|
c9e435c129 | ||
|
|
26125f4002 | ||
|
|
93be558e84 | ||
|
|
e1be52f0f0 | ||
|
|
359a90636a | ||
|
|
d9b31ffd18 | ||
|
|
4b72639969 | ||
|
|
aecbf2e476 | ||
|
|
52d8c44320 | ||
|
|
5d57052ea2 | ||
|
|
570e1e2029 | ||
|
|
5b11202a9c | ||
|
|
c2d5d1b5f6 | ||
|
|
ef5849eba1 | ||
|
|
c2c542af3c | ||
|
|
792412b4c7 | ||
|
|
2b8e8306d7 | ||
|
|
84bcb01fe1 | ||
|
|
253bdf974e | ||
|
|
5839382c64 | ||
|
|
ab9c45549b | ||
|
|
cb413fa40a | ||
|
|
982c062a01 | ||
|
|
9f097149b2 | ||
|
|
cb50c89fb9 | ||
|
|
7fb2b9b734 | ||
|
|
b933fbcd6b | ||
|
|
c880e4befb | ||
|
|
575d908dba | ||
|
|
ecf188ad18 | ||
|
|
0b3cb9964a | ||
|
|
c175f1c1b3 | ||
|
|
dbacf096c6 | ||
|
|
c7b9b73292 | ||
|
|
19079ac123 | ||
|
|
2a66880a58 | ||
|
|
8b91251337 | ||
|
|
0c2742a895 | ||
|
|
a8692c3893 | ||
|
|
6cf757e440 | ||
|
|
c0f4143618 | ||
|
|
73b30974bf | ||
|
|
879f6cfeff | ||
|
|
0fc8222e63 | ||
|
|
5c2bb1cc25 | ||
|
|
c1d0bb973a | ||
|
|
c45cd74b67 | ||
|
|
05251cb4e3 | ||
|
|
ffbbde9d8f | ||
|
|
c75bcd97b2 | ||
|
|
0cc0b21d5f | ||
|
|
a524ba8506 | ||
|
|
17be25c036 | ||
|
|
5a518cec03 | ||
|
|
0adf4fcf32 | ||
|
|
c4ed6b065b | ||
|
|
0c4805df21 | ||
|
|
27972b987e | ||
|
|
fe11e5ff1e | ||
|
|
e3231859ea | ||
|
|
90a795b2c8 | ||
|
|
46f7e4db66 | ||
|
|
d956c04f2a | ||
|
|
59e147227d | ||
|
|
4cbe874c81 | ||
|
|
e9e6ef1f20 | ||
|
|
d2ae41c9ae | ||
|
|
79a1c3af8a | ||
|
|
c06f898d39 | ||
|
|
b09b915d16 | ||
|
|
179ccb7805 | ||
|
|
f7987a91ce | ||
|
|
e68b96f67d | ||
|
|
7f6379960b | ||
|
|
317b92c31e | ||
|
|
35e65921f9 | ||
|
|
95eeae4c42 | ||
|
|
165d77df30 | ||
|
|
65a4b6159d | ||
|
|
6f690d2522 | ||
|
|
5a34ac0d2b | ||
|
|
0ccb352b7e | ||
|
|
6d8add6d54 | ||
|
|
7ab22b0e07 | ||
|
|
20249ec4bf | ||
|
|
db38b4d532 | ||
|
|
ba5e63395f | ||
|
|
55cbb6cd2c | ||
|
|
8aecbe89f9 | ||
|
|
0a0476b6d3 | ||
|
|
38612e5d6c | ||
|
|
66e75fdc9d | ||
|
|
1e76733564 | ||
|
|
370d0ce78b | ||
|
|
6f402cd218 | ||
|
|
e778d223a7 | ||
|
|
db5aa0c869 | ||
|
|
8077b0bae1 | ||
|
|
e8c6c07e6a | ||
|
|
c1396c05aa | ||
|
|
9ca95c7f17 | ||
|
|
fb2d53664b | ||
|
|
6fd6022210 | ||
|
|
83782f5628 | ||
|
|
a19f147a6a | ||
|
|
85ce3d9a7a | ||
|
|
886a1f1207 | ||
|
|
8d6245eb38 | ||
|
|
7d76fea48d | ||
|
|
600bd6e9ee | ||
|
|
ccc64db805 | ||
|
|
ea407be5f0 | ||
|
|
9b9471da49 | ||
|
|
b1402d3821 | ||
|
|
62f0d0a1c0 | ||
|
|
2865f2ffca | ||
|
|
3860e68caa | ||
|
|
74d0a89820 | ||
|
|
91e4c85a28 | ||
|
|
a05b24cfd7 | ||
|
|
72e3925696 | ||
|
|
9ea419c05b | ||
|
|
3dc1631207 | ||
|
|
ab5ef6e0ff | ||
|
|
61113d5c08 | ||
|
|
d73d7330eb | ||
|
|
45a85e006b | ||
|
|
06cce5259b | ||
|
|
3e393982e0 | ||
|
|
db2e2d89ee | ||
|
|
a3d9e29350 | ||
|
|
682d88df02 | ||
|
|
b5defd31ed | ||
|
|
553e67bda7 | ||
|
|
95bb669ffc | ||
|
|
505752476c | ||
|
|
c723acfe30 | ||
|
|
2fd7076ccf | ||
|
|
0705465ba4 | ||
|
|
7a4649f0cc | ||
|
|
8b8a62ccf1 | ||
|
|
243f3e7371 | ||
|
|
4ad368560c | ||
|
|
c6c302e3d6 | ||
|
|
686f59e21b | ||
|
|
f1bd4bffb7 | ||
|
|
18a8556997 | ||
|
|
f10c842aa4 | ||
|
|
74d379a09c | ||
|
|
3dd1d5bd62 | ||
|
|
3247b77410 | ||
|
|
ce9194057a | ||
|
|
04cf5eca59 | ||
|
|
86d65e96ec | ||
|
|
1262a760b8 | ||
|
|
acd41e5885 | ||
|
|
f22568d3e8 | ||
|
|
11d182f907 | ||
|
|
e25e58cfad | ||
|
|
2e4cc76d6a | ||
|
|
02e9d807cd | ||
|
|
8923be50ec | ||
|
|
a07c0d9746 | ||
|
|
e56c4643de | ||
|
|
fb942d1099 | ||
|
|
3842b6df32 | ||
|
|
fadd9580a2 | ||
|
|
110a58b3cd | ||
|
|
5a25ba6663 | ||
|
|
0013a52a5c | ||
|
|
05d37e3911 | ||
|
|
bc55da0720 | ||
|
|
57a582e2cb | ||
|
|
7c0faf2143 | ||
|
|
fe0515ed28 | ||
|
|
0c657ef382 | ||
|
|
4ce6588f0c | ||
|
|
d083ff9048 | ||
|
|
54f9902849 | ||
|
|
2e572d2b02 | ||
|
|
ac9f337d4e | ||
|
|
078c2ad98b | ||
|
|
830d9a05e7 | ||
|
|
7dfeebff39 | ||
|
|
05a414c93f | ||
|
|
16a432125b | ||
|
|
573e25ecdf | ||
|
|
821cc6d768 | ||
|
|
c13b5e15d4 | ||
|
|
3eea76784d | ||
|
|
22c66b2790 | ||
|
|
fcabe9ca36 | ||
|
|
2af3c8f714 | ||
|
|
253440844e | ||
|
|
cc87f6cdd1 | ||
|
|
a77ef95ce4 | ||
|
|
7fb7b00836 | ||
|
|
3252de2f3f | ||
|
|
9b0b975490 | ||
|
|
449acbc7dc | ||
|
|
1c82e4e1e1 | ||
|
|
77cdc2108f | ||
|
|
cc092f535b | ||
|
|
13930f8dcc | ||
|
|
ebad1f67ce |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
code_review:
|
||||
pull_request_opened:
|
||||
code_review: false
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.groovy text eol=lf
|
||||
*.gradle text eol=lf
|
||||
|
||||
# ─── Configuration & data ─────────────────────────────────────────────────────
|
||||
*.properties text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.MF text eol=lf
|
||||
|
||||
# Gradle dependency-lock files
|
||||
*.lockfile text eol=lf
|
||||
|
||||
# ─── Scripts ──────────────────────────────────────────────────────────────────
|
||||
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# CI pipeline scripts
|
||||
Jenkinsfile text eol=lf
|
||||
|
||||
# Windows batch files must keep CRLF so cmd.exe can parse them.
|
||||
*.bat text eol=crlf
|
||||
|
||||
# ─── Binary files ─────────────────────────────────────────────────────────────
|
||||
# Raster / animated images
|
||||
*.png binary
|
||||
*.apng binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
|
||||
# Archives & compiled artifacts
|
||||
*.jar binary
|
||||
*.zip binary
|
||||
*.class binary
|
||||
|
||||
# Cryptographic / certificate files
|
||||
*.der binary
|
||||
|
||||
# Minecraft schematic
|
||||
*.litematic binary
|
||||
|
||||
# Compiled / intermediate build artefacts
|
||||
*.bin binary
|
||||
*.probe binary
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: QQ 群
|
||||
url: https://docs.hmcl.net/groups.html
|
||||
about: Hello Minecraft! Launcher 的官方 QQ 交流群。
|
||||
@@ -9,15 +9,15 @@ body:
|
||||
value: |
|
||||
提交前请确认:
|
||||
|
||||
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**,如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
|
||||
* 你的启动器版本是**最新的预览版本**,可以点击 [此处](https://zkitefly.github.io/HMCL-Snapshot-Update/) 下载最新预览版本。
|
||||
* 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**。如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
|
||||
* 你的启动器版本是**最新的预览版本**。你可以从 [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) 或 [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main) 下载最新预览版本。
|
||||
|
||||
如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。
|
||||
|
||||
Before submitting, please confirm:
|
||||
|
||||
* The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
|
||||
* Your launcher is the **latest nightly build**. You can click [here](https://zkitefly.github.io/HMCL-Snapshot-Update/en) to download the latest nightly build.
|
||||
* Your launcher is the **latest nightly build**. You can download the latest nightly build from [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) or [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main).
|
||||
|
||||
If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help.
|
||||
- type: textarea
|
||||
@@ -38,7 +38,7 @@ body:
|
||||
attributes:
|
||||
label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File
|
||||
description: |
|
||||
如果你的启动器崩溃了,请将崩溃报告填入(或将文件拖入)下方。
|
||||
如果你的启动器崩溃了,请将崩溃报告填入 (或将文件拖入) 下方。
|
||||
如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。
|
||||
**请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!**
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -27,28 +27,30 @@ jobs:
|
||||
run: ./gradlew build --no-daemon --parallel
|
||||
env:
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- name: Upload JAR
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-jar
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.jar
|
||||
HMCL/build/libs/HMCL-*.jar.sha256
|
||||
path: HMCL/build/libs/HMCL-*.jar
|
||||
archive: false
|
||||
- name: Upload EXE
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-exe
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.exe
|
||||
HMCL/build/libs/HMCL-*.exe.sha256
|
||||
path: HMCL/build/libs/HMCL-*.exe
|
||||
archive: false
|
||||
- name: Upload SH
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-sh
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.sh
|
||||
HMCL/build/libs/HMCL-*.sh.sha256
|
||||
path: HMCL/build/libs/HMCL-*.sh
|
||||
archive: false
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-deb
|
||||
path: HMCL/build/libs/HMCL-*.deb
|
||||
archive: false
|
||||
@@ -11,8 +11,9 @@ concurrency:
|
||||
jobs:
|
||||
mirror:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
include:
|
||||
- name: Gitee
|
||||
repo: gitee.com/huanghongxun/HMCL
|
||||
user: 'hmcl-sync'
|
||||
@@ -21,12 +22,12 @@ jobs:
|
||||
repo: cnb.cool/HMCL-dev/HMCL
|
||||
user: 'cnb'
|
||||
token: 'CNB_SYNC_TOKEN'
|
||||
name: Mirror to ${{ matrix.target.name }}
|
||||
name: Mirror to ${{ matrix.name }}
|
||||
if: ${{ github.repository == 'HMCL-dev/HMCL' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror GitHub to ${{ matrix.target.name }}
|
||||
- name: Mirror GitHub to ${{ matrix.name }}
|
||||
run: |
|
||||
git clone --mirror "https://github.com/$GITHUB_REPOSITORY.git" -- repo
|
||||
git clone --mirror "https://github.com/${{ github.repository }}.git" -- repo
|
||||
cd repo
|
||||
git push -f --prune "https://${{ matrix.target.user }}:${{ secrets[matrix.target.token] }}@${{ matrix.target.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*"
|
||||
git push -f --prune "https://${{ matrix.user }}:${{ secrets[matrix.token] }}@${{ matrix.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*"
|
||||
@@ -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
-1
@@ -5,6 +5,7 @@ hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
.gradle
|
||||
gradle.properties
|
||||
|
||||
*.lck
|
||||
*.1
|
||||
@@ -15,6 +16,8 @@ hs_err_pid*
|
||||
NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
@@ -42,6 +45,7 @@ language-subtag-registry
|
||||
# eclipse
|
||||
/bin/
|
||||
/HMCL/bin/
|
||||
/HMCLBoot/bin/
|
||||
/HMCLCore/bin/
|
||||
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
||||
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
||||
@@ -63,4 +67,10 @@ language-subtag-registry
|
||||
# test
|
||||
/hmcl.json
|
||||
/.hmcl.json
|
||||
/.hmcl/
|
||||
/.hmcl/
|
||||
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Hello Minecraft! Launcher Copyright (C) &#36;today.year huangyuhui <huanghongxun2008@126.com> and contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>." />
|
||||
<option name="myName" value="HMCL" />
|
||||
</copyright>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="HMCL">
|
||||
<LanguageOptions name="JAVA">
|
||||
<option name="fileTypeOverride" value="3" />
|
||||
<option name="addBlankAfter" value="false" />
|
||||
</LanguageOptions>
|
||||
</settings>
|
||||
</component>
|
||||
@@ -0,0 +1,21 @@
|
||||
# Java Code Style Requirements
|
||||
|
||||
These rules apply to all Java code written or modified in this repository.
|
||||
|
||||
## Nullability
|
||||
|
||||
- Annotate every class with JetBrains Annotations `@NotNullByDefault`.
|
||||
- Any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
||||
- Nullability must never be implicit.
|
||||
|
||||
## 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
|
||||
|
||||
- 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.
|
||||
+67
-6
@@ -1,3 +1,4 @@
|
||||
import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
@@ -5,6 +6,8 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
@@ -27,7 +30,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els
|
||||
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
|
||||
|
||||
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
|
||||
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
|
||||
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
|
||||
|
||||
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
|
||||
@@ -56,15 +58,21 @@ dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.fxsvgimage)
|
||||
implementation(libs.java.info)
|
||||
implementation(libs.monet.fx)
|
||||
implementation(libs.nayuki.qrcodegen)
|
||||
implementation(libs.uuid.tools)
|
||||
|
||||
testImplementation(libs.jimfs)
|
||||
|
||||
if (launcherExe.isBlank()) {
|
||||
implementation(libs.hmclauncher)
|
||||
}
|
||||
|
||||
embedResources(libs.authlib.injector)
|
||||
embedResources(libs.lwjgl.unsafe.agent)
|
||||
}
|
||||
|
||||
fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes)
|
||||
@@ -128,6 +136,7 @@ val addOpens = listOf(
|
||||
"javafx.base/javafx.beans.property",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/javafx.stage",
|
||||
"javafx.graphics/javafx.scene",
|
||||
"javafx.graphics/com.sun.glass.ui",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.javafx.util",
|
||||
@@ -151,9 +160,9 @@ val hmclProperties = buildList {
|
||||
}
|
||||
add("hmcl.version.type" to versionType)
|
||||
add("hmcl.microsoft.auth.id" to microsoftAuthId)
|
||||
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
|
||||
add("hmcl.curseforge.apikey" to curseForgeApiKey)
|
||||
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
|
||||
add("hmcl.lwjgl-unsafe-agent.version" to libs.lwjgl.unsafe.agent.get().version!!)
|
||||
}
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
@@ -191,7 +200,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/**") }
|
||||
|
||||
@@ -240,22 +249,29 @@ tasks.processResources {
|
||||
from(upsideDownTranslate.map { it.outputFile })
|
||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||
}
|
||||
|
||||
inputs.property("terracotta_version", libs.versions.terracotta)
|
||||
doLast {
|
||||
upgradeTerracottaConfig.get().checkValid()
|
||||
}
|
||||
}
|
||||
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
val 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")
|
||||
|
||||
@@ -270,8 +286,31 @@ val makeExecutables by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb by tasks.registering(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)
|
||||
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> {
|
||||
@@ -362,6 +401,28 @@ 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
|
||||
|
||||
tasks.register<CheckTranslations>("checkTranslations") {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.converters.ButtonTypeConverter;
|
||||
import com.jfoenix.skins.JFXButtonSkin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.SimpleStyleableObjectProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableObjectProperty;
|
||||
import javafx.css.StyleableProperty;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Labeled;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
public class JFXButton extends Button {
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-button";
|
||||
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
public JFXButton() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public JFXButton(String text) {
|
||||
super(text);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public JFXButton(String text, Node graphic) {
|
||||
super(text, graphic);
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXButtonSkin(this);
|
||||
}
|
||||
|
||||
private final ObjectProperty<Paint> ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null);
|
||||
|
||||
public final ObjectProperty<Paint> ripplerFillProperty() {
|
||||
return this.ripplerFill;
|
||||
}
|
||||
|
||||
public final Paint getRipplerFill() {
|
||||
return this.ripplerFillProperty().get();
|
||||
}
|
||||
|
||||
public final void setRipplerFill(Paint ripplerFill) {
|
||||
this.ripplerFillProperty().set(ripplerFill);
|
||||
}
|
||||
|
||||
private final StyleableObjectProperty<ButtonType> buttonType = new SimpleStyleableObjectProperty<>(
|
||||
JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT);
|
||||
|
||||
public ButtonType getButtonType() {
|
||||
return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<ButtonType> buttonTypeProperty() {
|
||||
return this.buttonType;
|
||||
}
|
||||
|
||||
public void setButtonType(ButtonType type) {
|
||||
this.buttonType.set(type);
|
||||
}
|
||||
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (this.STYLEABLES == null) {
|
||||
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(Labeled.getClassCssMetaData());
|
||||
this.STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
|
||||
return this.STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return JFXButton.StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
public enum ButtonType {
|
||||
FLAT,
|
||||
RAISED;
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXButton, ButtonType> BUTTON_TYPE;
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) {
|
||||
public boolean isSettable(JFXButton control) {
|
||||
return control.buttonType == null || !control.buttonType.isBound();
|
||||
}
|
||||
|
||||
public StyleableProperty<ButtonType> getStyleableProperty(JFXButton control) {
|
||||
return control.buttonTypeProperty();
|
||||
}
|
||||
};
|
||||
List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, BUTTON_TYPE);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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.JFXCheckBoxSkin;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/// JFXCheckBox is the material design implementation of a checkbox.
|
||||
/// it shows ripple effect and a custom selection animation.
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXCheckBox extends CheckBox {
|
||||
|
||||
public JFXCheckBox(String text) {
|
||||
super(text);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public JFXCheckBox() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXCheckBoxSkin(this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Stylesheet Handling *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// Initialize the style class to 'jfx-check-box'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-check-box";
|
||||
|
||||
/// checkbox color property when selected
|
||||
private StyleableObjectProperty<Paint> checkedColor;
|
||||
|
||||
private static final Color DEFAULT_CHECKED_COLOR = Color.valueOf("#0F9D58");
|
||||
|
||||
public StyleableObjectProperty<Paint> checkedColorProperty() {
|
||||
if (checkedColor == null) {
|
||||
checkedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.CHECKED_COLOR,
|
||||
JFXCheckBox.this,
|
||||
"checkedColor",
|
||||
DEFAULT_CHECKED_COLOR);
|
||||
}
|
||||
return this.checkedColor;
|
||||
}
|
||||
|
||||
public Paint getCheckedColor() {
|
||||
return checkedColor == null ? DEFAULT_CHECKED_COLOR : checkedColor.get();
|
||||
}
|
||||
|
||||
public void setCheckedColor(Paint color) {
|
||||
this.checkedColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkbox color property when not selected
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> unCheckedColor;
|
||||
|
||||
private static final Color DEFAULT_UNCHECKED_COLOR = Color.valueOf("#5A5A5A");
|
||||
|
||||
public StyleableObjectProperty<Paint> unCheckedColorProperty() {
|
||||
if (unCheckedColor == null) {
|
||||
unCheckedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNCHECKED_COLOR,
|
||||
JFXCheckBox.this,
|
||||
"unCheckedColor",
|
||||
DEFAULT_UNCHECKED_COLOR);
|
||||
}
|
||||
return this.unCheckedColor;
|
||||
}
|
||||
|
||||
public Paint getUnCheckedColor() {
|
||||
return unCheckedColor == null ? DEFAULT_UNCHECKED_COLOR : unCheckedColor.get();
|
||||
}
|
||||
|
||||
public void setUnCheckedColor(Paint color) {
|
||||
this.unCheckedColor.set(color);
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXCheckBox, Paint> CHECKED_COLOR =
|
||||
new CssMetaData<>("-jfx-checked-color",
|
||||
PaintConverter.getInstance(), DEFAULT_CHECKED_COLOR) {
|
||||
@Override
|
||||
public boolean isSettable(JFXCheckBox control) {
|
||||
return control.checkedColor == null || !control.checkedColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXCheckBox control) {
|
||||
return control.checkedColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXCheckBox, Paint> UNCHECKED_COLOR =
|
||||
new CssMetaData<>("-jfx-unchecked-color",
|
||||
PaintConverter.getInstance(), DEFAULT_UNCHECKED_COLOR) {
|
||||
@Override
|
||||
public boolean isSettable(JFXCheckBox control) {
|
||||
return control.unCheckedColor == null || !control.unCheckedColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXCheckBox control) {
|
||||
return control.unCheckedColorProperty();
|
||||
}
|
||||
};
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(CheckBox.getClassCssMetaData());
|
||||
Collections.addAll(styleables,
|
||||
CHECKED_COLOR,
|
||||
UNCHECKED_COLOR
|
||||
);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* 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.converters.base.NodeConverter;
|
||||
import com.jfoenix.skins.JFXComboBoxListViewSkin;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
/**
|
||||
* JFXComboBox is the material design implementation of a combobox.
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXComboBox() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXComboBox(ObservableList<T> items) {
|
||||
super(items);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
this.setCellFactory(listView -> new JFXListCell<T>() {
|
||||
@Override
|
||||
public void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
updateDisplayText(this, item, empty);
|
||||
}
|
||||
});
|
||||
|
||||
// had to refactor the code out of the skin class to allow
|
||||
// customization of the button cell
|
||||
this.setButtonCell(new ListCell<T>() {
|
||||
{
|
||||
// fixed clearing the combo box value is causing
|
||||
// java prompt text to be shown because the button cell is not updated
|
||||
JFXComboBox.this.valueProperty().addListener(observable -> {
|
||||
if (JFXComboBox.this.getValue() == null) {
|
||||
updateItem(null, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
updateDisplayText(this, item, empty);
|
||||
this.setVisible(item != null || !empty);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
useJFXContextMenu(editorProperty().get());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXComboBoxListViewSkin<T>(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-combo-box'.
|
||||
* <p>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-combo-box";
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Node Converter Property *
|
||||
* *
|
||||
**************************************************************************/
|
||||
/**
|
||||
* Converts the user-typed input (when the ComboBox is
|
||||
* {@link #editableProperty() editable}) to an object of type T, such that
|
||||
* the input may be retrieved via the {@link #valueProperty() value} property.
|
||||
*/
|
||||
public ObjectProperty<NodeConverter<T>> nodeConverterProperty() {
|
||||
return nodeConverter;
|
||||
}
|
||||
|
||||
private ObjectProperty<NodeConverter<T>> nodeConverter = new SimpleObjectProperty<>(this, "nodeConverter",
|
||||
JFXComboBox.<T>defaultNodeConverter());
|
||||
|
||||
public final void setNodeConverter(NodeConverter<T> value) {
|
||||
nodeConverterProperty().set(value);
|
||||
}
|
||||
|
||||
public final NodeConverter<T> getNodeConverter() {
|
||||
return nodeConverterProperty().get();
|
||||
}
|
||||
|
||||
private static <T> NodeConverter<T> defaultNodeConverter() {
|
||||
return new NodeConverter<T>() {
|
||||
@Override
|
||||
public Node toNode(T object) {
|
||||
if (object == null) {
|
||||
return null;
|
||||
}
|
||||
StackPane selectedValueContainer = new StackPane();
|
||||
selectedValueContainer.getStyleClass().add("combo-box-selected-value-container");
|
||||
selectedValueContainer.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
Label selectedValueLabel = object instanceof Label ? new Label(((Label) object).getText()) : new Label(
|
||||
object.toString());
|
||||
selectedValueLabel.setTextFill(Color.BLACK);
|
||||
selectedValueContainer.getChildren().add(selectedValueLabel);
|
||||
StackPane.setAlignment(selectedValueLabel, Pos.CENTER_LEFT);
|
||||
StackPane.setMargin(selectedValueLabel, new Insets(0, 0, 0, 5));
|
||||
return selectedValueContainer;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public T fromNode(Node node) {
|
||||
return (T) node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(T object) {
|
||||
if (object == null) {
|
||||
return null;
|
||||
}
|
||||
if (object instanceof Label) {
|
||||
return ((Label) object).getText();
|
||||
}
|
||||
return object.toString();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private boolean updateDisplayText(ListCell<T> cell, T item, boolean empty) {
|
||||
if (empty) {
|
||||
// create empty cell
|
||||
if (cell == null) {
|
||||
return true;
|
||||
}
|
||||
cell.setGraphic(null);
|
||||
cell.setText(null);
|
||||
return true;
|
||||
} else if (item instanceof Node) {
|
||||
Node currentNode = cell.getGraphic();
|
||||
Node newNode = (Node) item;
|
||||
// create a node from the selected node of the listview
|
||||
// using JFXComboBox {@link #nodeConverterProperty() NodeConverter})
|
||||
NodeConverter<T> nc = this.getNodeConverter();
|
||||
Node node = nc == null ? null : nc.toNode(item);
|
||||
if (currentNode == null || !currentNode.equals(newNode)) {
|
||||
cell.setText(null);
|
||||
cell.setGraphic(node == null ? newNode : node);
|
||||
}
|
||||
return node == null;
|
||||
} else {
|
||||
// run item through StringConverter if it isn't null
|
||||
StringConverter<T> c = this.getConverter();
|
||||
String s = item == null ? this.getPromptText() : (c == null ? item.toString() : c.toString(item));
|
||||
cell.setText(s);
|
||||
cell.setGraphic(null);
|
||||
return s == null || s.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* styleable Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* set true to show a float the prompt text when focusing the field
|
||||
*/
|
||||
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT,
|
||||
JFXComboBox.this,
|
||||
"lableFloat",
|
||||
false);
|
||||
|
||||
public final StyleableBooleanProperty labelFloatProperty() {
|
||||
return this.labelFloat;
|
||||
}
|
||||
|
||||
public final boolean isLabelFloat() {
|
||||
return this.labelFloatProperty().get();
|
||||
}
|
||||
|
||||
public final void setLabelFloat(final boolean labelFloat) {
|
||||
this.labelFloatProperty().set(labelFloat);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is unfocused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR,
|
||||
JFXComboBox.this,
|
||||
"unFocusColor",
|
||||
Color.rgb(77,
|
||||
77,
|
||||
77));
|
||||
|
||||
public Paint getUnFocusColor() {
|
||||
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unFocusColorProperty() {
|
||||
return this.unFocusColor;
|
||||
}
|
||||
|
||||
public void setUnFocusColor(Paint color) {
|
||||
this.unFocusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is focused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR,
|
||||
JFXComboBox.this,
|
||||
"focusColor",
|
||||
Color.valueOf("#4059A9"));
|
||||
|
||||
public Paint getFocusColor() {
|
||||
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> focusColorProperty() {
|
||||
return this.focusColor;
|
||||
}
|
||||
|
||||
public void setFocusColor(Paint color) {
|
||||
this.focusColor.set(color);
|
||||
}
|
||||
|
||||
private final static class StyleableProperties {
|
||||
private static final CssMetaData<JFXComboBox<?>, Paint> UNFOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
|
||||
"-jfx-unfocus-color",
|
||||
PaintConverter.getInstance(),
|
||||
Color.valueOf("#A6A6A6")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXComboBox<?> control) {
|
||||
return control.unFocusColor == null || !control.unFocusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
|
||||
return control.unFocusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXComboBox<?>, Paint> FOCUS_COLOR = new CssMetaData<JFXComboBox<?>, Paint>(
|
||||
"-jfx-focus-color",
|
||||
PaintConverter.getInstance(),
|
||||
Color.valueOf("#3f51b5")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXComboBox<?> control) {
|
||||
return control.focusColor == null || !control.focusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXComboBox<?> control) {
|
||||
return control.focusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXComboBox<?>, Boolean> LABEL_FLOAT = new CssMetaData<JFXComboBox<?>, Boolean>(
|
||||
"-jfx-label-float",
|
||||
BooleanConverter.getInstance(),
|
||||
false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXComboBox<?> control) {
|
||||
return control.labelFloat == null || !control.labelFloat.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXComboBox<?> control) {
|
||||
return control.labelFloatProperty();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
|
||||
Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT);
|
||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
// inherit the styleable properties from parent
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (STYLEABLES == null) {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
|
||||
Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(Control.getClassCssMetaData());
|
||||
STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
return STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,21 +23,13 @@ import com.jfoenix.controls.events.JFXDialogEvent;
|
||||
import com.jfoenix.converters.DialogTransitionConverter;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ObjectPropertyBase;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.SimpleStyleableObjectProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableObjectProperty;
|
||||
import javafx.css.StyleableProperty;
|
||||
import javafx.css.*;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -49,7 +41,6 @@ import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
@@ -155,7 +146,6 @@ public class JFXDialog extends StackPane {
|
||||
});
|
||||
|
||||
contentHolder = new StackPane();
|
||||
contentHolder.setBackground(new Background(new BackgroundFill(Color.WHITE, new CornerRadii(2), null)));
|
||||
JFXDepthManager.setDepth(contentHolder, 4);
|
||||
contentHolder.setPickOnBounds(false);
|
||||
// ensure stackpane is never resized beyond it's preferred size
|
||||
@@ -381,6 +371,14 @@ public class JFXDialog extends StackPane {
|
||||
setCycleDuration(Duration.seconds(0.4));
|
||||
setDelay(Duration.ZERO);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void starting() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void stopping() {
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
public class JFXDialogLayout extends StackPane {
|
||||
private final StackPane heading = new StackPane();
|
||||
private final StackPane body = new StackPane();
|
||||
private final FlowPane actions = new FlowPane() {
|
||||
protected double computeMinWidth(double height) {
|
||||
if (this.getContentBias() == Orientation.HORIZONTAL) {
|
||||
double maxPref = 0.0;
|
||||
for (Node child : this.getChildren()) {
|
||||
if (child.isManaged()) {
|
||||
maxPref = Math.max(maxPref, child.minWidth(-1.0));
|
||||
}
|
||||
}
|
||||
|
||||
Insets insets = this.getInsets();
|
||||
return insets.getLeft() + this.snapSizeX(maxPref) + insets.getRight();
|
||||
} else {
|
||||
return this.computePrefWidth(height);
|
||||
}
|
||||
}
|
||||
|
||||
protected double computeMinHeight(double width) {
|
||||
if (this.getContentBias() == Orientation.VERTICAL) {
|
||||
double maxPref = 0.0;
|
||||
|
||||
for (Node child : this.getChildren()) {
|
||||
if (child.isManaged()) {
|
||||
maxPref = Math.max(maxPref, child.minHeight(-1.0));
|
||||
}
|
||||
}
|
||||
|
||||
Insets insets = this.getInsets();
|
||||
return insets.getTop() + this.snapSizeY(maxPref) + insets.getBottom();
|
||||
} else {
|
||||
return this.computePrefHeight(width);
|
||||
}
|
||||
}
|
||||
};
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-dialog-layout";
|
||||
|
||||
public JFXDialogLayout() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
|
||||
VBox layout = new VBox();
|
||||
|
||||
this.heading.getStyleClass().add("jfx-layout-heading");
|
||||
this.heading.getStyleClass().add("title");
|
||||
|
||||
this.body.getStyleClass().add("jfx-layout-body");
|
||||
this.body.prefHeightProperty().bind(this.prefHeightProperty());
|
||||
this.body.prefWidthProperty().bind(this.prefWidthProperty());
|
||||
|
||||
this.actions.getStyleClass().add("jfx-layout-actions");
|
||||
|
||||
layout.getChildren().setAll(this.heading, this.body, this.actions);
|
||||
|
||||
this.getChildren().add(layout);
|
||||
}
|
||||
|
||||
public ObservableList<Node> getHeading() {
|
||||
return this.heading.getChildren();
|
||||
}
|
||||
|
||||
public void setHeading(Node... titleContent) {
|
||||
this.heading.getChildren().setAll(titleContent);
|
||||
}
|
||||
|
||||
public ObservableList<Node> getBody() {
|
||||
return this.body.getChildren();
|
||||
}
|
||||
|
||||
public void setBody(Node... body) {
|
||||
this.body.getChildren().setAll(body);
|
||||
}
|
||||
|
||||
public ObservableList<Node> getActions() {
|
||||
return this.actions.getChildren();
|
||||
}
|
||||
|
||||
public void setActions(Node... actions) {
|
||||
this.actions.getChildren().setAll(actions);
|
||||
}
|
||||
|
||||
public void setActions(List<? extends Node> actions) {
|
||||
this.actions.getChildren().setAll(actions);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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.utils.JFXNodeUtils;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.Shape;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/// material design implementation of ListCell
|
||||
///
|
||||
/// By default, JFXListCell will try to create a graphic node for the cell,
|
||||
/// to override it you need to set graphic to null in [#updateItem(Object, boolean)] method.
|
||||
///
|
||||
/// NOTE: passive nodes (Labels and Shapes) will be set to mouse transparent in order to
|
||||
/// show the ripple effect upon clicking , to change this behavior you can override the
|
||||
/// method {[#makeChildrenTransparent()]
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXListCell<T> extends ListCell<T> {
|
||||
|
||||
protected JFXRippler cellRippler = new JFXRippler(this) {
|
||||
@Override
|
||||
protected Node getMask() {
|
||||
Region clip = new Region();
|
||||
JFXNodeUtils.updateBackground(JFXListCell.this.getBackground(), clip);
|
||||
double width = control.getLayoutBounds().getWidth();
|
||||
double height = control.getLayoutBounds().getHeight();
|
||||
clip.resize(width, height);
|
||||
return clip;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void positionControl(Node control) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
protected Node cellContent;
|
||||
private Rectangle clip;
|
||||
|
||||
private Timeline gapAnimation;
|
||||
private boolean playExpandAnimation = false;
|
||||
private boolean selectionChanged = false;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXListCell() {
|
||||
initialize();
|
||||
initListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* init listeners to update the vertical gap / selection animation
|
||||
*/
|
||||
private void initListeners() {
|
||||
listViewProperty().addListener((listObj, oldList, newList) -> {
|
||||
if (newList instanceof JFXListView<?> listView) {
|
||||
listView.currentVerticalGapProperty().addListener((o, oldVal, newVal) -> {
|
||||
cellRippler.rippler.setClip(null);
|
||||
if (newVal.doubleValue() != 0) {
|
||||
playExpandAnimation = true;
|
||||
getListView().requestLayout();
|
||||
} else {
|
||||
// fake expand state
|
||||
double gap = clip.getY() * 2;
|
||||
gapAnimation = new Timeline(
|
||||
new KeyFrame(Duration.millis(240),
|
||||
new KeyValue(this.translateYProperty(),
|
||||
-gap / 2 - (gap * (getIndex())),
|
||||
Interpolator.EASE_BOTH)
|
||||
));
|
||||
gapAnimation.play();
|
||||
gapAnimation.setOnFinished((finish) -> {
|
||||
requestLayout();
|
||||
Platform.runLater(() -> getListView().requestLayout());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
selectedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
selectionChanged = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
cellRippler.resizeRelocate(0, 0, getWidth(), getHeight());
|
||||
double gap = getGap();
|
||||
|
||||
if (clip == null) {
|
||||
clip = new Rectangle(0, gap / 2, getWidth(), getHeight() - gap);
|
||||
setClip(clip);
|
||||
} else {
|
||||
if (gap != 0) {
|
||||
if (playExpandAnimation || selectionChanged) {
|
||||
// fake list collapse state
|
||||
if (playExpandAnimation) {
|
||||
this.setTranslateY(-gap / 2 + (-gap * (getIndex())));
|
||||
clip.setY(gap / 2);
|
||||
clip.setHeight(getHeight() - gap);
|
||||
gapAnimation = new Timeline(new KeyFrame(Duration.millis(240),
|
||||
new KeyValue(this.translateYProperty(),
|
||||
0,
|
||||
Interpolator.EASE_BOTH)));
|
||||
playExpandAnimation = false;
|
||||
} else if (selectionChanged) {
|
||||
clip.setY(0);
|
||||
clip.setHeight(getHeight());
|
||||
gapAnimation = new Timeline(
|
||||
new KeyFrame(Duration.millis(240),
|
||||
new KeyValue(clip.yProperty(), gap / 2, Interpolator.EASE_BOTH),
|
||||
new KeyValue(clip.heightProperty(), getHeight() - gap, Interpolator.EASE_BOTH)
|
||||
));
|
||||
}
|
||||
playExpandAnimation = false;
|
||||
selectionChanged = false;
|
||||
gapAnimation.play();
|
||||
} else {
|
||||
if (gapAnimation != null) {
|
||||
gapAnimation.stop();
|
||||
}
|
||||
this.setTranslateY(0);
|
||||
clip.setY(gap / 2);
|
||||
clip.setHeight(getHeight() - gap);
|
||||
}
|
||||
} else {
|
||||
this.setTranslateY(0);
|
||||
clip.setY(0);
|
||||
clip.setHeight(getHeight());
|
||||
}
|
||||
clip.setX(0);
|
||||
clip.setWidth(getWidth());
|
||||
}
|
||||
if (!getChildren().contains(cellRippler)) {
|
||||
makeChildrenTransparent();
|
||||
getChildren().add(0, cellRippler);
|
||||
cellRippler.rippler.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this method is used to set some nodes in cell content as mouse transparent nodes
|
||||
* so clicking on them will trigger the ripple effect.
|
||||
*/
|
||||
protected void makeChildrenTransparent() {
|
||||
for (Node child : getChildren()) {
|
||||
if (child instanceof Label) {
|
||||
Set<Node> texts = child.lookupAll("Text");
|
||||
for (Node text : texts) {
|
||||
text.setMouseTransparent(true);
|
||||
}
|
||||
} else if (child instanceof Shape) {
|
||||
child.setMouseTransparent(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
// remove empty (Trailing cells)
|
||||
setMouseTransparent(true);
|
||||
setStyle("-fx-background-color:TRANSPARENT;");
|
||||
} else {
|
||||
setMouseTransparent(false);
|
||||
setStyle(null);
|
||||
if (item instanceof Node newNode) {
|
||||
setText(null);
|
||||
Node currentNode = getGraphic();
|
||||
if (currentNode == null || !currentNode.equals(newNode)) {
|
||||
cellContent = newNode;
|
||||
cellRippler.rippler.cacheRippleClip(false);
|
||||
// build the Cell node
|
||||
// RIPPLER ITEM : in case if the list item has its own rippler bind the list rippler and item rippler properties
|
||||
if (newNode instanceof JFXRippler newRippler) {
|
||||
// build cell container from exisiting rippler
|
||||
cellRippler.ripplerFillProperty().bind(newRippler.ripplerFillProperty());
|
||||
cellRippler.maskTypeProperty().bind(newRippler.maskTypeProperty());
|
||||
cellRippler.positionProperty().bind(newRippler.positionProperty());
|
||||
cellContent = newRippler.getControl();
|
||||
}
|
||||
((Region) cellContent).setMaxHeight(cellContent.prefHeight(-1));
|
||||
setGraphic(cellContent);
|
||||
}
|
||||
} else {
|
||||
setText(item == null ? "null" : item.toString());
|
||||
setGraphic(null);
|
||||
}
|
||||
// show cell tooltip if it's toggled in JFXListView
|
||||
if (getListView() instanceof JFXListView<?> listView && listView.isShowTooltip()) {
|
||||
if (item instanceof Label label) {
|
||||
setTooltip(new Tooltip(label.getText()));
|
||||
} else if (getText() != null) {
|
||||
setTooltip(new Tooltip(getText()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stylesheet Handling *
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-list-cell'.
|
||||
* <p>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-list-cell";
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
this.setPadding(new Insets(8, 12, 8, 12));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width) {
|
||||
double gap = getGap();
|
||||
return super.computePrefHeight(width) + gap;
|
||||
}
|
||||
|
||||
private double getGap() {
|
||||
return (getListView() instanceof JFXListView<?> listView)
|
||||
? (listView.isExpanded() ? listView.currentVerticalGapProperty().get() : 0)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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.JFXListViewSkin;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.SizeConverter;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// Material design implementation of List View
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXListView<T> extends ListView<T> {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXListView() {
|
||||
this.setCellFactory(listView -> new JFXListCell<>());
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXListViewSkin<>(this);
|
||||
}
|
||||
|
||||
private IntegerProperty depth;
|
||||
|
||||
public IntegerProperty depthProperty() {
|
||||
if (depth == null) {
|
||||
depth = new SimpleIntegerProperty(this, "depth", 0);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
public int getDepth() {
|
||||
return depth != null ? depth.get() : 0;
|
||||
}
|
||||
|
||||
public void setDepth(int depth) {
|
||||
depthProperty().set(depth);
|
||||
}
|
||||
|
||||
private DoubleProperty currentVerticalGap;
|
||||
|
||||
DoubleProperty currentVerticalGapProperty() {
|
||||
if (currentVerticalGap == null) {
|
||||
currentVerticalGap = new SimpleDoubleProperty(this, "currentVerticalGap");
|
||||
}
|
||||
return currentVerticalGap;
|
||||
}
|
||||
|
||||
private void updateVerticalGap() {
|
||||
if (isExpanded()) {
|
||||
currentVerticalGapProperty().set(verticalGap.get());
|
||||
} else {
|
||||
currentVerticalGapProperty().set(0);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* this only works if the items were labels / strings
|
||||
*/
|
||||
private BooleanProperty showTooltip;
|
||||
|
||||
public final BooleanProperty showTooltipProperty() {
|
||||
if (showTooltip == null) {
|
||||
showTooltip = new SimpleBooleanProperty(this, "showTooltip", false);
|
||||
}
|
||||
return this.showTooltip;
|
||||
}
|
||||
|
||||
public final boolean isShowTooltip() {
|
||||
return showTooltip != null && showTooltip.get();
|
||||
}
|
||||
|
||||
public final void setShowTooltip(final boolean showTooltip) {
|
||||
this.showTooltipProperty().set(showTooltip);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Stylesheet Handling *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// Initialize the style class to 'jfx-list-view'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-list-view";
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
/**
|
||||
* propagate mouse events to the parent node ( e.g. to allow dragging while clicking on the list)
|
||||
*/
|
||||
public void propagateMouseEventsToParent() {
|
||||
this.addEventHandler(MouseEvent.ANY, e -> {
|
||||
e.consume();
|
||||
this.getParent().fireEvent(e);
|
||||
});
|
||||
}
|
||||
|
||||
private StyleableDoubleProperty verticalGap;
|
||||
|
||||
public StyleableDoubleProperty verticalGapProperty() {
|
||||
if (this.verticalGap == null) {
|
||||
this.verticalGap = new StyleableDoubleProperty(0.0) {
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return JFXListView.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "verticalGap";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CssMetaData<? extends Styleable, Number> getCssMetaData() {
|
||||
return StyleableProperties.VERTICAL_GAP;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
updateVerticalGap();
|
||||
}
|
||||
};
|
||||
}
|
||||
return this.verticalGap;
|
||||
}
|
||||
|
||||
public Double getVerticalGap() {
|
||||
return verticalGap == null ? 0.0 : verticalGap.get();
|
||||
}
|
||||
|
||||
public void setVerticalGap(Double gap) {
|
||||
verticalGapProperty().set(gap);
|
||||
}
|
||||
|
||||
private StyleableBooleanProperty expanded;
|
||||
|
||||
public StyleableBooleanProperty expandedProperty() {
|
||||
if (expanded == null) {
|
||||
expanded = new StyleableBooleanProperty(false) {
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return JFXListView.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "expanded";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CssMetaData<? extends Styleable, Boolean> getCssMetaData() {
|
||||
return StyleableProperties.EXPANDED;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
updateVerticalGap();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return this.expanded;
|
||||
}
|
||||
|
||||
public Boolean isExpanded() {
|
||||
return expanded != null && expanded.get();
|
||||
}
|
||||
|
||||
public void setExpanded(Boolean expanded) {
|
||||
expandedProperty().set(expanded);
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXListView<?>, Number> VERTICAL_GAP =
|
||||
new CssMetaData<>("-jfx-vertical-gap",
|
||||
SizeConverter.getInstance(), 0) {
|
||||
@Override
|
||||
public boolean isSettable(JFXListView<?> control) {
|
||||
return control.verticalGap == null || !control.verticalGap.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableDoubleProperty getStyleableProperty(JFXListView<?> control) {
|
||||
return control.verticalGapProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXListView<?>, Boolean> EXPANDED =
|
||||
new CssMetaData<>("-jfx-expanded",
|
||||
BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXListView<?> control) {
|
||||
// it's only settable if the List is not shown yet
|
||||
return control.getHeight() == 0 && (control.expanded == null || !control.expanded.isBound());
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXListView<?> control) {
|
||||
return control.expandedProperty();
|
||||
}
|
||||
};
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(ListView.getClassCssMetaData());
|
||||
Collections.addAll(styleables, VERTICAL_GAP, EXPANDED);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* 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.JFXPasswordFieldSkin;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
/**
|
||||
* JFXPasswordField is the material design implementation of a password Field.
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXPasswordField extends PasswordField {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXPasswordField() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXPasswordFieldSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
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>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-password-field";
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* holds the current active validator on the password field in case of validation error
|
||||
*/
|
||||
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
public ValidatorBase getActiveValidator() {
|
||||
return activeValidator == null ? null : activeValidator.get();
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
|
||||
return this.activeValidator.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* list of validators that will validate the password value upon calling
|
||||
* {{@link #validate()}
|
||||
*/
|
||||
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<ValidatorBase> getValidators() {
|
||||
return validators;
|
||||
}
|
||||
|
||||
public void setValidators(ValidatorBase... validators) {
|
||||
this.validators.addAll(validators);
|
||||
}
|
||||
|
||||
/**
|
||||
* validates the password value using the list of validators provided by the user
|
||||
* {{@link #setValidators(ValidatorBase...)}
|
||||
*
|
||||
* @return true if the value is valid else false
|
||||
*/
|
||||
public boolean validate() {
|
||||
for (ValidatorBase validator : validators) {
|
||||
if (validator.getSrcControl() == null) {
|
||||
validator.setSrcControl(this);
|
||||
}
|
||||
validator.validate();
|
||||
if (validator.getHasErrors()) {
|
||||
activeValidator.set(validator);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
activeValidator.set(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void resetValidation() {
|
||||
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
|
||||
activeValidator.set(null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* styleable Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* set true to show a float the prompt text when focusing the field
|
||||
*/
|
||||
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXPasswordField.this, "lableFloat", false);
|
||||
|
||||
public final StyleableBooleanProperty labelFloatProperty() {
|
||||
return this.labelFloat;
|
||||
}
|
||||
|
||||
public final boolean isLabelFloat() {
|
||||
return this.labelFloatProperty().get();
|
||||
}
|
||||
|
||||
public final void setLabelFloat(final boolean labelFloat) {
|
||||
this.labelFloatProperty().set(labelFloat);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is unfocused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXPasswordField.this, "unFocusColor", Color.rgb(77, 77, 77));
|
||||
|
||||
public Paint getUnFocusColor() {
|
||||
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unFocusColorProperty() {
|
||||
return this.unFocusColor;
|
||||
}
|
||||
|
||||
public void setUnFocusColor(Paint color) {
|
||||
this.unFocusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is focused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXPasswordField.this, "focusColor", Color.valueOf("#4059A9"));
|
||||
|
||||
public Paint getFocusColor() {
|
||||
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> focusColorProperty() {
|
||||
return this.focusColor;
|
||||
}
|
||||
|
||||
public void setFocusColor(Paint color) {
|
||||
this.focusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* disable animation on validation
|
||||
*/
|
||||
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXPasswordField.this, "disableAnimation", false);
|
||||
|
||||
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 final static class StyleableProperties {
|
||||
private static final CssMetaData<JFXPasswordField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXPasswordField control) {
|
||||
return control.unFocusColor == null || !control.unFocusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
|
||||
return control.unFocusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXPasswordField, Paint> FOCUS_COLOR = new CssMetaData<JFXPasswordField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXPasswordField control) {
|
||||
return control.focusColor == null || !control.focusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXPasswordField control) {
|
||||
return control.focusColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXPasswordField, Boolean> LABEL_FLOAT = new CssMetaData<JFXPasswordField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXPasswordField control) {
|
||||
return control.labelFloat == null || !control.labelFloat.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
|
||||
return control.labelFloatProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXPasswordField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXPasswordField, Boolean>("-fx-disable-animation", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXPasswordField control) {
|
||||
return control.disableAnimation == null || !control.disableAnimation.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) {
|
||||
return control.disableAnimationProperty();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
|
||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
// inherit the styleable properties from parent
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (STYLEABLES == null) {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(TextField.getClassCssMetaData());
|
||||
STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
return STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.JFXPopupSkin;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.NodeOrientation;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.PopupControl;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.Window;
|
||||
|
||||
/// JFXPopup is the material design implementation of a popup.
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 2.0
|
||||
/// @since 2017-03-01
|
||||
@DefaultProperty(value = "popupContent")
|
||||
public class JFXPopup extends PopupControl {
|
||||
|
||||
public enum PopupHPosition {
|
||||
RIGHT,
|
||||
LEFT;
|
||||
|
||||
public PopupHPosition getOpposite() {
|
||||
return (this == RIGHT) ? LEFT : RIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
public enum PopupVPosition {
|
||||
TOP, BOTTOM
|
||||
}
|
||||
|
||||
/// Creates empty popup.
|
||||
public JFXPopup() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/// creates popup with a specified container and content
|
||||
///
|
||||
/// @param content the node that will be shown in the popup
|
||||
public JFXPopup(Region content) {
|
||||
setPopupContent(content);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.setAutoFix(false);
|
||||
this.setAutoHide(true);
|
||||
this.setHideOnEscape(true);
|
||||
this.setConsumeAutoHidingEvents(false);
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXPopupSkin(this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Setters / Getters *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
private final ObjectProperty<Region> popupContent = new SimpleObjectProperty<>(new Pane());
|
||||
|
||||
public final ObjectProperty<Region> popupContentProperty() {
|
||||
return this.popupContent;
|
||||
}
|
||||
|
||||
public final Region getPopupContent() {
|
||||
return this.popupContentProperty().get();
|
||||
}
|
||||
|
||||
public final void setPopupContent(final Region popupContent) {
|
||||
this.popupContentProperty().set(popupContent);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Public API *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// show the popup using the default position
|
||||
public void show(Node node) {
|
||||
this.show(node, PopupVPosition.TOP, PopupHPosition.LEFT, 0, 0);
|
||||
}
|
||||
|
||||
/// show the popup according to the specified position
|
||||
///
|
||||
/// @param vAlign can be TOP/BOTTOM
|
||||
/// @param hAlign can be LEFT/RIGHT
|
||||
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign) {
|
||||
this.show(node, vAlign, hAlign, 0, 0);
|
||||
}
|
||||
|
||||
/// show the popup according to the specified position with a certain offset
|
||||
///
|
||||
/// @param vAlign can be TOP/BOTTOM
|
||||
/// @param hAlign can be LEFT/RIGHT
|
||||
/// @param initOffsetX on the x-axis
|
||||
/// @param initOffsetY on the y-axis
|
||||
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
|
||||
show(node, vAlign, hAlign, initOffsetX, initOffsetY, false);
|
||||
}
|
||||
|
||||
public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY, boolean attachToNode) {
|
||||
if (!isShowing()) {
|
||||
Scene scene = node.getScene();
|
||||
if (scene == null || scene.getWindow() == null) {
|
||||
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
|
||||
}
|
||||
Window parent = scene.getWindow();
|
||||
final Point2D origin = node.localToScene(0, 0);
|
||||
|
||||
boolean isRTL = node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT;
|
||||
|
||||
double anchorX = parent.getX() + scene.getX() + origin.getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0);
|
||||
double anchorY = parent.getY() + origin.getY() + scene.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0);
|
||||
|
||||
if (attachToNode)
|
||||
this.show(node, anchorX, anchorY);
|
||||
else
|
||||
this.show(parent, anchorX, anchorY);
|
||||
|
||||
((JFXPopupSkin) getSkin()).reset(vAlign, isRTL ? hAlign.getOpposite() : hAlign, isRTL ? -initOffsetX : initOffsetX, initOffsetY);
|
||||
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
|
||||
}
|
||||
}
|
||||
|
||||
public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) {
|
||||
if (!isShowing()) {
|
||||
if (window == null) {
|
||||
throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window.");
|
||||
}
|
||||
Window parent = window;
|
||||
final double anchorX = parent.getX() + x + initOffsetX;
|
||||
final double anchorY = parent.getY() + y + initOffsetY;
|
||||
this.show(parent, anchorX, anchorY);
|
||||
((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY);
|
||||
Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hide() {
|
||||
super.hide();
|
||||
((JFXPopupSkin) getSkin()).init();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Stylesheet Handling *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// Initialize the style class to 'jfx-popup'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-popup";
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.JFXProgressBarSkin;
|
||||
import javafx.scene.control.ProgressBar;
|
||||
import javafx.scene.control.Skin;
|
||||
|
||||
/// JFXProgressBar is the material design implementation of a progress bar.
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXProgressBar extends ProgressBar {
|
||||
/// Initialize the style class to 'jfx-progress-bar'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-progress-bar";
|
||||
|
||||
public JFXProgressBar() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
public JFXProgressBar(double progress) {
|
||||
super(progress);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXProgressBarSkin(this);
|
||||
}
|
||||
|
||||
private boolean smoothProgress = true;
|
||||
|
||||
public boolean isSmoothProgress() {
|
||||
return smoothProgress;
|
||||
}
|
||||
|
||||
public void setSmoothProgress(boolean smoothProgress) {
|
||||
this.smoothProgress = smoothProgress;
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
setPrefWidth(200);
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.JFXRadioButtonSkin;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.ColorConverter;
|
||||
import javafx.scene.control.RadioButton;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/// JFXRadioButton is the material design implementation of a radio button.
|
||||
///
|
||||
/// @author Bashir Elias & Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXRadioButton extends RadioButton {
|
||||
|
||||
public JFXRadioButton(String text) {
|
||||
super(text);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public JFXRadioButton() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXRadioButtonSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
/// Initialize the style class to 'jfx-radio-button'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-radio-button";
|
||||
|
||||
/// default color used when the radio button is selected
|
||||
private StyleableObjectProperty<Color> selectedColor;
|
||||
|
||||
private static final Color DEFAULT_SELECTED_COLOR = Color.valueOf("#0F9D58");
|
||||
|
||||
public final StyleableObjectProperty<Color> selectedColorProperty() {
|
||||
if (selectedColor == null) {
|
||||
selectedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.SELECTED_COLOR,
|
||||
JFXRadioButton.this,
|
||||
"selectedColor",
|
||||
DEFAULT_SELECTED_COLOR);
|
||||
}
|
||||
return this.selectedColor;
|
||||
}
|
||||
|
||||
public final Color getSelectedColor() {
|
||||
return selectedColor == null ? DEFAULT_SELECTED_COLOR : this.selectedColorProperty().get();
|
||||
}
|
||||
|
||||
public final void setSelectedColor(final Color selectedColor) {
|
||||
this.selectedColorProperty().set(selectedColor);
|
||||
}
|
||||
|
||||
/// default color used when the radio button is not selected
|
||||
private StyleableObjectProperty<Color> unSelectedColor;
|
||||
|
||||
private static final Color DEFAULT_UNSELECTED_COLOR = Color.valueOf("#5A5A5A");
|
||||
|
||||
public final StyleableObjectProperty<Color> unSelectedColorProperty() {
|
||||
if (unSelectedColor == null) {
|
||||
unSelectedColor = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.UNSELECTED_COLOR,
|
||||
JFXRadioButton.this,
|
||||
"unSelectedColor",
|
||||
DEFAULT_UNSELECTED_COLOR);
|
||||
}
|
||||
return this.unSelectedColor;
|
||||
}
|
||||
|
||||
public final Color getUnSelectedColor() {
|
||||
return unSelectedColor == null ? DEFAULT_UNSELECTED_COLOR : this.unSelectedColorProperty().get();
|
||||
}
|
||||
|
||||
public final void setUnSelectedColor(final Color unSelectedColor) {
|
||||
this.unSelectedColorProperty().set(unSelectedColor);
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXRadioButton, Color> SELECTED_COLOR =
|
||||
new CssMetaData<JFXRadioButton, Color>("-jfx-selected-color",
|
||||
ColorConverter.getInstance(), DEFAULT_SELECTED_COLOR) {
|
||||
@Override
|
||||
public boolean isSettable(JFXRadioButton control) {
|
||||
return control.selectedColor == null || !control.selectedColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Color> getStyleableProperty(JFXRadioButton control) {
|
||||
return control.selectedColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXRadioButton, Color> UNSELECTED_COLOR =
|
||||
new CssMetaData<JFXRadioButton, Color>("-jfx-unselected-color",
|
||||
ColorConverter.getInstance(), DEFAULT_UNSELECTED_COLOR) {
|
||||
@Override
|
||||
public boolean isSettable(JFXRadioButton control) {
|
||||
return control.unSelectedColor == null || !control.unSelectedColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Color> getStyleableProperty(JFXRadioButton control) {
|
||||
return control.unSelectedColorProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(RadioButton.getClassCssMetaData());
|
||||
Collections.addAll(styleables,
|
||||
SELECTED_COLOR,
|
||||
UNSELECTED_COLOR
|
||||
);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import javafx.geometry.Bounds;
|
||||
import javafx.scene.CacheHint;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.Region;
|
||||
@@ -77,14 +78,13 @@ public class JFXRippler extends StackPane {
|
||||
protected Node control;
|
||||
|
||||
protected static final double RIPPLE_MAX_RADIUS = 300;
|
||||
|
||||
private boolean enabled = true;
|
||||
private boolean forceOverlay = false;
|
||||
private final Interpolator rippleInterpolator = Interpolator.SPLINE(0.0825,
|
||||
private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825,
|
||||
0.3025,
|
||||
0.0875,
|
||||
0.9975); //0.1, 0.54, 0.28, 0.95);
|
||||
|
||||
private boolean forceOverlay = false;
|
||||
|
||||
/// creates empty rippler node
|
||||
public JFXRippler() {
|
||||
this(null, RipplerMask.RECT, RipplerPos.FRONT);
|
||||
@@ -176,10 +176,6 @@ public class JFXRippler extends StackPane {
|
||||
return control;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enable) {
|
||||
this.enabled = enable;
|
||||
}
|
||||
|
||||
// methods that can be changed by extending the rippler class
|
||||
|
||||
/// generate the clipping mask
|
||||
@@ -235,10 +231,11 @@ public class JFXRippler extends StackPane {
|
||||
*
|
||||
* @return the ripple radius size
|
||||
*/
|
||||
protected double computeRippleRadius() {
|
||||
double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth();
|
||||
double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight();
|
||||
return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5;
|
||||
protected double computeRippleRadius(double centerX, double centerY) {
|
||||
double dx = Math.max(centerX, control.getLayoutBounds().getWidth() - centerX);
|
||||
double dy = Math.max(centerY, control.getLayoutBounds().getHeight() - centerY);
|
||||
|
||||
return (Math.sqrt(dx * dx + dy * dy) / 0.9) + 2;
|
||||
}
|
||||
|
||||
protected void setOverLayBounds(Rectangle overlay) {
|
||||
@@ -255,10 +252,15 @@ public class JFXRippler extends StackPane {
|
||||
if (getChildren().contains(control)) {
|
||||
control.boundsInParentProperty().addListener(observable -> resetRippler());
|
||||
}
|
||||
control.addEventHandler(MouseEvent.MOUSE_PRESSED,
|
||||
(event) -> createRipple(event.getX(), event.getY()));
|
||||
control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
createRipple(event.getX(), event.getY());
|
||||
});
|
||||
// create fade out transition for the ripple
|
||||
control.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> releaseRipple());
|
||||
control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> {
|
||||
if (event.getButton() == MouseButton.PRIMARY)
|
||||
releaseRipple();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,25 +365,23 @@ public class JFXRippler extends StackPane {
|
||||
}
|
||||
|
||||
void createRipple() {
|
||||
if (enabled) {
|
||||
if (!generating.getAndSet(true)) {
|
||||
// create overlay once then change its color later
|
||||
createOverlay();
|
||||
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
|
||||
this.setClip(getMask());
|
||||
}
|
||||
this.resetClip = false;
|
||||
|
||||
// create the ripple effect
|
||||
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
|
||||
getChildren().add(ripple);
|
||||
ripplesQueue.add(ripple);
|
||||
|
||||
// animate the ripple
|
||||
overlayRect.outAnimation.stop();
|
||||
overlayRect.inAnimation.play();
|
||||
ripple.inAnimation.play();
|
||||
if (!generating.getAndSet(true)) {
|
||||
// create overlay once then change its color later
|
||||
createOverlay();
|
||||
if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) {
|
||||
this.setClip(getMask());
|
||||
}
|
||||
this.resetClip = false;
|
||||
|
||||
// create the ripple effect
|
||||
final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY);
|
||||
getChildren().add(ripple);
|
||||
ripplesQueue.add(ripple);
|
||||
|
||||
// animate the ripple
|
||||
overlayRect.outAnimation.stop();
|
||||
overlayRect.inAnimation.play();
|
||||
ripple.inAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,15 +415,15 @@ public class JFXRippler extends StackPane {
|
||||
overlayRect.setClip(getMask());
|
||||
getChildren().add(0, overlayRect);
|
||||
overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
if (ripplerFill.get() instanceof Color) {
|
||||
return new Color(((Color) ripplerFill.get()).getRed(),
|
||||
((Color) ripplerFill.get()).getGreen(),
|
||||
((Color) ripplerFill.get()).getBlue(),
|
||||
if (getRipplerFill() instanceof Color fill) {
|
||||
return new Color(fill.getRed(),
|
||||
fill.getGreen(),
|
||||
fill.getBlue(),
|
||||
0.2);
|
||||
} else {
|
||||
return Color.TRANSPARENT;
|
||||
}
|
||||
}, ripplerFill));
|
||||
}, ripplerFillProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,8 +473,8 @@ public class JFXRippler extends StackPane {
|
||||
private Ripple(double centerX, double centerY) {
|
||||
super(centerX,
|
||||
centerY,
|
||||
ripplerRadius.get().doubleValue() == Region.USE_COMPUTED_SIZE ?
|
||||
computeRippleRadius() : ripplerRadius.get().doubleValue(), null);
|
||||
getRipplerRadius() == Region.USE_COMPUTED_SIZE ?
|
||||
computeRippleRadius(centerX, centerY) : getRipplerRadius(), null);
|
||||
setCache(true);
|
||||
setCacheHint(CacheHint.SPEED);
|
||||
setCacheShape(true);
|
||||
@@ -484,12 +484,12 @@ public class JFXRippler extends StackPane {
|
||||
KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2];
|
||||
outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3];
|
||||
|
||||
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, rippleInterpolator);
|
||||
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, rippleInterpolator);
|
||||
inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR);
|
||||
inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR);
|
||||
|
||||
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, rippleInterpolator);
|
||||
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, rippleInterpolator);
|
||||
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, rippleInterpolator);
|
||||
outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR);
|
||||
outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR);
|
||||
outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR);
|
||||
|
||||
if (isRipplerRecenter()) {
|
||||
double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55;
|
||||
@@ -497,42 +497,42 @@ public class JFXRippler extends StackPane {
|
||||
inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(),
|
||||
Math.signum(dx) * Math.min(Math.abs(dx),
|
||||
this.getRadius() / 2),
|
||||
rippleInterpolator);
|
||||
RIPPLE_INTERPOLATOR);
|
||||
inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(),
|
||||
Math.signum(dy) * Math.min(Math.abs(dy),
|
||||
this.getRadius() / 2),
|
||||
rippleInterpolator);
|
||||
RIPPLE_INTERPOLATOR);
|
||||
}
|
||||
inAnimation = new Timeline(new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(scaleXProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(scaleYProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(translateXProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(translateYProperty(),
|
||||
0,
|
||||
rippleInterpolator),
|
||||
RIPPLE_INTERPOLATOR),
|
||||
new KeyValue(opacityProperty(),
|
||||
1,
|
||||
rippleInterpolator)
|
||||
), new KeyFrame(Duration.millis(900), inKeyValues));
|
||||
RIPPLE_INTERPOLATOR)
|
||||
), new KeyFrame(Duration.millis(600), inKeyValues));
|
||||
|
||||
setScaleX(0);
|
||||
setScaleY(0);
|
||||
if (ripplerFill.get() instanceof Color) {
|
||||
Color circleColor = new Color(((Color) ripplerFill.get()).getRed(),
|
||||
((Color) ripplerFill.get()).getGreen(),
|
||||
((Color) ripplerFill.get()).getBlue(),
|
||||
if (getRipplerFill() instanceof Color fill) {
|
||||
Color circleColor = new Color(fill.getRed(),
|
||||
fill.getGreen(),
|
||||
fill.getBlue(),
|
||||
0.3);
|
||||
setStroke(circleColor);
|
||||
setFill(circleColor);
|
||||
} else {
|
||||
setStroke(ripplerFill.get());
|
||||
setFill(ripplerFill.get());
|
||||
setStroke(getRipplerFill());
|
||||
setFill(getRipplerFill());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,110 +585,125 @@ public class JFXRippler extends StackPane {
|
||||
* the ripple recenter property, by default it's false.
|
||||
* if true the ripple effect will show gravitational pull to the center of its control
|
||||
*/
|
||||
private final StyleableObjectProperty<Boolean> ripplerRecenter = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.RIPPLER_RECENTER,
|
||||
JFXRippler.this,
|
||||
"ripplerRecenter",
|
||||
false);
|
||||
private StyleableBooleanProperty ripplerRecenter;
|
||||
|
||||
public Boolean isRipplerRecenter() {
|
||||
public boolean isRipplerRecenter() {
|
||||
return ripplerRecenter != null && ripplerRecenter.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Boolean> ripplerRecenterProperty() {
|
||||
public StyleableBooleanProperty ripplerRecenterProperty() {
|
||||
if (this.ripplerRecenter == null) {
|
||||
this.ripplerRecenter = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_RECENTER,
|
||||
JFXRippler.this,
|
||||
"ripplerRecenter",
|
||||
false);
|
||||
}
|
||||
return this.ripplerRecenter;
|
||||
}
|
||||
|
||||
public void setRipplerRecenter(Boolean radius) {
|
||||
this.ripplerRecenter.set(radius);
|
||||
public void setRipplerRecenter(boolean recenter) {
|
||||
ripplerRecenterProperty().set(recenter);
|
||||
}
|
||||
|
||||
/**
|
||||
* the ripple radius size, by default it will be automatically computed.
|
||||
*/
|
||||
private final StyleableObjectProperty<Number> ripplerRadius = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.RIPPLER_RADIUS,
|
||||
JFXRippler.this,
|
||||
"ripplerRadius",
|
||||
Region.USE_COMPUTED_SIZE);
|
||||
private StyleableDoubleProperty ripplerRadius;
|
||||
|
||||
public Number getRipplerRadius() {
|
||||
public double getRipplerRadius() {
|
||||
return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Number> ripplerRadiusProperty() {
|
||||
public StyleableDoubleProperty ripplerRadiusProperty() {
|
||||
if (this.ripplerRadius == null) {
|
||||
this.ripplerRadius = new SimpleStyleableDoubleProperty(
|
||||
StyleableProperties.RIPPLER_RADIUS,
|
||||
JFXRippler.this,
|
||||
"ripplerRadius",
|
||||
Region.USE_COMPUTED_SIZE);
|
||||
}
|
||||
return this.ripplerRadius;
|
||||
}
|
||||
|
||||
public void setRipplerRadius(Number radius) {
|
||||
this.ripplerRadius.set(radius);
|
||||
public void setRipplerRadius(double radius) {
|
||||
ripplerRadiusProperty().set(radius);
|
||||
}
|
||||
|
||||
private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255);
|
||||
|
||||
/**
|
||||
* the default color of the ripple effect
|
||||
*/
|
||||
private final StyleableObjectProperty<Paint> ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
|
||||
JFXRippler.this,
|
||||
"ripplerFill",
|
||||
Color.rgb(0,
|
||||
200,
|
||||
255));
|
||||
private StyleableObjectProperty<Paint> ripplerFill;
|
||||
|
||||
public Paint getRipplerFill() {
|
||||
return ripplerFill == null ? Color.rgb(0, 200, 255) : ripplerFill.get();
|
||||
return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> ripplerFillProperty() {
|
||||
if (this.ripplerFill == null) {
|
||||
this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL,
|
||||
JFXRippler.this,
|
||||
"ripplerFill",
|
||||
DEFAULT_RIPPLER_FILL);
|
||||
}
|
||||
return this.ripplerFill;
|
||||
}
|
||||
|
||||
public void setRipplerFill(Paint color) {
|
||||
this.ripplerFill.set(color);
|
||||
ripplerFillProperty().set(color);
|
||||
}
|
||||
|
||||
/// mask property used for clipping the rippler.
|
||||
/// can be either CIRCLE/RECT
|
||||
private final StyleableObjectProperty<RipplerMask> maskType = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.MASK_TYPE,
|
||||
JFXRippler.this,
|
||||
"maskType",
|
||||
RipplerMask.RECT);
|
||||
private StyleableObjectProperty<RipplerMask> maskType;
|
||||
|
||||
public RipplerMask getMaskType() {
|
||||
return maskType == null ? RipplerMask.RECT : maskType.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<RipplerMask> maskTypeProperty() {
|
||||
if (this.maskType == null) {
|
||||
this.maskType = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.MASK_TYPE,
|
||||
JFXRippler.this,
|
||||
"maskType",
|
||||
RipplerMask.RECT);
|
||||
}
|
||||
return this.maskType;
|
||||
}
|
||||
|
||||
public void setMaskType(RipplerMask type) {
|
||||
this.maskType.set(type);
|
||||
if (this.maskType != null || type != RipplerMask.RECT)
|
||||
maskTypeProperty().set(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* the ripple disable, by default it's false.
|
||||
* if true the ripple effect will be hidden
|
||||
*/
|
||||
private final StyleableBooleanProperty ripplerDisabled = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_DISABLED,
|
||||
JFXRippler.this,
|
||||
"ripplerDisabled",
|
||||
false);
|
||||
private StyleableBooleanProperty ripplerDisabled;
|
||||
|
||||
public Boolean isRipplerDisabled() {
|
||||
public boolean isRipplerDisabled() {
|
||||
return ripplerDisabled != null && ripplerDisabled.get();
|
||||
}
|
||||
|
||||
public StyleableBooleanProperty ripplerDisabledProperty() {
|
||||
if (this.ripplerDisabled == null) {
|
||||
this.ripplerDisabled = new SimpleStyleableBooleanProperty(
|
||||
StyleableProperties.RIPPLER_DISABLED,
|
||||
JFXRippler.this,
|
||||
"ripplerDisabled",
|
||||
false);
|
||||
}
|
||||
return this.ripplerDisabled;
|
||||
}
|
||||
|
||||
public void setRipplerDisabled(Boolean disabled) {
|
||||
this.ripplerDisabled.set(disabled);
|
||||
public void setRipplerDisabled(boolean disabled) {
|
||||
ripplerDisabledProperty().set(disabled);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* indicates whether the ripple effect is infront of or behind the node
|
||||
*/
|
||||
@@ -735,7 +750,7 @@ public class JFXRippler extends StackPane {
|
||||
};
|
||||
private static final CssMetaData<JFXRippler, Paint> RIPPLER_FILL =
|
||||
new CssMetaData<>("-jfx-rippler-fill",
|
||||
PaintConverter.getInstance(), Color.rgb(0, 200, 255)) {
|
||||
PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) {
|
||||
@Override
|
||||
public boolean isSettable(JFXRippler control) {
|
||||
return control.ripplerFill == null || !control.ripplerFill.isBound();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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.converters.IndicatorPositionConverter;
|
||||
import com.jfoenix.skins.JFXSliderSkin;
|
||||
import javafx.beans.binding.StringBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.*;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.util.Callback;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/// JFXSlider is the material design implementation of a slider.
|
||||
///
|
||||
/// @author Bashir Elias & Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXSlider extends Slider {
|
||||
|
||||
public JFXSlider() {
|
||||
super(0, 100, 50);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public JFXSlider(double min, double max, double value) {
|
||||
super(min, max, value);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXSliderSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
public enum IndicatorPosition {
|
||||
LEFT, RIGHT
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// String binding factory for the slider value.
|
||||
/// Sets a custom string for the value text (by default, it shows the value rounded to the nearest whole number).
|
||||
///
|
||||
///
|
||||
/// For example, to have the value displayed as a percentage (assuming the slider has a range of (0, 100)):
|
||||
/// ```java
|
||||
/// JFXSlider mySlider = ...
|
||||
/// mySlider.setValueFactory(slider ->
|
||||
/// Bindings.createStringBinding(
|
||||
/// () -> ((int) slider.getValue()) + "%",
|
||||
/// slider.valueProperty()
|
||||
/// )
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// NOTE: might be replaced later with a call back to create the animated thumb node
|
||||
///
|
||||
/// @param callback a callback to create the string value binding
|
||||
private ObjectProperty<Callback<JFXSlider, StringBinding>> valueFactory;
|
||||
|
||||
public final ObjectProperty<Callback<JFXSlider, StringBinding>> valueFactoryProperty() {
|
||||
if (valueFactory == null) {
|
||||
valueFactory = new SimpleObjectProperty<>(this, "valueFactory");
|
||||
}
|
||||
return valueFactory;
|
||||
}
|
||||
|
||||
/// @return the current slider value factory
|
||||
public final Callback<JFXSlider, StringBinding> getValueFactory() {
|
||||
return valueFactory == null ? null : valueFactory.get();
|
||||
}
|
||||
|
||||
/// sets custom string binding for the slider text value
|
||||
///
|
||||
/// @param valueFactory a callback to create the string value binding
|
||||
public final void setValueFactory(final Callback<JFXSlider, StringBinding> valueFactory) {
|
||||
this.valueFactoryProperty().set(valueFactory);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Stylesheet Handling *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// Initialize the style class to 'jfx-slider'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-slider";
|
||||
|
||||
/// indicates the position of the slider indicator, can be
|
||||
/// either LEFT or RIGHT
|
||||
private StyleableObjectProperty<IndicatorPosition> indicatorPosition;
|
||||
|
||||
public StyleableObjectProperty<IndicatorPosition> indicatorPositionProperty() {
|
||||
if (indicatorPosition == null) {
|
||||
indicatorPosition = new SimpleStyleableObjectProperty<>(
|
||||
StyleableProperties.INDICATOR_POSITION,
|
||||
JFXSlider.this,
|
||||
"indicatorPosition",
|
||||
IndicatorPosition.LEFT);
|
||||
}
|
||||
return this.indicatorPosition;
|
||||
}
|
||||
|
||||
public IndicatorPosition getIndicatorPosition() {
|
||||
return indicatorPosition == null ? IndicatorPosition.LEFT : indicatorPosition.get();
|
||||
}
|
||||
|
||||
public void setIndicatorPosition(IndicatorPosition pos) {
|
||||
indicatorPositionProperty().set(pos);
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXSlider, IndicatorPosition> INDICATOR_POSITION = new CssMetaData<>(
|
||||
"-jfx-indicator-position",
|
||||
IndicatorPositionConverter.getInstance(),
|
||||
IndicatorPosition.LEFT) {
|
||||
@Override
|
||||
public boolean isSettable(JFXSlider control) {
|
||||
return control.indicatorPosition == null || !control.indicatorPosition.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<IndicatorPosition> getStyleableProperty(JFXSlider control) {
|
||||
return control.indicatorPositionProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(
|
||||
Slider.getClassCssMetaData());
|
||||
Collections.addAll(styleables, INDICATOR_POSITION);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* 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 javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.WeakChangeListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventType;
|
||||
import javafx.geometry.Bounds;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
|
||||
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
|
||||
///
|
||||
/// To show a snackbar you need to
|
||||
/// <ol>
|
||||
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
|
||||
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
|
||||
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
|
||||
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
|
||||
///
|
||||
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
|
||||
/// but any arbitrary [Node] will do.
|
||||
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
|
||||
/// duration.
|
||||
/// </ol>
|
||||
///
|
||||
/// Finally, with all those things prepared, show your snackbar using
|
||||
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
|
||||
///
|
||||
/// It's most convenient to create functions to do most of this (creating the layout and event) with the default
|
||||
/// settings; that way all you need to do to show a snackbar is specify the message or just the message and the duration.
|
||||
///
|
||||
/// @see <a href="https://material.io/design/components/snackbars.html#"> The Material Design Snackbar</a>
|
||||
public class JFXSnackbar extends Group {
|
||||
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar";
|
||||
|
||||
private Pane snackbarContainer;
|
||||
private final ChangeListener<? super Number> sizeListener = (o, oldVal, newVal) -> refreshPopup();
|
||||
private final WeakChangeListener<? super Number> weakSizeListener = new WeakChangeListener<>(sizeListener);
|
||||
|
||||
private final AtomicBoolean processingQueue = new AtomicBoolean(false);
|
||||
private final ConcurrentLinkedQueue<SnackbarEvent> eventQueue = new ConcurrentLinkedQueue<>();
|
||||
private final ConcurrentHashMap.KeySetView<Object, Boolean> eventsSet = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final Pane content;
|
||||
private PseudoClass activePseudoClass = null;
|
||||
private PauseTransition pauseTransition;
|
||||
|
||||
/// This constructor assumes that you will eventually call the [#registerSnackbarContainer(Pane)] method before
|
||||
/// calling the [#enqueue(SnackbarEvent)] method. Otherwise, how will the snackbar know where to show itself?
|
||||
///
|
||||
///
|
||||
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
|
||||
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
|
||||
///
|
||||
/// To show a snackbar you need to
|
||||
///
|
||||
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
|
||||
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
|
||||
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
|
||||
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
|
||||
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
|
||||
/// but any arbitrary [Node] will do.
|
||||
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
|
||||
/// duration.
|
||||
///
|
||||
/// Finally, with all those things prepared, show your snackbar using
|
||||
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
|
||||
///
|
||||
public JFXSnackbar() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/// "Snackbars provide brief messages about app processes at the bottom of the screen"
|
||||
/// (<a href="https://material.io/design/components/snackbars.html#">Material Design Guidelines</a>).
|
||||
///
|
||||
/// To show a snackbar you need to
|
||||
///
|
||||
/// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in
|
||||
/// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method.
|
||||
/// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your
|
||||
/// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended.
|
||||
/// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty,
|
||||
/// but any arbitrary [Node] will do.
|
||||
/// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the
|
||||
/// duration.
|
||||
///
|
||||
/// Finally, with all those things prepared, show your snackbar using
|
||||
/// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)].
|
||||
///
|
||||
/// @param snackbarContainer where the snackbar will appear. Using a single snackbar instead of many, will ensure that
|
||||
/// the [#enqueue(SnackbarEvent)] method works correctly.
|
||||
public JFXSnackbar(Pane snackbarContainer) {
|
||||
initialize();
|
||||
content = new StackPane();
|
||||
content.getStyleClass().add("jfx-snackbar-content");
|
||||
//wrap the content in a group so that the content is managed inside its own container
|
||||
//but the group is not managed in the snackbarContainer so it does not affect any layout calculations
|
||||
getChildren().add(content);
|
||||
setManaged(false);
|
||||
setVisible(false);
|
||||
|
||||
// register the container before resizing it
|
||||
registerSnackbarContainer(snackbarContainer);
|
||||
|
||||
// resize the popup if its layout has been changed
|
||||
layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup());
|
||||
|
||||
addEventHandler(SnackbarEvent.SNACKBAR, this::enqueue);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
// Setters / Getters
|
||||
|
||||
public Pane getPopupContainer() {
|
||||
return snackbarContainer;
|
||||
}
|
||||
|
||||
public void setPrefWidth(double width) {
|
||||
content.setPrefWidth(width);
|
||||
}
|
||||
|
||||
public double getPrefWidth() {
|
||||
return content.getPrefWidth();
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
public void registerSnackbarContainer(Pane snackbarContainer) {
|
||||
if (snackbarContainer != null) {
|
||||
if (this.snackbarContainer != null) {
|
||||
//since listeners are added the container should be properly registered/unregistered
|
||||
throw new IllegalArgumentException("Snackbar Container already set");
|
||||
}
|
||||
this.snackbarContainer = snackbarContainer;
|
||||
this.snackbarContainer.getChildren().add(this);
|
||||
this.snackbarContainer.heightProperty().addListener(weakSizeListener);
|
||||
this.snackbarContainer.widthProperty().addListener(weakSizeListener);
|
||||
}
|
||||
}
|
||||
|
||||
public void unregisterSnackbarContainer(Pane snackbarContainer) {
|
||||
if (snackbarContainer != null) {
|
||||
if (this.snackbarContainer == null) {
|
||||
throw new IllegalArgumentException("Snackbar Container not set");
|
||||
}
|
||||
this.snackbarContainer.getChildren().remove(this);
|
||||
this.snackbarContainer.heightProperty().removeListener(weakSizeListener);
|
||||
this.snackbarContainer.widthProperty().removeListener(weakSizeListener);
|
||||
this.snackbarContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void show(SnackbarEvent event) {
|
||||
content.getChildren().setAll(event.getContent());
|
||||
openAnimation = getTimeline(event.getTimeout());
|
||||
if (event.getPseudoClass() != null) {
|
||||
activePseudoClass = event.getPseudoClass();
|
||||
content.pseudoClassStateChanged(activePseudoClass, true);
|
||||
}
|
||||
openAnimation.play();
|
||||
}
|
||||
|
||||
private Timeline openAnimation = null;
|
||||
|
||||
private Timeline getTimeline(Duration timeout) {
|
||||
Timeline animation;
|
||||
animation = new Timeline(
|
||||
new KeyFrame(
|
||||
Duration.ZERO,
|
||||
e -> {
|
||||
this.toBack();
|
||||
this.setVisible(false);
|
||||
},
|
||||
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
|
||||
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
|
||||
),
|
||||
new KeyFrame(
|
||||
Duration.millis(10),
|
||||
e -> {
|
||||
this.toFront();
|
||||
this.setVisible(true);
|
||||
}
|
||||
),
|
||||
new KeyFrame(Duration.millis(300),
|
||||
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
|
||||
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
|
||||
)
|
||||
);
|
||||
animation.setCycleCount(1);
|
||||
pauseTransition = Duration.INDEFINITE.equals(timeout) ? null : new PauseTransition(timeout);
|
||||
if (pauseTransition != null) {
|
||||
animation.setOnFinished(finish -> {
|
||||
pauseTransition.setOnFinished(done -> {
|
||||
pauseTransition = null;
|
||||
eventsSet.remove(currentEvent);
|
||||
currentEvent = eventQueue.peek();
|
||||
close();
|
||||
});
|
||||
pauseTransition.play();
|
||||
});
|
||||
}
|
||||
return animation;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (openAnimation != null) {
|
||||
openAnimation.stop();
|
||||
}
|
||||
if (this.isVisible()) {
|
||||
Timeline closeAnimation = new Timeline(
|
||||
new KeyFrame(
|
||||
Duration.ZERO,
|
||||
e -> this.toFront(),
|
||||
new KeyValue(this.opacityProperty(), 1, Motion.EASE),
|
||||
new KeyValue(this.translateYProperty(), 0, Motion.EASE)
|
||||
),
|
||||
new KeyFrame(
|
||||
Duration.millis(290),
|
||||
e -> this.setVisible(true)
|
||||
),
|
||||
new KeyFrame(Duration.millis(300),
|
||||
e -> {
|
||||
this.toBack();
|
||||
this.setVisible(false);
|
||||
},
|
||||
new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE),
|
||||
new KeyValue(this.opacityProperty(), 0, Motion.EASE)
|
||||
)
|
||||
);
|
||||
closeAnimation.setCycleCount(1);
|
||||
closeAnimation.setOnFinished(e -> {
|
||||
resetPseudoClass();
|
||||
processSnackbar();
|
||||
});
|
||||
closeAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
private SnackbarEvent currentEvent = null;
|
||||
|
||||
public SnackbarEvent getCurrentEvent() {
|
||||
return currentEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows {@link SnackbarEvent SnackbarEvents} one by one. The next event will be shown after the current event's duration.
|
||||
*
|
||||
* @param event the {@link SnackbarEvent event} to put in the queue.
|
||||
*/
|
||||
public void enqueue(SnackbarEvent event) {
|
||||
synchronized (this) {
|
||||
if (!eventsSet.contains(event)) {
|
||||
eventsSet.add(event);
|
||||
eventQueue.offer(event);
|
||||
} else if (currentEvent == event && pauseTransition != null) {
|
||||
pauseTransition.playFromStart();
|
||||
}
|
||||
}
|
||||
if (processingQueue.compareAndSet(false, true)) {
|
||||
Platform.runLater(() -> {
|
||||
currentEvent = eventQueue.poll();
|
||||
if (currentEvent != null) {
|
||||
show(currentEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void resetPseudoClass() {
|
||||
if (activePseudoClass != null) {
|
||||
content.pseudoClassStateChanged(activePseudoClass, false);
|
||||
activePseudoClass = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void processSnackbar() {
|
||||
currentEvent = eventQueue.poll();
|
||||
if (currentEvent != null) {
|
||||
eventsSet.remove(currentEvent);
|
||||
show(currentEvent);
|
||||
} else {
|
||||
//The enqueue method and this listener should be executed sequentially on the FX Thread so there
|
||||
//should not be a race condition
|
||||
processingQueue.getAndSet(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshPopup() {
|
||||
if (snackbarContainer == null) {
|
||||
return;
|
||||
}
|
||||
Bounds contentBound = this.getLayoutBounds();
|
||||
double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2);
|
||||
double offsetY = snackbarContainer.getHeight() - contentBound.getHeight();
|
||||
this.setLayoutX(offsetX);
|
||||
this.setLayoutY(offsetY);
|
||||
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Event API
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/// Specifies _what_ and _how long_ to show a [JFXSnackbar].
|
||||
///
|
||||
/// The _what_ can be any arbitrary [Node]; the [JFXSnackbarLayout] is a great choice.
|
||||
///
|
||||
/// The _how long_ is specified in the form of a [javafx.util.Duration][javafx.util.Duration], not to be
|
||||
/// confused with the [java.time.Duration].
|
||||
public static class SnackbarEvent extends Event {
|
||||
|
||||
public static final EventType<SnackbarEvent> SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR");
|
||||
|
||||
/// The amount of time the snackbar will show for, if not otherwise specified.
|
||||
///
|
||||
/// It's 1.5 seconds.
|
||||
public static Duration DEFAULT_DURATION = Duration.seconds(1.5);
|
||||
|
||||
private final Node content;
|
||||
private final PseudoClass pseudoClass;
|
||||
private final Duration timeout;
|
||||
|
||||
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION] and no pseudoClass.
|
||||
///
|
||||
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
|
||||
public SnackbarEvent(Node content) {
|
||||
this(content, DEFAULT_DURATION, null);
|
||||
}
|
||||
|
||||
/// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION]; you specify the contents and
|
||||
/// pseudoClass.
|
||||
///
|
||||
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
|
||||
public SnackbarEvent(Node content, PseudoClass pseudoClass) {
|
||||
this(content, DEFAULT_DURATION, pseudoClass);
|
||||
}
|
||||
|
||||
/// Creates a SnackbarEvent with no pseudoClass; you specify the contents and duration.
|
||||
/// pseudoClass.
|
||||
///
|
||||
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
|
||||
/// @param timeout the amount of time you want the snackbar to show for.
|
||||
public SnackbarEvent(Node content, Duration timeout) {
|
||||
this(content, timeout, null);
|
||||
}
|
||||
|
||||
/// Creates a SnackbarEvent; you specify the contents, duration and pseudoClass.
|
||||
///
|
||||
/// If you don't need so much customization, try one of the other constructors.
|
||||
///
|
||||
/// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice.
|
||||
/// @param timeout the amount of time you want the snackbar to show for.
|
||||
public SnackbarEvent(Node content, Duration timeout, PseudoClass pseudoClass) {
|
||||
super(SNACKBAR);
|
||||
this.content = content;
|
||||
this.pseudoClass = pseudoClass;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public Node getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public PseudoClass getPseudoClass() {
|
||||
return pseudoClass;
|
||||
}
|
||||
|
||||
public Duration getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public EventType<? extends SnackbarEvent> getEventType() {
|
||||
return (EventType<? extends SnackbarEvent>) super.getEventType();
|
||||
}
|
||||
|
||||
public boolean isPersistent() {
|
||||
return Duration.INDEFINITE.equals(getTimeout());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 javafx.beans.binding.Bindings;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
/// JFXSnackbarLayout default layout for snackbar content
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2018-11-16
|
||||
public class JFXSnackbarLayout extends BorderPane {
|
||||
|
||||
private final Label toast;
|
||||
private JFXButton action;
|
||||
private final StackPane actionContainer;
|
||||
|
||||
public JFXSnackbarLayout(String message) {
|
||||
this(message, null, null);
|
||||
}
|
||||
|
||||
public JFXSnackbarLayout(String message, String actionText, EventHandler<ActionEvent> actionHandler) {
|
||||
initialize();
|
||||
|
||||
toast = new Label();
|
||||
toast.setMinWidth(Control.USE_PREF_SIZE);
|
||||
toast.getStyleClass().add("jfx-snackbar-toast");
|
||||
toast.setWrapText(true);
|
||||
toast.setText(message);
|
||||
StackPane toastContainer = new StackPane(toast);
|
||||
toastContainer.setPadding(new Insets(20));
|
||||
actionContainer = new StackPane();
|
||||
actionContainer.setVisible(false);
|
||||
actionContainer.setManaged(false);
|
||||
|
||||
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
||||
if (getPrefWidth() == -1) {
|
||||
return getPrefWidth();
|
||||
}
|
||||
double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0;
|
||||
return prefWidthProperty().get() - actionWidth;
|
||||
}, prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty()));
|
||||
|
||||
setLeft(toastContainer);
|
||||
setRight(actionContainer);
|
||||
|
||||
if (actionText != null) {
|
||||
action = new JFXButton();
|
||||
action.setText(actionText);
|
||||
action.setOnAction(actionHandler);
|
||||
action.setMinWidth(Control.USE_PREF_SIZE);
|
||||
action.setButtonType(JFXButton.ButtonType.FLAT);
|
||||
action.getStyleClass().add("jfx-snackbar-action");
|
||||
// actions will be added upon showing the snackbar if needed
|
||||
actionContainer.getChildren().add(action);
|
||||
|
||||
if (!actionText.isEmpty()) {
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
action.setVisible(true);
|
||||
actionContainer.setVisible(true);
|
||||
actionContainer.setManaged(true);
|
||||
// to force updating the layout bounds
|
||||
action.setText("");
|
||||
action.setText(actionText);
|
||||
action.setOnAction(actionHandler);
|
||||
} else {
|
||||
actionContainer.setVisible(false);
|
||||
actionContainer.setManaged(false);
|
||||
action.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar-layout";
|
||||
|
||||
public String getToast() {
|
||||
return toast.getText();
|
||||
}
|
||||
|
||||
public void setToast(String toast) {
|
||||
this.toast.setText(toast);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.JFXSpinnerSkin;
|
||||
import javafx.css.CssMetaData;
|
||||
import javafx.css.SimpleStyleableDoubleProperty;
|
||||
import javafx.css.Styleable;
|
||||
import javafx.css.StyleableDoubleProperty;
|
||||
import javafx.css.converter.SizeConverter;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.Skin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/// JFXSpinner is the material design implementation of a loading spinner.
|
||||
///
|
||||
/// @author Bashir Elias & Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXSpinner extends ProgressIndicator {
|
||||
|
||||
public static final double INDETERMINATE_PROGRESS = ProgressIndicator.INDETERMINATE_PROGRESS;
|
||||
|
||||
public JFXSpinner() {
|
||||
this(INDETERMINATE_PROGRESS);
|
||||
}
|
||||
|
||||
public JFXSpinner(double progress) {
|
||||
super(progress);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXSpinnerSkin(this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Stylesheet Handling *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/// Initialize the style class to 'jfx-spinner'.
|
||||
///
|
||||
/// This is the selector class from which CSS can be used to style
|
||||
/// this control.
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-spinner";
|
||||
|
||||
private static final double DEFAULT_RADIUS = 16.0;
|
||||
|
||||
/**
|
||||
* specifies the radius of the spinner node, by default it's set to `16.0`
|
||||
*/
|
||||
private StyleableDoubleProperty radius;
|
||||
|
||||
public final StyleableDoubleProperty radiusProperty() {
|
||||
if (this.radius == null) {
|
||||
this.radius = new SimpleStyleableDoubleProperty(StyleableProperties.RADIUS,
|
||||
JFXSpinner.this,
|
||||
"radius",
|
||||
DEFAULT_RADIUS);
|
||||
}
|
||||
return this.radius;
|
||||
}
|
||||
|
||||
public final double getRadius() {
|
||||
return radius != null ? radius.get() : DEFAULT_RADIUS;
|
||||
}
|
||||
|
||||
public final void setRadius(final double radius) {
|
||||
this.radiusProperty().set(radius);
|
||||
}
|
||||
|
||||
public double getStartingAngle() {
|
||||
return 360 - ThreadLocalRandom.current().nextDouble() * 720;
|
||||
}
|
||||
|
||||
private static final class StyleableProperties {
|
||||
private static final CssMetaData<JFXSpinner, Number> RADIUS =
|
||||
new CssMetaData<>("-jfx-radius",
|
||||
SizeConverter.getInstance(), DEFAULT_RADIUS) {
|
||||
@Override
|
||||
public boolean isSettable(JFXSpinner control) {
|
||||
return control.radius == null || !control.radius.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) {
|
||||
return control.radiusProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables =
|
||||
new ArrayList<>(ProgressIndicator.getClassCssMetaData());
|
||||
Collections.addAll(styleables, RADIUS);
|
||||
CHILD_STYLEABLES = List.copyOf(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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.JFXTextAreaSkin;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
/**
|
||||
* JFXTextArea is the material design implementation of a text area.
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXTextArea extends TextArea {
|
||||
/**
|
||||
* Initialize the style class to 'jfx-text-field'.
|
||||
* <p>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-text-area";
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXTextArea() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXTextArea(String text) {
|
||||
super(text);
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXTextAreaSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
|
||||
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextAreaSkinAndroid\";");
|
||||
}
|
||||
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* holds the current active validator on the text area in case of validation error
|
||||
*/
|
||||
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
public ValidatorBase getActiveValidator() {
|
||||
return activeValidator == null ? null : activeValidator.get();
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
|
||||
return this.activeValidator.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* list of validators that will validate the text value upon calling
|
||||
* {{@link #validate()}
|
||||
*/
|
||||
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<ValidatorBase> getValidators() {
|
||||
return validators;
|
||||
}
|
||||
|
||||
public void setValidators(ValidatorBase... validators) {
|
||||
this.validators.addAll(validators);
|
||||
}
|
||||
|
||||
/**
|
||||
* validates the text value using the list of validators provided by the user
|
||||
* {{@link #setValidators(ValidatorBase...)}
|
||||
*
|
||||
* @return true if the value is valid else false
|
||||
*/
|
||||
public boolean validate() {
|
||||
for (ValidatorBase validator : validators) {
|
||||
if (validator.getSrcControl() == null) {
|
||||
validator.setSrcControl(this);
|
||||
}
|
||||
validator.validate();
|
||||
if (validator.getHasErrors()) {
|
||||
activeValidator.set(validator);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
activeValidator.set(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void resetValidation() {
|
||||
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
|
||||
activeValidator.set(null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* styleable Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* set true to show a float the prompt text when focusing the field
|
||||
*/
|
||||
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextArea.this, "lableFloat", false);
|
||||
|
||||
public final StyleableBooleanProperty labelFloatProperty() {
|
||||
return this.labelFloat;
|
||||
}
|
||||
|
||||
public final boolean isLabelFloat() {
|
||||
return this.labelFloatProperty().get();
|
||||
}
|
||||
|
||||
public final void setLabelFloat(final boolean labelFloat) {
|
||||
this.labelFloatProperty().set(labelFloat);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the text area is unfocused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextArea.this, "unFocusColor", Color.rgb(77, 77, 77));
|
||||
|
||||
public Paint getUnFocusColor() {
|
||||
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unFocusColorProperty() {
|
||||
return this.unFocusColor;
|
||||
}
|
||||
|
||||
public void setUnFocusColor(Paint color) {
|
||||
this.unFocusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the text area is focused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextArea.this, "focusColor", Color.valueOf("#4059A9"));
|
||||
|
||||
public Paint getFocusColor() {
|
||||
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> focusColorProperty() {
|
||||
return this.focusColor;
|
||||
}
|
||||
|
||||
public void setFocusColor(Paint color) {
|
||||
this.focusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* disable animation on validation
|
||||
*/
|
||||
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextArea.this, "disableAnimation", false);
|
||||
|
||||
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 final static class StyleableProperties {
|
||||
private static final CssMetaData<JFXTextArea, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.rgb(77, 77, 77)) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextArea control) {
|
||||
return control.unFocusColor == null || !control.unFocusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
|
||||
return control.unFocusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXTextArea, Paint> FOCUS_COLOR = new CssMetaData<JFXTextArea, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#4059A9")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextArea control) {
|
||||
return control.focusColor == null || !control.focusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXTextArea control) {
|
||||
return control.focusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXTextArea, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextArea, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextArea control) {
|
||||
return control.labelFloat == null || !control.labelFloat.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
|
||||
return control.labelFloatProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXTextArea, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextArea, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextArea control) {
|
||||
return control.disableAnimation == null || !control.disableAnimation.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) {
|
||||
return control.disableAnimationProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
|
||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
// inherit the styleable properties from parent
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (STYLEABLES == null) {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(TextArea.getClassCssMetaData());
|
||||
STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
return STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* 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.JFXTextFieldSkin;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.*;
|
||||
import javafx.css.converter.BooleanConverter;
|
||||
import javafx.css.converter.PaintConverter;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
/**
|
||||
* JFXTextField is the material design implementation of a text Field.
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public class JFXTextField extends TextField {
|
||||
/**
|
||||
* Initialize the style class to 'jfx-text-field'.
|
||||
* <p>
|
||||
* This is the selector class from which CSS can be used to style
|
||||
* this control.
|
||||
*/
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-text-field";
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXTextField() {
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public JFXTextField(String text) {
|
||||
super(text);
|
||||
initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new JFXTextFieldSkin(this);
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) {
|
||||
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextFieldSkinAndroid\";");
|
||||
}
|
||||
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/5822
|
||||
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* holds the current active validator on the text field in case of validation error
|
||||
*/
|
||||
private ReadOnlyObjectWrapper<ValidatorBase> activeValidator = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
public ValidatorBase getActiveValidator() {
|
||||
return activeValidator == null ? null : activeValidator.get();
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<ValidatorBase> activeValidatorProperty() {
|
||||
return this.activeValidator.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* list of validators that will validate the text value upon calling
|
||||
* {{@link #validate()}
|
||||
*/
|
||||
private ObservableList<ValidatorBase> validators = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<ValidatorBase> getValidators() {
|
||||
return validators;
|
||||
}
|
||||
|
||||
public void setValidators(ValidatorBase... validators) {
|
||||
this.validators.addAll(validators);
|
||||
}
|
||||
|
||||
/**
|
||||
* validates the text value using the list of validators provided by the user
|
||||
* {{@link #setValidators(ValidatorBase...)}
|
||||
*
|
||||
* @return true if the value is valid else false
|
||||
*/
|
||||
public boolean validate() {
|
||||
for (ValidatorBase validator : validators) {
|
||||
if (validator.getSrcControl() == null) {
|
||||
validator.setSrcControl(this);
|
||||
}
|
||||
validator.validate();
|
||||
if (validator.getHasErrors()) {
|
||||
activeValidator.set(validator);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
activeValidator.set(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void resetValidation() {
|
||||
pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false);
|
||||
activeValidator.set(null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* *
|
||||
* styleable Properties *
|
||||
* *
|
||||
**************************************************************************/
|
||||
|
||||
/**
|
||||
* set true to show a float the prompt text when focusing the field
|
||||
*/
|
||||
private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextField.this, "lableFloat", false);
|
||||
|
||||
public final StyleableBooleanProperty labelFloatProperty() {
|
||||
return this.labelFloat;
|
||||
}
|
||||
|
||||
public final boolean isLabelFloat() {
|
||||
return this.labelFloatProperty().get();
|
||||
}
|
||||
|
||||
public final void setLabelFloat(final boolean labelFloat) {
|
||||
this.labelFloatProperty().set(labelFloat);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is unfocused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextField.this, "unFocusColor", Color.rgb(77, 77, 77));
|
||||
|
||||
public Paint getUnFocusColor() {
|
||||
return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> unFocusColorProperty() {
|
||||
return this.unFocusColor;
|
||||
}
|
||||
|
||||
public void setUnFocusColor(Paint color) {
|
||||
this.unFocusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* default color used when the field is focused
|
||||
*/
|
||||
private StyleableObjectProperty<Paint> focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextField.this, "focusColor", Color.valueOf("#4059A9"));
|
||||
|
||||
public Paint getFocusColor() {
|
||||
return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get();
|
||||
}
|
||||
|
||||
public StyleableObjectProperty<Paint> focusColorProperty() {
|
||||
return this.focusColor;
|
||||
}
|
||||
|
||||
public void setFocusColor(Paint color) {
|
||||
this.focusColor.set(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* disable animation on validation
|
||||
*/
|
||||
private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextField.this, "disableAnimation", false);
|
||||
|
||||
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 final static class StyleableProperties {
|
||||
private static final CssMetaData<JFXTextField, Paint> UNFOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextField control) {
|
||||
return control.unFocusColor == null || !control.unFocusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
|
||||
return control.unFocusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXTextField, Paint> FOCUS_COLOR = new CssMetaData<JFXTextField, Paint>("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextField control) {
|
||||
return control.focusColor == null || !control.focusColor.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableProperty<Paint> getStyleableProperty(JFXTextField control) {
|
||||
return control.focusColorProperty();
|
||||
}
|
||||
};
|
||||
private static final CssMetaData<JFXTextField, Boolean> LABEL_FLOAT = new CssMetaData<JFXTextField, Boolean>("-jfx-label-float", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextField control) {
|
||||
return control.labelFloat == null || !control.labelFloat.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
|
||||
return control.labelFloatProperty();
|
||||
}
|
||||
};
|
||||
|
||||
private static final CssMetaData<JFXTextField, Boolean> DISABLE_ANIMATION = new CssMetaData<JFXTextField, Boolean>("-jfx-disable-animation", BooleanConverter.getInstance(), false) {
|
||||
@Override
|
||||
public boolean isSettable(JFXTextField control) {
|
||||
return control.disableAnimation == null || !control.disableAnimation.isBound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StyleableBooleanProperty getStyleableProperty(JFXTextField control) {
|
||||
return control.disableAnimationProperty();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
private static final List<CssMetaData<? extends Styleable, ?>> CHILD_STYLEABLES;
|
||||
|
||||
static {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION);
|
||||
CHILD_STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
}
|
||||
|
||||
// inherit the styleable properties from parent
|
||||
private List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
|
||||
if (STYLEABLES == null) {
|
||||
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(Control.getClassCssMetaData());
|
||||
styleables.addAll(getClassCssMetaData());
|
||||
styleables.addAll(TextField.getClassCssMetaData());
|
||||
STYLEABLES = Collections.unmodifiableList(styleables);
|
||||
}
|
||||
return STYLEABLES;
|
||||
}
|
||||
|
||||
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
|
||||
return StyleableProperties.CHILD_STYLEABLES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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.utils.JFXNodeUtils;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.WeakInvalidationListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/// JFXTreeCell is simple material design implementation of a tree cell.
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2017-02-15
|
||||
public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
|
||||
protected JFXRippler cellRippler = new JFXRippler(this) {
|
||||
@Override
|
||||
protected Node getMask() {
|
||||
Region clip = new Region();
|
||||
JFXNodeUtils.updateBackground(JFXTreeCell.this.getBackground(), clip);
|
||||
double width = control.getLayoutBounds().getWidth();
|
||||
double height = control.getLayoutBounds().getHeight();
|
||||
clip.resize(width, height);
|
||||
return clip;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void positionControl(Node control) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
private HBox hbox;
|
||||
private final StackPane selectedPane = new StackPane();
|
||||
|
||||
private final InvalidationListener treeItemGraphicInvalidationListener = observable -> updateDisplay(getItem(),
|
||||
isEmpty());
|
||||
private final WeakInvalidationListener weakTreeItemGraphicListener = new WeakInvalidationListener(
|
||||
treeItemGraphicInvalidationListener);
|
||||
|
||||
private WeakReference<TreeItem<T>> treeItemRef;
|
||||
|
||||
public JFXTreeCell() {
|
||||
selectedPane.getStyleClass().add("selection-bar");
|
||||
selectedPane.setBackground(new Background(new BackgroundFill(Color.RED, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
selectedPane.setPrefWidth(3);
|
||||
selectedPane.setMouseTransparent(true);
|
||||
selectedProperty().addListener((o, oldVal, newVal) -> selectedPane.setVisible(newVal ? true : false));
|
||||
|
||||
final InvalidationListener treeItemInvalidationListener = observable -> {
|
||||
TreeItem<T> oldTreeItem = treeItemRef == null ? null : treeItemRef.get();
|
||||
if (oldTreeItem != null) {
|
||||
oldTreeItem.graphicProperty().removeListener(weakTreeItemGraphicListener);
|
||||
}
|
||||
|
||||
TreeItem<T> newTreeItem = getTreeItem();
|
||||
if (newTreeItem != null) {
|
||||
newTreeItem.graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||
treeItemRef = new WeakReference<>(newTreeItem);
|
||||
}
|
||||
};
|
||||
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
|
||||
treeItemProperty().addListener(weakTreeItemListener);
|
||||
if (getTreeItem() != null) {
|
||||
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
if (!getChildren().contains(selectedPane)) {
|
||||
getChildren().add(0, cellRippler);
|
||||
cellRippler.rippler.clear();
|
||||
getChildren().add(0, selectedPane);
|
||||
}
|
||||
cellRippler.resizeRelocate(0, 0, getWidth(), getHeight());
|
||||
cellRippler.releaseRipple();
|
||||
selectedPane.resizeRelocate(0, 0, selectedPane.prefWidth(-1), getHeight());
|
||||
selectedPane.setVisible(isSelected());
|
||||
}
|
||||
|
||||
private void updateDisplay(T item, boolean empty) {
|
||||
if (item == null || empty) {
|
||||
hbox = null;
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
TreeItem<T> treeItem = getTreeItem();
|
||||
if (treeItem != null && treeItem.getGraphic() != null) {
|
||||
if (item instanceof Node) {
|
||||
setText(null);
|
||||
if (hbox == null) {
|
||||
hbox = new HBox(3);
|
||||
}
|
||||
hbox.getChildren().setAll(treeItem.getGraphic(), (Node) item);
|
||||
setGraphic(hbox);
|
||||
} else {
|
||||
hbox = null;
|
||||
setText(item.toString());
|
||||
setGraphic(treeItem.getGraphic());
|
||||
}
|
||||
} else {
|
||||
hbox = null;
|
||||
if (item instanceof Node) {
|
||||
setText(null);
|
||||
setGraphic((Node) item);
|
||||
} else {
|
||||
setText(item.toString());
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
updateDisplay(item, empty);
|
||||
setMouseTransparent(item == null || empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeView;
|
||||
|
||||
/// JFXTreeView is the material design implementation of a TreeView
|
||||
/// with expand/collapse animation and selection indicator.
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2017-02-15
|
||||
public class JFXTreeView<T> extends TreeView<T> {
|
||||
|
||||
private static final String DEFAULT_STYLE_CLASS = "jfx-tree-view";
|
||||
|
||||
public JFXTreeView() {
|
||||
init();
|
||||
}
|
||||
|
||||
public JFXTreeView(TreeItem<T> root) {
|
||||
super(root);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
this.setCellFactory((view) -> new JFXTreeCell<>());
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.datamodels.treetable;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
|
||||
/// data model that is used in JFXTreeTableView, it's used to implement
|
||||
/// the grouping feature.
|
||||
///
|
||||
/// **Note:** the data object used in JFXTreeTableView **must** extends this class
|
||||
///
|
||||
/// @param <T> is the concrete object of the Tree table
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class RecursiveTreeObject<T> {
|
||||
|
||||
/// grouped children objects
|
||||
private ObservableList<T> children = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<T> getChildren() {
|
||||
return children;
|
||||
}
|
||||
|
||||
public void setChildren(ObservableList<T> children) {
|
||||
this.children = children;
|
||||
}
|
||||
|
||||
/// Whether or not the object is grouped by a specified tree table column
|
||||
ObjectProperty<TreeTableColumn<T, ?>> groupedColumn = new SimpleObjectProperty<>();
|
||||
|
||||
public final ObjectProperty<TreeTableColumn<T, ?>> groupedColumnProperty() {
|
||||
return this.groupedColumn;
|
||||
}
|
||||
|
||||
public final TreeTableColumn<T, ?> getGroupedColumn() {
|
||||
return this.groupedColumnProperty().get();
|
||||
}
|
||||
|
||||
public final void setGroupedColumn(final TreeTableColumn<T, ?> groupedColumn) {
|
||||
this.groupedColumnProperty().set(groupedColumn);
|
||||
}
|
||||
|
||||
/// the value that must be shown when grouped
|
||||
ObjectProperty<Object> groupedValue = new SimpleObjectProperty<>();
|
||||
|
||||
public final ObjectProperty<Object> groupedValueProperty() {
|
||||
return this.groupedValue;
|
||||
}
|
||||
|
||||
public final Object getGroupedValue() {
|
||||
return this.groupedValueProperty().get();
|
||||
}
|
||||
|
||||
public final void setGroupedValue(final Object groupedValue) {
|
||||
this.groupedValueProperty().set(groupedValue);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.events;
|
||||
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventType;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/// JFXDialog events, used exclusively by the following methods:
|
||||
///
|
||||
/// - [com.jfoenix.controls.JFXDialog#show()]
|
||||
/// - [com.jfoenix.controls.JFXDialog#close()]
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXDialogEvent extends Event {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/// This event occurs when a JFXDialog is closed, no longer visible to the user
|
||||
/// ( after the exit animation ends )
|
||||
public static final EventType<JFXDialogEvent> CLOSED = new EventType<>(Event.ANY, "JFX_DIALOG_CLOSED");
|
||||
|
||||
/// This event occurs when a JFXDialog is opened, visible to the user
|
||||
/// ( after the entrance animation ends )
|
||||
public static final EventType<JFXDialogEvent> OPENED = new EventType<>(Event.ANY, "JFX_DIALOG_OPENED");
|
||||
|
||||
/// Construct a new JFXDialog `Event` with the specified event type
|
||||
///
|
||||
/// @param eventType the event type
|
||||
public JFXDialogEvent(EventType<? extends Event> eventType) {
|
||||
super(eventType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (c) 2016 JFoenix
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.jfoenix.effects;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.effect.BlurType;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
/**
|
||||
* it will create a shadow effect for a given node and a specified depth level.
|
||||
* depth levels are {0,1,2,3,4,5}
|
||||
*
|
||||
* @author Shadi Shaheen
|
||||
* @version 1.0
|
||||
* @since 2016-03-09
|
||||
*/
|
||||
public final class JFXDepthManager {
|
||||
private JFXDepthManager() {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
private static final DropShadow[] depth = new DropShadow[] {
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 0),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 8, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 12, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 16, 0, 0, 4),
|
||||
new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 8)
|
||||
};
|
||||
|
||||
/**
|
||||
* this method is used to add shadow effect to the node,
|
||||
* however the shadow is not real ( gets affected with node transformations)
|
||||
* <p>
|
||||
* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow
|
||||
*/
|
||||
public static void setDepth(Node control, int level) {
|
||||
level = level < 0 ? 0 : level;
|
||||
level = level > 5 ? 5 : level;
|
||||
control.setEffect(new DropShadow(BlurType.GAUSSIAN,
|
||||
depth[level].getColor(),
|
||||
depth[level].getRadius(),
|
||||
depth[level].getSpread(),
|
||||
depth[level].getOffsetX(),
|
||||
depth[level].getOffsetY()));
|
||||
}
|
||||
|
||||
public static int getLevels() {
|
||||
return depth.length;
|
||||
}
|
||||
|
||||
public static DropShadow getShadowAt(int level) {
|
||||
return depth[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* this method will generate a new container node that prevent
|
||||
* control transformation to be applied to the shadow effect
|
||||
* (which makes it looks as a real shadow)
|
||||
*/
|
||||
public static Node createMaterialNode(Node control, int level) {
|
||||
Node container = new Pane(control) {
|
||||
@Override
|
||||
protected double computeMaxWidth(double height) {
|
||||
return computePrefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxHeight(double width) {
|
||||
return computePrefHeight(width);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
return control.prefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width) {
|
||||
return control.prefHeight(width);
|
||||
}
|
||||
};
|
||||
container.getStyleClass().add("depth-container");
|
||||
container.setPickOnBounds(false);
|
||||
level = level < 0 ? 0 : level;
|
||||
level = level > 5 ? 5 : level;
|
||||
container.setEffect(new DropShadow(BlurType.GAUSSIAN,
|
||||
depth[level].getColor(),
|
||||
depth[level].getRadius(),
|
||||
depth[level].getSpread(),
|
||||
depth[level].getOffsetX(),
|
||||
depth[level].getOffsetY()));
|
||||
return container;
|
||||
}
|
||||
|
||||
public static void pop(Node control) {
|
||||
control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ButtonSkin;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXRippler;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.effect.DropShadow;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Shape;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public class JFXButtonSkin extends ButtonSkin {
|
||||
private final StackPane buttonContainer = new StackPane();
|
||||
private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) {
|
||||
protected Node getMask() {
|
||||
StackPane mask = new StackPane();
|
||||
mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty());
|
||||
mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background(
|
||||
new BackgroundFill(Color.WHITE,
|
||||
JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty()
|
||||
? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii()
|
||||
: JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)),
|
||||
JFXButtonSkin.this.buttonContainer.backgroundProperty()));
|
||||
mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset());
|
||||
return mask;
|
||||
}
|
||||
|
||||
private void initListeners() {
|
||||
this.ripplerPane.setOnMousePressed((event) -> {
|
||||
if (JFXButtonSkin.this.releaseManualRippler != null) {
|
||||
JFXButtonSkin.this.releaseManualRippler.run();
|
||||
}
|
||||
|
||||
JFXButtonSkin.this.releaseManualRippler = null;
|
||||
this.createRipple(event.getX(), event.getY());
|
||||
});
|
||||
}
|
||||
};
|
||||
private Transition clickedAnimation;
|
||||
private final CornerRadii defaultRadii = new CornerRadii(3.0);
|
||||
private boolean invalid = true;
|
||||
private Runnable releaseManualRippler = null;
|
||||
|
||||
public JFXButtonSkin(JFXButton button) {
|
||||
super(button);
|
||||
this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.releaseManualRippler = this.buttonRippler.createManualRipple();
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(1.0);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
} else {
|
||||
if (this.releaseManualRippler != null) {
|
||||
this.releaseManualRippler.run();
|
||||
}
|
||||
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(-1.0);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
this.buttonContainer.getChildren().add(this.buttonRippler);
|
||||
button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal));
|
||||
button.setOnMousePressed((e) -> {
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(1.0F);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
});
|
||||
button.setOnMouseReleased((e) -> {
|
||||
if (this.clickedAnimation != null) {
|
||||
this.clickedAnimation.setRate(-1.0F);
|
||||
this.clickedAnimation.play();
|
||||
}
|
||||
});
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button);
|
||||
if (focusVisibleProperty == null) {
|
||||
focusVisibleProperty = button.focusedProperty();
|
||||
}
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.buttonRippler.showOverlay();
|
||||
}
|
||||
} else {
|
||||
this.buttonRippler.hideOverlay();
|
||||
}
|
||||
});
|
||||
button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay());
|
||||
button.setPickOnBounds(false);
|
||||
this.buttonContainer.setPickOnBounds(false);
|
||||
this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty());
|
||||
this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty());
|
||||
this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) {
|
||||
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
|
||||
}
|
||||
|
||||
try {
|
||||
return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY));
|
||||
} catch (Exception var3) {
|
||||
return this.getSkinnable().getBackground();
|
||||
}
|
||||
}, this.getSkinnable().backgroundProperty()));
|
||||
button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) {
|
||||
button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null)));
|
||||
}
|
||||
|
||||
this.updateButtonType(button.getButtonType());
|
||||
this.updateChildren();
|
||||
}
|
||||
|
||||
protected void updateChildren() {
|
||||
super.updateChildren();
|
||||
if (this.buttonContainer != null) {
|
||||
this.getChildren().add(0, this.buttonContainer);
|
||||
}
|
||||
|
||||
for (int i = 1; i < this.getChildren().size(); ++i) {
|
||||
this.getChildren().get(i).setMouseTransparent(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
if (this.invalid) {
|
||||
if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) {
|
||||
for (int i = this.getChildren().size() - 1; i >= 1; --i) {
|
||||
if (this.getChildren().get(i) instanceof Shape shape) {
|
||||
this.buttonRippler.setRipplerFill(shape.getFill());
|
||||
shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.getChildren().get(i) instanceof Label label) {
|
||||
this.buttonRippler.setRipplerFill(label.getTextFill());
|
||||
label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill());
|
||||
}
|
||||
|
||||
this.invalid = false;
|
||||
}
|
||||
|
||||
double shift = 1.0F;
|
||||
this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift);
|
||||
this.layoutLabelInArea(x, y, w, h);
|
||||
}
|
||||
|
||||
private boolean isJavaDefaultBackground(Background background) {
|
||||
try {
|
||||
String firstFill = background.getFills().get(0).getFill().toString();
|
||||
return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill);
|
||||
} catch (Exception var3) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isJavaDefaultClickedBackground(Background background) {
|
||||
try {
|
||||
return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString());
|
||||
} catch (Exception var3) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateButtonType(JFXButton.ButtonType type) {
|
||||
switch (type) {
|
||||
case RAISED -> {
|
||||
JFXDepthManager.setDepth(this.buttonContainer, 2);
|
||||
this.clickedAnimation = new ButtonClickTransition();
|
||||
}
|
||||
case FLAT -> this.buttonContainer.setEffect(null);
|
||||
}
|
||||
}
|
||||
|
||||
private class ButtonClickTransition extends CachedTransition {
|
||||
public ButtonClickTransition() {
|
||||
super(JFXButtonSkin.this.buttonContainer, new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)),
|
||||
new KeyFrame(Duration.millis(1000.0F),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH),
|
||||
new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH))));
|
||||
this.setCycleDuration(Duration.seconds(0.2));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
@@ -35,6 +36,7 @@ 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;
|
||||
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
@@ -75,7 +77,11 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
this.updateRippleColor();
|
||||
this.playSelectAnimation(newVal);
|
||||
});
|
||||
control.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null)
|
||||
focusVisibleProperty = control.focusedProperty();
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.rippler.showOverlay();
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
|
||||
import com.jfoenix.concurrency.JFXUtilities;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.EventTarget;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
protected final ObjectProperty<Paint> promptTextFill;
|
||||
private boolean invalid = true;
|
||||
private final StackPane customPane;
|
||||
private final StackPane line = new StackPane();
|
||||
private final StackPane focusedLine = new StackPane();
|
||||
private final Text promptText = new Text();
|
||||
private final double initScale = 0.05;
|
||||
private final Scale scale;
|
||||
private final Timeline linesAnimation;
|
||||
private ParallelTransition transition;
|
||||
private CachedTransition promptTextUpTransition;
|
||||
private CachedTransition promptTextDownTransition;
|
||||
private CachedTransition promptTextColorTransition;
|
||||
private final Scale promptTextScale;
|
||||
private Paint oldPromptTextFill;
|
||||
private final BooleanBinding usePromptText;
|
||||
|
||||
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
|
||||
super(comboBox);
|
||||
this.scale = new Scale(this.initScale, 1.0F);
|
||||
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
|
||||
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
|
||||
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
|
||||
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.promptText.textProperty().bind(comboBox.promptTextProperty());
|
||||
this.promptText.fillProperty().bind(this.promptTextFill);
|
||||
this.promptText.getStyleClass().addAll("text", "prompt-text");
|
||||
this.promptText.getTransforms().add(this.promptTextScale);
|
||||
if (!comboBox.isLabelFloat()) {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.customPane = new StackPane();
|
||||
this.customPane.setMouseTransparent(true);
|
||||
this.customPane.getStyleClass().add("combo-box-button-container");
|
||||
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
|
||||
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.customPane.getChildren().add(this.promptText);
|
||||
this.getChildren().add(0, this.customPane);
|
||||
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
|
||||
this.line.getStyleClass().add("input-line");
|
||||
this.focusedLine.getStyleClass().add("input-focused-line");
|
||||
this.getChildren().add(this.line);
|
||||
this.getChildren().add(this.focusedLine);
|
||||
this.line.setPrefHeight(1.0F);
|
||||
this.line.setTranslateY(1.0F);
|
||||
this.line.setManaged(false);
|
||||
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (this.getSkinnable().isDisabled()) {
|
||||
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
|
||||
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
this.focusedLine.setPrefHeight(2.0F);
|
||||
this.focusedLine.setTranslateY(0.0F);
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
this.focusedLine.getTransforms().add(this.scale);
|
||||
this.focusedLine.setManaged(false);
|
||||
if (comboBox.isEditable()) {
|
||||
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
|
||||
comboBox.getEditor().promptTextProperty().unbind();
|
||||
comboBox.getEditor().setPromptText(null);
|
||||
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.usePromptText.invalidate();
|
||||
comboBox.setValue(this.getConverter().fromString(newVal));
|
||||
});
|
||||
}
|
||||
|
||||
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.promptText.visibleProperty().unbind();
|
||||
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
|
||||
} else {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.createFocusTransition();
|
||||
});
|
||||
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
});
|
||||
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.unFocus();
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for (Node child : getChildren()) {
|
||||
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
|
||||
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
|
||||
// We implement hideOnClick ourselves to prevent the popup from
|
||||
// hiding when releasing the mouse on the scrollbar.
|
||||
|
||||
this.setHideOnClick(false);
|
||||
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
|
||||
EventTarget target = t.getTarget();
|
||||
if (target instanceof Node targetNode) {
|
||||
List<String> s = targetNode.getStyleClass();
|
||||
if (s.contains("thumb")
|
||||
|| s.contains("track")
|
||||
|| s.contains("decrement-arrow")
|
||||
|| s.contains("increment-arrow")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
this.customPane.resizeRelocate(x, y, w, h);
|
||||
if (this.invalid) {
|
||||
this.invalid = false;
|
||||
if (!this.getSkinnable().isEditable()) {
|
||||
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
|
||||
if (javaPromptText != null) {
|
||||
this.promptTextFill.set(javaPromptText.getFill());
|
||||
}
|
||||
}
|
||||
|
||||
this.createFloatingAnimation();
|
||||
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
|
||||
this.animateFloatingLabel(true);
|
||||
}
|
||||
}
|
||||
|
||||
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
|
||||
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
|
||||
this.scale.setPivotX(w / (double) 2.0F);
|
||||
}
|
||||
|
||||
private void createFloatingAnimation() {
|
||||
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition.setOnFinished((finish) -> {
|
||||
this.promptText.setTranslateY(0.0F);
|
||||
this.promptTextScale.setX(1.0F);
|
||||
this.promptTextScale.setY(1.0F);
|
||||
});
|
||||
}
|
||||
|
||||
private void focus() {
|
||||
if (this.transition == null) {
|
||||
this.createFocusTransition();
|
||||
}
|
||||
|
||||
this.transition.play();
|
||||
}
|
||||
|
||||
private void animateFloatingLabel(boolean up) {
|
||||
if (this.promptText == null) {
|
||||
Platform.runLater(() -> this.animateFloatingLabel(up));
|
||||
} else {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
this.transition.getChildren().remove(this.promptTextUpTransition);
|
||||
this.transition.getChildren().remove(this.promptTextColorTransition);
|
||||
this.transition = null;
|
||||
}
|
||||
|
||||
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
|
||||
this.promptTextDownTransition.stop();
|
||||
this.promptTextUpTransition.play();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextColorTransition.play();
|
||||
}
|
||||
} else if (!up) {
|
||||
this.promptTextUpTransition.stop();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
}
|
||||
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void createFocusTransition() {
|
||||
this.transition = new ParallelTransition();
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.transition.getChildren().add(this.promptTextUpTransition);
|
||||
this.transition.getChildren().add(this.promptTextColorTransition);
|
||||
}
|
||||
|
||||
this.transition.getChildren().add(this.linesAnimation);
|
||||
}
|
||||
|
||||
private void unFocus() {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
}
|
||||
|
||||
this.scale.setX(this.initScale);
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
if (this.usePromptText()) {
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean usePromptText() {
|
||||
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
|
||||
String promptTxt = this.getSkinnable().getPromptText();
|
||||
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,21 @@ import javafx.geometry.Insets;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TextFormatter;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/**
|
||||
* @author Shadi Shaheen
|
||||
@@ -75,7 +82,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
|
||||
// create JFX Decorator
|
||||
pickerDecorator = new JFXDecorator(dialog, this, false, false, false);
|
||||
pickerDecorator.setOnCloseButtonAction(this::updateColor);
|
||||
pickerDecorator.setOnCloseButtonAction(this::close);
|
||||
pickerDecorator.setPickOnBounds(false);
|
||||
customScene = new Scene(pickerDecorator, Color.TRANSPARENT);
|
||||
StyleSheets.init(customScene);
|
||||
@@ -95,14 +102,17 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
|
||||
rgbField.getStyleClass().add("custom-color-field");
|
||||
rgbField.setPromptText("RGB Color");
|
||||
rgbField.setTextFormatter(colorCharFormatter());
|
||||
rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
|
||||
|
||||
hsbField.getStyleClass().add("custom-color-field");
|
||||
hsbField.setPromptText("HSB Color");
|
||||
hsbField.setTextFormatter(colorCharFormatter());
|
||||
hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
|
||||
|
||||
hexField.getStyleClass().add("custom-color-field");
|
||||
hexField.setPromptText("#HEX Color");
|
||||
hexField.setTextFormatter(colorCharFormatter());
|
||||
hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
|
||||
|
||||
StackPane tabContent = new StackPane();
|
||||
@@ -147,6 +157,22 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
paraTransition.play();
|
||||
});
|
||||
|
||||
container.getChildren().add(tabs);
|
||||
|
||||
HBox actionsHBox = new HBox();
|
||||
actionsHBox.getStyleClass().add("jfx-color-dialog-actions");
|
||||
|
||||
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
|
||||
acceptButton.setOnAction(event -> updateColor());
|
||||
acceptButton.getStyleClass().add("jfx-color-dialog-accept");
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(event -> close());
|
||||
cancelButton.getStyleClass().add("jfx-color-dialog-cancel");
|
||||
|
||||
actionsHBox.getChildren().addAll(acceptButton, cancelButton);
|
||||
container.getChildren().add(actionsHBox);
|
||||
|
||||
initRun = () -> {
|
||||
// change tabs labels font color according to the selected color
|
||||
pane.backgroundProperty().addListener((o, oldVal, newVal) -> {
|
||||
@@ -173,7 +199,7 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
(int) (newColor.getRed() * 255),
|
||||
(int) (newColor.getGreen() * 255),
|
||||
(int) (newColor.getBlue() * 255));
|
||||
String rgb = String.format("rgba(%d, %d, %d, 1)",
|
||||
String rgb = String.format("rgb(%d, %d, %d)",
|
||||
(int) (newColor.getRed() * 255),
|
||||
(int) (newColor.getGreen() * 255),
|
||||
(int) (newColor.getBlue() * 255));
|
||||
@@ -226,7 +252,13 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
return pane.getBackground().getFills().get(0).getFill();
|
||||
}, pane.backgroundProperty()));
|
||||
|
||||
acceptButton.textFillProperty().bind(Bindings.createObjectBinding(() -> {
|
||||
Color fill = (Color) pane.getBackground().getFills().get(0).getFill();
|
||||
return ColorScheme.newBuilder(Themes.getColorScheme())
|
||||
.setPrimaryColorSeed(fill)
|
||||
.build()
|
||||
.getPrimary();
|
||||
}, pane.backgroundProperty()));
|
||||
|
||||
((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty()
|
||||
.bind(Bindings.createObjectBinding(() -> {
|
||||
@@ -256,9 +288,6 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
container.getChildren().add(tabs);
|
||||
|
||||
this.getChildren().add(container);
|
||||
this.setPadding(new Insets(0));
|
||||
|
||||
@@ -288,7 +317,11 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
if (!systemChange) {
|
||||
userChange = true;
|
||||
try {
|
||||
curvedColorPicker.setColor(Color.valueOf(colorWebString));
|
||||
Color color = Color.valueOf(colorWebString);
|
||||
if (color.getOpacity() != 1.0) {
|
||||
color = Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||
}
|
||||
curvedColorPicker.setColor(color);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// if color is not valid then do nothing
|
||||
}
|
||||
@@ -303,6 +336,9 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
|
||||
public void setCurrentColor(Color currentColor) {
|
||||
this.currentColorProperty.set(currentColor);
|
||||
if (curvedColorPicker != null && currentColor != null) {
|
||||
curvedColorPicker.setColor(currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
Color getCurrentColor() {
|
||||
@@ -404,4 +440,20 @@ public class JFXCustomColorPickerDialog extends StackPane {
|
||||
dialog.setMinWidth(minWidth);
|
||||
dialog.setMinHeight(minHeight);
|
||||
}
|
||||
|
||||
private static final Pattern COLOR_CHAR_PATTERN = Pattern.compile("[0-9a-zA-Z#(),%.\\s]*");
|
||||
|
||||
private static TextFormatter<String> colorCharFormatter() {
|
||||
return new TextFormatter<>(change -> {
|
||||
if (!change.isContentChange()) return change;
|
||||
|
||||
String ins = StringUtils.toHalfWidth(change.getText());
|
||||
if (!COLOR_CHAR_PATTERN.matcher(ins).matches()) return null;
|
||||
String full = StringUtils.toHalfWidth(change.getControlNewText());
|
||||
long h = full.chars().filter(c -> c == '#').count();
|
||||
if (h > 1 || (h == 1 && full.indexOf('#') != 0)) return null;
|
||||
change.setText(ins);
|
||||
return change;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,26 +20,23 @@
|
||||
*/
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ListViewSkin;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.skin.ListViewSkin;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4720
|
||||
public class JFXListViewSkin<T> extends ListViewSkin<T> {
|
||||
|
||||
private final VirtualFlow<ListCell<T>> flow;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public JFXListViewSkin(final JFXListView<T> listView) {
|
||||
super(listView);
|
||||
flow = (VirtualFlow<ListCell<T>>) getChildren().get(0);
|
||||
JFXDepthManager.setDepth(flow, listView.depthProperty().get());
|
||||
listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal));
|
||||
FXUtils.smoothScrolling(flow);
|
||||
VirtualFlow<ListCell<T>> flow = getVirtualFlow();
|
||||
FXUtils.onChangeAndOperate(listView.depthProperty(), depth -> JFXDepthManager.setDepth(flow, depth.intValue()));
|
||||
|
||||
if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) {
|
||||
FXUtils.smoothScrolling(flow);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,47 +44,4 @@ public class JFXListViewSkin<T> extends ListViewSkin<T> {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
final int itemsCount = getSkinnable().getItems().size();
|
||||
if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) {
|
||||
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
|
||||
}
|
||||
|
||||
final double fixedCellSize = getSkinnable().getFixedCellSize();
|
||||
double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ?
|
||||
fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight();
|
||||
double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
|
||||
if (height > computedHeight) {
|
||||
height = computedHeight;
|
||||
}
|
||||
|
||||
if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) {
|
||||
return getSkinnable().getMaxHeight();
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
private double estimateHeight() {
|
||||
// compute the border/padding for the list
|
||||
double borderWidth = snapVerticalInsets();
|
||||
// compute the gap between list cells
|
||||
|
||||
JFXListView<T> listview = (JFXListView<T>) getSkinnable();
|
||||
double gap = listview.isExpanded() ? ((JFXListView<T>) getSkinnable()).getVerticalGap() * (getSkinnable().getItems()
|
||||
.size()) : 0;
|
||||
// compute the height of each list cell
|
||||
double cellsHeight = 0;
|
||||
for (int i = 0; i < flow.getCellCount(); i++) {
|
||||
ListCell<T> cell = flow.getCell(i);
|
||||
cellsHeight += cell.getHeight();
|
||||
}
|
||||
return cellsHeight + gap + borderWidth;
|
||||
}
|
||||
|
||||
private double snapVerticalInsets() {
|
||||
return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2016 JFoenix
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import com.jfoenix.controls.JFXPopup.PopupHPosition;
|
||||
import com.jfoenix.controls.JFXPopup.PopupVPosition;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import javafx.animation.*;
|
||||
import javafx.animation.Animation.Status;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
|
||||
/// # Material Design Popup Skin
|
||||
/// TODO: REWORK
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 2.0
|
||||
/// @since 2017-03-01
|
||||
public class JFXPopupSkin implements Skin<JFXPopup> {
|
||||
|
||||
protected JFXPopup control;
|
||||
protected StackPane container = new StackPane();
|
||||
protected Region popupContent;
|
||||
protected Node root;
|
||||
|
||||
private Animation animation;
|
||||
protected Scale scale;
|
||||
|
||||
public JFXPopupSkin(JFXPopup control) {
|
||||
this.control = control;
|
||||
// set scale y to 0.01 instead of 0 to allow layout of the content,
|
||||
// otherwise it will cause exception in traverse engine, when focusing the 1st node
|
||||
scale = new Scale(1.0, 0.01, 0, 0);
|
||||
popupContent = control.getPopupContent();
|
||||
container.getStyleClass().add("jfx-popup-container");
|
||||
container.getChildren().add(popupContent);
|
||||
container.getTransforms().add(scale);
|
||||
container.setOpacity(0);
|
||||
root = JFXDepthManager.createMaterialNode(container, 4);
|
||||
animation = AnimationUtils.isAnimationEnabled() ? getAnimation() : null;
|
||||
}
|
||||
|
||||
public void reset(PopupVPosition vAlign, PopupHPosition hAlign, double offsetX, double offsetY) {
|
||||
// postion the popup according to its animation
|
||||
scale.setPivotX(hAlign == PopupHPosition.RIGHT ? container.getWidth() : 0);
|
||||
scale.setPivotY(vAlign == PopupVPosition.BOTTOM ? container.getHeight() : 0);
|
||||
control.setX(control.getX() + (hAlign == PopupHPosition.RIGHT ? -container.getWidth() + offsetX : offsetX));
|
||||
control.setY(control.getY() + (vAlign == PopupVPosition.BOTTOM ? -container.getHeight() + offsetY : offsetY));
|
||||
}
|
||||
|
||||
public final void animate() {
|
||||
if (animation != null) {
|
||||
if (animation.getStatus() == Status.STOPPED) {
|
||||
container.setOpacity(1);
|
||||
animation.playFromStart();
|
||||
}
|
||||
} else {
|
||||
container.setOpacity(1);
|
||||
popupContent.setOpacity(1);
|
||||
scale.setX(1.0);
|
||||
scale.setY(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JFXPopup getSkinnable() {
|
||||
return control;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getNode() {
|
||||
return root;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
if (animation != null) {
|
||||
animation.stop();
|
||||
animation = null;
|
||||
}
|
||||
container = null;
|
||||
control = null;
|
||||
popupContent = null;
|
||||
root = null;
|
||||
}
|
||||
|
||||
protected Animation getAnimation() {
|
||||
Interpolator interpolator = Motion.EASE;
|
||||
return new Timeline(
|
||||
new KeyFrame(
|
||||
Duration.ZERO,
|
||||
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
|
||||
new KeyValue(scale.xProperty(), 0, interpolator),
|
||||
new KeyValue(scale.yProperty(), 0, interpolator)
|
||||
),
|
||||
new KeyFrame(Motion.SHORT4,
|
||||
new KeyValue(popupContent.opacityProperty(), 0, interpolator),
|
||||
new KeyValue(scale.xProperty(), 1, interpolator)
|
||||
),
|
||||
new KeyFrame(Motion.MEDIUM2,
|
||||
new KeyValue(popupContent.opacityProperty(), 1, interpolator),
|
||||
new KeyValue(scale.yProperty(), 1, interpolator)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public void init() {
|
||||
if (animation != null)
|
||||
animation.stop();
|
||||
container.setOpacity(0);
|
||||
scale.setX(1.0);
|
||||
scale.setY(0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.JFXProgressBar;
|
||||
import com.jfoenix.utils.TreeShowingProperty;
|
||||
import javafx.animation.*;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
/// # Material Design ProgressBar Skin
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 2.0
|
||||
/// @since 2017-10-06
|
||||
public class JFXProgressBarSkin extends SkinBase<JFXProgressBar> {
|
||||
|
||||
private static final double DEFAULT_HEIGHT = 4;
|
||||
|
||||
private final StackPane track;
|
||||
private final StackPane bar;
|
||||
private final Rectangle clip;
|
||||
private Animation transition;
|
||||
private final TreeShowingProperty treeShowingProperty;
|
||||
private double fullWidth;
|
||||
|
||||
public JFXProgressBarSkin(JFXProgressBar control) {
|
||||
super(control);
|
||||
|
||||
this.treeShowingProperty = new TreeShowingProperty(control);
|
||||
|
||||
registerChangeListener(treeShowingProperty, obs -> updateProgress(false));
|
||||
registerChangeListener(control.progressProperty(), obs -> updateProgress(true));
|
||||
|
||||
track = new StackPane();
|
||||
track.getStyleClass().setAll("track");
|
||||
|
||||
bar = new StackPane();
|
||||
bar.getStyleClass().setAll("bar");
|
||||
|
||||
clip = new Rectangle();
|
||||
clip.setArcWidth(DEFAULT_HEIGHT);
|
||||
clip.setArcHeight(DEFAULT_HEIGHT);
|
||||
bar.setClip(clip);
|
||||
|
||||
getChildren().setAll(track, bar);
|
||||
|
||||
getSkinnable().requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return Node.BASELINE_OFFSET_SAME_AS_HEIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return Math.max(100, leftInset + bar.prefWidth(getSkinnable().getWidth()) + rightInset);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return topInset + DEFAULT_HEIGHT + bottomInset;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return getSkinnable().prefWidth(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return getSkinnable().prefHeight(width);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
track.resizeRelocate(x, y, w, h);
|
||||
bar.resizeRelocate(x, y, w, h);
|
||||
|
||||
clip.relocate(0, 0);
|
||||
clip.setWidth(0);
|
||||
clip.setHeight(h);
|
||||
clip.setTranslateX(0);
|
||||
|
||||
fullWidth = w;
|
||||
|
||||
clearAnimation();
|
||||
updateProgress(false);
|
||||
}
|
||||
|
||||
private boolean wasIndeterminate = false;
|
||||
|
||||
private void updateProgress(boolean playProgressAnimation) {
|
||||
double progress = Math.min(getSkinnable().getProgress(), 1.0);
|
||||
boolean isIndeterminate = progress < 0.0;
|
||||
boolean isTreeShowing = treeShowingProperty.get();
|
||||
|
||||
if (isIndeterminate != wasIndeterminate) {
|
||||
wasIndeterminate = isIndeterminate;
|
||||
clearAnimation();
|
||||
clip.setTranslateX(0);
|
||||
}
|
||||
|
||||
if (isIndeterminate) { // indeterminate
|
||||
if (isTreeShowing) {
|
||||
if (transition == null) {
|
||||
transition = createIndeterminateTransition();
|
||||
transition.playFromStart();
|
||||
} else {
|
||||
transition.play();
|
||||
}
|
||||
} else if (transition != null) {
|
||||
transition.pause();
|
||||
}
|
||||
} else { // determinate
|
||||
clearAnimation();
|
||||
if (isTreeShowing && playProgressAnimation
|
||||
&& AnimationUtils.isAnimationEnabled()
|
||||
&& getSkinnable().isSmoothProgress()) {
|
||||
transition = createDeterminateTransition(progress);
|
||||
transition.playFromStart();
|
||||
} else {
|
||||
clip.setWidth(computeBarWidth(progress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final Duration INDETERMINATE_DURATION = Duration.seconds(1);
|
||||
|
||||
private Transition createIndeterminateTransition() {
|
||||
double minWidth = 0;
|
||||
double maxWidth = fullWidth * 0.4;
|
||||
Transition transition = new Transition() {
|
||||
{
|
||||
setInterpolator(Interpolator.LINEAR);
|
||||
setCycleDuration(INDETERMINATE_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void interpolate(double frac) {
|
||||
double currentWidth;
|
||||
|
||||
if (frac <= 0.5) {
|
||||
currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5);
|
||||
} else {
|
||||
currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5);
|
||||
}
|
||||
|
||||
double targetCenter;
|
||||
if (frac <= 0.1) {
|
||||
targetCenter = 0.0;
|
||||
} else if (frac >= 0.9) {
|
||||
targetCenter = fullWidth;
|
||||
} else {
|
||||
targetCenter = ((frac - 0.1) / 0.8) * fullWidth;
|
||||
}
|
||||
|
||||
clip.setWidth(currentWidth);
|
||||
clip.setTranslateX(targetCenter - currentWidth / 2.0);
|
||||
}
|
||||
};
|
||||
|
||||
transition.setCycleCount(Timeline.INDEFINITE);
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2);
|
||||
|
||||
private Timeline createDeterminateTransition(double targetProgress) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)),
|
||||
new KeyFrame(DETERMINATE_DURATION,
|
||||
new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT))
|
||||
);
|
||||
timeline.setOnFinished(e -> {
|
||||
if (transition == timeline) {
|
||||
transition = null;
|
||||
}
|
||||
});
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private void clearAnimation() {
|
||||
if (transition != null) {
|
||||
transition.stop();
|
||||
transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
treeShowingProperty.dispose();
|
||||
clearAnimation();
|
||||
}
|
||||
|
||||
private double computeBarWidth(double progress) {
|
||||
assert progress >= 0 && progress <= 1;
|
||||
double barWidth = ((int) fullWidth * 2 * progress) / 2.0;
|
||||
return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
@@ -23,6 +24,7 @@ import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
@@ -58,7 +60,12 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
AnchorPane.setRightAnchor(this.rippler, this.labelOffset);
|
||||
|
||||
this.updateChildren();
|
||||
control.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control);
|
||||
if (focusVisibleProperty == null) {
|
||||
focusVisibleProperty = control.focusedProperty();
|
||||
}
|
||||
|
||||
focusVisibleProperty.addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
if (!this.getSkinnable().isPressed()) {
|
||||
this.rippler.showOverlay();
|
||||
@@ -66,7 +73,6 @@ public class JFXRadioButtonSkin extends RadioButtonSkin {
|
||||
} else {
|
||||
this.rippler.hideOverlay();
|
||||
}
|
||||
|
||||
});
|
||||
control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay());
|
||||
this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors());
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.JFXSlider;
|
||||
import com.jfoenix.controls.JFXSlider.IndicatorPosition;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.scene.control.skin.SliderSkin;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/// # Material Design Slider Skin
|
||||
///
|
||||
/// rework of JFXSliderSkin by extending Java SliderSkin
|
||||
/// this solves padding and resizing issues
|
||||
///
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
/// @since 2016-03-09
|
||||
public class JFXSliderSkin extends SliderSkin {
|
||||
|
||||
private static final PseudoClass MIN_VALUE = PseudoClass.getPseudoClass("min");
|
||||
private static final PseudoClass MAX_VALUE = PseudoClass.getPseudoClass("max");
|
||||
|
||||
private final Pane mouseHandlerPane = new Pane();
|
||||
private final Text sliderValue;
|
||||
private final StackPane coloredTrack;
|
||||
private final StackPane thumb;
|
||||
private final StackPane track;
|
||||
private final StackPane animatedThumb;
|
||||
private NumberAxis tickLine;
|
||||
|
||||
private Timeline timeline;
|
||||
|
||||
private double indicatorRotation;
|
||||
private double horizontalRotation;
|
||||
private double shifting;
|
||||
|
||||
public JFXSliderSkin(JFXSlider slider) {
|
||||
super(slider);
|
||||
|
||||
track = (StackPane) getSkinnable().lookup(".track");
|
||||
thumb = (StackPane) getSkinnable().lookup(".thumb");
|
||||
tickLine = (NumberAxis) getSkinnable().lookup(".axis");
|
||||
if (tickLine != null) tickLine.setAnimated(false);
|
||||
|
||||
coloredTrack = new StackPane();
|
||||
coloredTrack.getStyleClass().add("colored-track");
|
||||
coloredTrack.setMouseTransparent(true);
|
||||
|
||||
sliderValue = new Text();
|
||||
sliderValue.getStyleClass().setAll("slider-value");
|
||||
|
||||
animatedThumb = new StackPane();
|
||||
animatedThumb.getStyleClass().add("animated-thumb");
|
||||
animatedThumb.getChildren().add(sliderValue);
|
||||
animatedThumb.setMouseTransparent(true);
|
||||
animatedThumb.setScaleX(0);
|
||||
animatedThumb.setScaleY(0);
|
||||
|
||||
thumb.layoutXProperty().addListener(x -> {
|
||||
if (slider.getOrientation() == Orientation.VERTICAL) initAnimation(Orientation.VERTICAL);
|
||||
});
|
||||
thumb.layoutYProperty().addListener(y -> {
|
||||
if (slider.getOrientation() == Orientation.HORIZONTAL) initAnimation(Orientation.HORIZONTAL);
|
||||
});
|
||||
|
||||
addJFXChildren();
|
||||
getChildren().addListener((ListChangeListener<Node>) c -> {
|
||||
while (c.next()) {
|
||||
if (c.wasAdded()) {
|
||||
c.getAddedSubList().forEach(added -> {
|
||||
if (added instanceof NumberAxis) {
|
||||
tickLine = (NumberAxis) added;
|
||||
tickLine.setAnimated(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
registerChangeListener(slider.showTickMarksProperty(), e -> addJFXChildren());
|
||||
registerChangeListener(slider.showTickLabelsProperty(), e -> addJFXChildren());
|
||||
registerChangeListener(slider.valueFactoryProperty(), obs -> refreshSliderValueBinding());
|
||||
|
||||
initListeners();
|
||||
}
|
||||
|
||||
private void addJFXChildren() {
|
||||
ObservableList<Node> children = getChildren();
|
||||
Slider slider = getSkinnable();
|
||||
if ((slider.isShowTickMarks() || slider.isShowTickLabels()) && tickLine != null && !children.contains(tickLine)) {
|
||||
children.add(0, tickLine);
|
||||
}
|
||||
if (children.contains(coloredTrack)) return;
|
||||
children.add(children.indexOf(thumb), coloredTrack);
|
||||
children.add(children.indexOf(thumb), animatedThumb);
|
||||
children.add(0, mouseHandlerPane);
|
||||
}
|
||||
|
||||
private void refreshSliderValueBinding() {
|
||||
sliderValue.textProperty().unbind();
|
||||
if (((JFXSlider) getSkinnable()).getValueFactory() != null) {
|
||||
sliderValue.textProperty()
|
||||
.bind(((JFXSlider) getSkinnable()).getValueFactory().call((JFXSlider) getSkinnable()));
|
||||
} else {
|
||||
sliderValue.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
if (getSkinnable().getLabelFormatter() != null) {
|
||||
return getSkinnable().getLabelFormatter().toString(getSkinnable().getValue());
|
||||
} else {
|
||||
return String.valueOf(Math.round(getSkinnable().getValue()));
|
||||
}
|
||||
}, getSkinnable().valueProperty()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
|
||||
if (timeline == null) {
|
||||
initAnimation(getSkinnable().getOrientation());
|
||||
}
|
||||
|
||||
double prefWidth = animatedThumb.prefWidth(-1);
|
||||
animatedThumb.resize(prefWidth, animatedThumb.prefHeight(prefWidth));
|
||||
|
||||
boolean horizontal = getSkinnable().getOrientation() == Orientation.HORIZONTAL;
|
||||
double width, height, layoutX, layoutY;
|
||||
if (horizontal) {
|
||||
width = thumb.getLayoutX() - snappedLeftInset();
|
||||
height = track.getHeight();
|
||||
layoutX = track.getLayoutX();
|
||||
layoutY = track.getLayoutY();
|
||||
animatedThumb.setLayoutX(thumb.getLayoutX() + thumb.getWidth() / 2 - animatedThumb.getWidth() / 2);
|
||||
} else {
|
||||
height = track.getLayoutBounds().getMaxY() + track.getLayoutY() - thumb.getLayoutY() - snappedBottomInset();
|
||||
width = track.getWidth();
|
||||
layoutX = track.getLayoutX();
|
||||
layoutY = thumb.getLayoutY();
|
||||
animatedThumb.setLayoutY(thumb.getLayoutY() + thumb.getHeight() / 2 - animatedThumb.getHeight() / 2);
|
||||
}
|
||||
|
||||
coloredTrack.resizeRelocate(layoutX, layoutY, width, height);
|
||||
mouseHandlerPane.resizeRelocate(x, y, w, h);
|
||||
}
|
||||
|
||||
private void initializeVariables() {
|
||||
shifting = 30 + thumb.getWidth();
|
||||
if (getSkinnable().getOrientation() != Orientation.HORIZONTAL) {
|
||||
horizontalRotation = -90;
|
||||
}
|
||||
if (((JFXSlider) getSkinnable()).getIndicatorPosition() != IndicatorPosition.LEFT) {
|
||||
indicatorRotation = 180;
|
||||
shifting = -shifting;
|
||||
}
|
||||
final double rotationAngle = 45;
|
||||
sliderValue.setRotate(rotationAngle + indicatorRotation + 3 * horizontalRotation);
|
||||
animatedThumb.setRotate(-rotationAngle + indicatorRotation + horizontalRotation);
|
||||
}
|
||||
|
||||
private void initListeners() {
|
||||
// delegate slider mouse events to track node
|
||||
mouseHandlerPane.setOnMousePressed(this::delegateToTrack);
|
||||
mouseHandlerPane.setOnMouseReleased(this::delegateToTrack);
|
||||
mouseHandlerPane.setOnMouseDragged(this::delegateToTrack);
|
||||
|
||||
// animate value node
|
||||
track.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
|
||||
timeline.setRate(1);
|
||||
timeline.play();
|
||||
});
|
||||
track.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> {
|
||||
timeline.setRate(-1);
|
||||
timeline.play();
|
||||
});
|
||||
thumb.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> {
|
||||
timeline.setRate(1);
|
||||
timeline.play();
|
||||
});
|
||||
thumb.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> {
|
||||
timeline.setRate(-1);
|
||||
timeline.play();
|
||||
});
|
||||
|
||||
refreshSliderValueBinding();
|
||||
updateValueStyleClass();
|
||||
|
||||
getSkinnable().valueProperty().addListener(observable -> updateValueStyleClass());
|
||||
}
|
||||
|
||||
private void delegateToTrack(MouseEvent event) {
|
||||
if (!event.isConsumed()) {
|
||||
event.consume();
|
||||
track.fireEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateValueStyleClass() {
|
||||
getSkinnable().pseudoClassStateChanged(MIN_VALUE, getSkinnable().getMin() == getSkinnable().getValue());
|
||||
getSkinnable().pseudoClassStateChanged(MAX_VALUE, getSkinnable().getMax() == getSkinnable().getValue());
|
||||
}
|
||||
|
||||
private void initAnimation(Orientation orientation) {
|
||||
initializeVariables();
|
||||
|
||||
double thumbPos, thumbNewPos;
|
||||
DoubleProperty layoutProperty;
|
||||
|
||||
if (orientation == Orientation.HORIZONTAL) {
|
||||
if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) {
|
||||
thumbPos = thumb.getLayoutY() - thumb.getHeight();
|
||||
thumbNewPos = thumbPos - shifting;
|
||||
} else {
|
||||
double height = animatedThumb.prefHeight(animatedThumb.prefWidth(-1));
|
||||
thumbPos = thumb.getLayoutY() - height / 2;
|
||||
thumbNewPos = thumb.getLayoutY() - height - thumb.getHeight();
|
||||
}
|
||||
layoutProperty = animatedThumb.translateYProperty();
|
||||
} else {
|
||||
if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) {
|
||||
thumbPos = thumb.getLayoutX() - thumb.getWidth();
|
||||
thumbNewPos = thumbPos - shifting;
|
||||
} else {
|
||||
double width = animatedThumb.prefWidth(-1);
|
||||
thumbPos = thumb.getLayoutX() - width / 2;
|
||||
thumbNewPos = thumb.getLayoutX() - width - thumb.getWidth();
|
||||
}
|
||||
layoutProperty = animatedThumb.translateXProperty();
|
||||
}
|
||||
|
||||
clearAnimation();
|
||||
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(
|
||||
Duration.ZERO,
|
||||
new KeyValue(animatedThumb.scaleXProperty(), 0, Interpolator.EASE_BOTH),
|
||||
new KeyValue(animatedThumb.scaleYProperty(), 0, Interpolator.EASE_BOTH),
|
||||
new KeyValue(layoutProperty, thumbPos, Interpolator.EASE_BOTH)),
|
||||
new KeyFrame(
|
||||
Duration.seconds(0.2),
|
||||
new KeyValue(animatedThumb.scaleXProperty(), 1, Interpolator.EASE_BOTH),
|
||||
new KeyValue(animatedThumb.scaleYProperty(), 1, Interpolator.EASE_BOTH),
|
||||
new KeyValue(layoutProperty, thumbNewPos, Interpolator.EASE_BOTH)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
clearAnimation();
|
||||
}
|
||||
|
||||
private void clearAnimation() {
|
||||
if (timeline != null) {
|
||||
timeline.stop();
|
||||
timeline.getKeyFrames().clear();
|
||||
timeline = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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.JFXSpinner;
|
||||
import com.jfoenix.utils.TreeShowingProperty;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Arc;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.shape.StrokeLineCap;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/// JFXSpinner material design skin
|
||||
///
|
||||
/// @author Shadi Shaheen & Gerard Moubarak
|
||||
/// @version 1.0
|
||||
/// @since 2017-09-25
|
||||
public class JFXSpinnerSkin extends SkinBase<JFXSpinner> {
|
||||
|
||||
private static final double DEFAULT_STROKE_WIDTH = 4;
|
||||
|
||||
private JFXSpinner control;
|
||||
private final TreeShowingProperty treeShowingProperty;
|
||||
private boolean isValid = false;
|
||||
|
||||
private Timeline timeline;
|
||||
private Arc arc;
|
||||
private Arc track;
|
||||
private final StackPane arcPane;
|
||||
private final Rectangle fillRect;
|
||||
|
||||
private final double startingAngle;
|
||||
|
||||
public JFXSpinnerSkin(JFXSpinner control) {
|
||||
super(control);
|
||||
|
||||
this.control = control;
|
||||
this.treeShowingProperty = new TreeShowingProperty(control);
|
||||
this.startingAngle = control.getStartingAngle();
|
||||
|
||||
arc = new Arc();
|
||||
arc.setManaged(false);
|
||||
arc.setLength(180);
|
||||
arc.getStyleClass().setAll("arc");
|
||||
arc.setFill(Color.TRANSPARENT);
|
||||
arc.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
arc.setStrokeLineCap(StrokeLineCap.ROUND);
|
||||
|
||||
track = new Arc();
|
||||
track.setManaged(false);
|
||||
track.setLength(360);
|
||||
track.setStrokeWidth(DEFAULT_STROKE_WIDTH);
|
||||
track.getStyleClass().setAll("track");
|
||||
track.setFill(Color.TRANSPARENT);
|
||||
|
||||
fillRect = new Rectangle();
|
||||
fillRect.setFill(Color.TRANSPARENT);
|
||||
final Group group = new Group(fillRect, track, arc);
|
||||
group.setManaged(false);
|
||||
arcPane = new StackPane(group);
|
||||
arcPane.setPrefSize(50, 50);
|
||||
getChildren().setAll(arcPane);
|
||||
|
||||
// register listeners
|
||||
registerChangeListener(control.progressProperty(), obs -> updateProgress());
|
||||
registerChangeListener(treeShowingProperty, obs -> updateProgress());
|
||||
}
|
||||
|
||||
private void updateProgress() {
|
||||
double progress = Double.min(getSkinnable().getProgress(), 1.0);
|
||||
if (progress < 0) { // indeterminate
|
||||
boolean treeShowing = treeShowingProperty.get();
|
||||
if (treeShowing) {
|
||||
if (timeline == null) {
|
||||
timeline = createTransition();
|
||||
timeline.playFromStart();
|
||||
} else {
|
||||
timeline.play();
|
||||
}
|
||||
} else if (timeline != null) {
|
||||
timeline.pause();
|
||||
}
|
||||
} else { // determinate
|
||||
clearAnimation();
|
||||
arc.setStartAngle(90);
|
||||
arc.setLength(-360 * progress);
|
||||
}
|
||||
}
|
||||
|
||||
private double computeSize() {
|
||||
return control.getRadius() * 2 + arc.getStrokeWidth() * 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
|
||||
return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset);
|
||||
} else {
|
||||
return computeSize();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
|
||||
return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset);
|
||||
} else {
|
||||
return computeSize();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
|
||||
return arcPane.prefWidth(-1);
|
||||
} else {
|
||||
return computeSize();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
if (Region.USE_COMPUTED_SIZE == control.getRadius()) {
|
||||
return arcPane.prefHeight(-1);
|
||||
} else {
|
||||
return computeSize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
|
||||
final double strokeWidth = arc.getStrokeWidth();
|
||||
double radius = control.getRadius();
|
||||
final double arcSize = snapSizeX(radius * 2 + strokeWidth);
|
||||
|
||||
arcPane.resizeRelocate((contentWidth - arcSize) / 2 + 1, (contentHeight - arcSize) / 2 + 1, arcSize, arcSize);
|
||||
updateArcLayout(radius, arcSize);
|
||||
|
||||
fillRect.setWidth(arcSize);
|
||||
fillRect.setHeight(arcSize);
|
||||
|
||||
if (!isValid) {
|
||||
updateProgress();
|
||||
isValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateArcLayout(double radius, double arcSize) {
|
||||
arc.setRadiusX(radius);
|
||||
arc.setRadiusY(radius);
|
||||
arc.setCenterX(arcSize / 2);
|
||||
arc.setCenterY(arcSize / 2);
|
||||
|
||||
track.setRadiusX(radius);
|
||||
track.setRadiusY(radius);
|
||||
track.setCenterX(arcSize / 2);
|
||||
track.setCenterY(arcSize / 2);
|
||||
track.setStrokeWidth(arc.getStrokeWidth());
|
||||
}
|
||||
|
||||
private void addKeyFrames(List<KeyFrame> frames, double angle, double duration) {
|
||||
frames.add(new KeyFrame(Duration.seconds(duration),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 45 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.4),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 90 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 0.7),
|
||||
new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 135 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
frames.add(new KeyFrame(Duration.seconds(duration + 1.1),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
angle + 435 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
}
|
||||
|
||||
private Timeline createTransition() {
|
||||
Timeline timeline;
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
var keyFrames = new ArrayList<KeyFrame>(17);
|
||||
addKeyFrames(keyFrames, 0, 0);
|
||||
addKeyFrames(keyFrames, 450, 1.4);
|
||||
addKeyFrames(keyFrames, 900, 2.8);
|
||||
addKeyFrames(keyFrames, 1350, 4.2);
|
||||
keyFrames.add(new KeyFrame(Duration.seconds(5.6),
|
||||
new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR),
|
||||
new KeyValue(arc.startAngleProperty(),
|
||||
1845 + startingAngle,
|
||||
Interpolator.LINEAR)));
|
||||
|
||||
timeline = new Timeline();
|
||||
timeline.getKeyFrames().setAll(keyFrames);
|
||||
} else {
|
||||
final double arcLength = 250;
|
||||
timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)),
|
||||
new KeyFrame(Duration.seconds(1.2),
|
||||
new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR),
|
||||
new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE))
|
||||
);
|
||||
}
|
||||
|
||||
timeline.setCycleCount(Timeline.INDEFINITE);
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private void clearAnimation() {
|
||||
if (timeline != null) {
|
||||
timeline.stop();
|
||||
timeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
treeShowingProperty.dispose();
|
||||
clearAnimation();
|
||||
arc = null;
|
||||
track = null;
|
||||
control = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,13 +19,25 @@
|
||||
|
||||
package com.jfoenix.utils;
|
||||
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.stage.Window;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Shadi Shaheen
|
||||
/// @version 1.0
|
||||
@@ -58,6 +70,70 @@ public final class JFXNodeUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static final @NotNull Function<Node, ObservableBooleanValue> treeVisiblePropertyGetter = initTreeVisiblePropertyGetter();
|
||||
|
||||
private static @NotNull Function<Node, ObservableBooleanValue> initTreeVisiblePropertyGetter() {
|
||||
|
||||
MethodHandles.Lookup lookup;
|
||||
try {
|
||||
lookup = MethodHandles.privateLookupIn(Node.class, MethodHandles.lookup());
|
||||
} catch (IllegalAccessException e) {
|
||||
LOG.warning("Failed to get private lookup for Node", e);
|
||||
return JFXNodeUtils::defaultTreeVisibleProperty;
|
||||
}
|
||||
|
||||
try {
|
||||
Method treeVisiblePropertyMethod = Node.class.getDeclaredMethod("treeVisibleProperty");
|
||||
if (!ObservableBooleanValue.class.isAssignableFrom(treeVisiblePropertyMethod.getReturnType())) {
|
||||
LOG.warning("Node.treeVisibleProperty() does not return ObservableBooleanValue: " + treeVisiblePropertyMethod.getReturnType());
|
||||
return JFXNodeUtils::defaultTreeVisibleProperty;
|
||||
}
|
||||
|
||||
MethodHandle handle = lookup.unreflect(treeVisiblePropertyMethod)
|
||||
.asType(MethodType.methodType(ObservableBooleanValue.class, Node.class));
|
||||
return item -> {
|
||||
try {
|
||||
return (ObservableBooleanValue) handle.invokeExact((Node) item);
|
||||
} catch (RuntimeException | Error e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new AssertionError("Unreachable", e);
|
||||
}
|
||||
};
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to get method handle for Node.treeVisibleProperty()", e);
|
||||
return JFXNodeUtils::defaultTreeVisibleProperty;
|
||||
}
|
||||
}
|
||||
|
||||
/// If `Node.treeVisibleProperty()` does not exist, use `Node.visibleProperty()` as a fallback
|
||||
private static @NotNull ObservableBooleanValue defaultTreeVisibleProperty(Node item) {
|
||||
return item.visibleProperty();
|
||||
}
|
||||
|
||||
public static @NotNull ObservableBooleanValue treeVisibleProperty(Node item) {
|
||||
return treeVisiblePropertyGetter.apply(item);
|
||||
}
|
||||
|
||||
public static boolean isTreeVisible(Node item) {
|
||||
return treeVisibleProperty(item).getValue();
|
||||
}
|
||||
|
||||
public static boolean isTreeShowing(Node node) {
|
||||
if (node == null)
|
||||
return false;
|
||||
|
||||
Scene scene = node.getScene();
|
||||
if (scene == null)
|
||||
return false;
|
||||
|
||||
Window window = scene.getWindow();
|
||||
if (window == null || !window.isShowing())
|
||||
return false;
|
||||
|
||||
return isTreeVisible(node);
|
||||
}
|
||||
|
||||
private JFXNodeUtils() {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.
|
||||
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||
*
|
||||
* This code is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License version 2 only, as
|
||||
* published by the Free Software Foundation. Oracle designates this
|
||||
* particular file as subject to the "Classpath" exception as provided
|
||||
* by Oracle in the LICENSE file that accompanied this code.
|
||||
*
|
||||
* This code 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
|
||||
* version 2 for more details (a copy is included in the LICENSE file that
|
||||
* accompanied this code).
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License version
|
||||
* 2 along with this work; if not, write to the Free Software Foundation,
|
||||
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
*
|
||||
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
|
||||
* or visit www.oracle.com if you need additional information or have any
|
||||
* questions.
|
||||
*/
|
||||
|
||||
package com.jfoenix.utils;
|
||||
|
||||
import javafx.beans.property.ReadOnlyBooleanPropertyBase;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Window;
|
||||
|
||||
/**
|
||||
* Used to observe changes in tree showing status for a {@link Node}. For a Node's tree to be showing
|
||||
* it must be visible, its ancestors must be visible, the node must be part of a {@link Scene} and
|
||||
* the scene must have a {@link Window} which is currently showing.<p>
|
||||
*
|
||||
* This class provides the exact same functionality as {@link JFXNodeUtils#isTreeShowing(Node)} in
|
||||
* an observable form.
|
||||
*/
|
||||
public class TreeShowingProperty extends ReadOnlyBooleanPropertyBase {
|
||||
private final ChangeListener<Boolean> windowShowingChangedListener = (obs, old, current) -> updateTreeShowing();
|
||||
private final ChangeListener<Window> sceneWindowChangedListener = (obs, old, current) -> windowChanged(old, current);
|
||||
private final ChangeListener<Scene> nodeSceneChangedListener = (obs, old, current) -> sceneChanged(old, current);
|
||||
|
||||
private final Node node;
|
||||
private final ObservableBooleanValue treeVisibleProperty;
|
||||
|
||||
private boolean valid;
|
||||
private boolean treeShowing;
|
||||
|
||||
/**
|
||||
* Constructs a new instance.
|
||||
*
|
||||
* @param node a {@link Node} for which the tree showing status should be observed, cannot be null
|
||||
*/
|
||||
public TreeShowingProperty(Node node) {
|
||||
this.node = node;
|
||||
this.treeVisibleProperty = JFXNodeUtils.treeVisibleProperty(node);
|
||||
|
||||
this.node.sceneProperty().addListener(nodeSceneChangedListener);
|
||||
this.treeVisibleProperty.addListener(windowShowingChangedListener);
|
||||
|
||||
sceneChanged(null, node.getScene());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "treeShowing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up any listeners that this class may have registered on the {@link Node}
|
||||
* that was supplied at construction.
|
||||
*/
|
||||
public void dispose() {
|
||||
node.sceneProperty().removeListener(nodeSceneChangedListener);
|
||||
|
||||
if (treeVisibleProperty != null)
|
||||
treeVisibleProperty.removeListener(windowShowingChangedListener);
|
||||
|
||||
valid = false; // prevents unregistration from triggering an invalidation notification
|
||||
sceneChanged(node.getScene(), null);
|
||||
}
|
||||
|
||||
protected void invalidate() {
|
||||
if (valid) {
|
||||
valid = false;
|
||||
fireValueChangedEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean get() {
|
||||
if (!valid) {
|
||||
updateTreeShowing();
|
||||
valid = true;
|
||||
}
|
||||
|
||||
return treeShowing;
|
||||
}
|
||||
|
||||
private void sceneChanged(Scene oldScene, Scene newScene) {
|
||||
if (oldScene != null) {
|
||||
oldScene.windowProperty().removeListener(sceneWindowChangedListener);
|
||||
}
|
||||
if (newScene != null) {
|
||||
newScene.windowProperty().addListener(sceneWindowChangedListener);
|
||||
}
|
||||
|
||||
windowChanged(
|
||||
oldScene == null ? null : oldScene.getWindow(),
|
||||
newScene == null ? null : newScene.getWindow()
|
||||
);
|
||||
}
|
||||
|
||||
private void windowChanged(Window oldWindow, Window newWindow) {
|
||||
if (oldWindow != null) {
|
||||
oldWindow.showingProperty().removeListener(windowShowingChangedListener);
|
||||
}
|
||||
if (newWindow != null) {
|
||||
newWindow.showingProperty().addListener(windowShowingChangedListener);
|
||||
}
|
||||
|
||||
updateTreeShowing();
|
||||
}
|
||||
|
||||
private void updateTreeShowing() {
|
||||
boolean newValue = JFXNodeUtils.isTreeShowing(node);
|
||||
|
||||
if (newValue != treeShowing) {
|
||||
treeShowing = newValue;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
import java.io.IOException;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
@@ -47,13 +48,17 @@ public final class EntryPoint {
|
||||
System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION);
|
||||
|
||||
createHMCLDirectories();
|
||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
||||
LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs"));
|
||||
|
||||
checkWine();
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
checkDirectoryPath();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && !isInsideMacAppBundle())
|
||||
initIcon();
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||
if (!isInsideMacAppBundle())
|
||||
initIcon();
|
||||
}
|
||||
|
||||
checkJavaFX();
|
||||
verifyJavaFX();
|
||||
@@ -135,29 +140,29 @@ public final class EntryPoint {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,25 +197,12 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkDirectoryPath() {
|
||||
String currentDir = System.getProperty("user.dir", "");
|
||||
if (currentDir.contains("!")) {
|
||||
LOG.error("The current working path contains an exclamation mark: " + currentDir);
|
||||
// No Chinese translation because both Swing and JavaFX cannot render Chinese character properly when exclamation mark exists in the path.
|
||||
showErrorAndExit("Exclamation mark(!) is not allowed in the path where HMCL is in.\n"
|
||||
+ "The path is " + currentDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkJavaFX() {
|
||||
try {
|
||||
SelfDependencyPatcher.patch();
|
||||
} catch (SelfDependencyPatcher.PatchException e) {
|
||||
LOG.error("Unable to patch JVM", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.missing"));
|
||||
} catch (SelfDependencyPatcher.IncompatibleVersionException e) {
|
||||
LOG.error("Unable to patch JVM", e);
|
||||
showErrorAndExit(i18n("fatal.javafx.incompatible"));
|
||||
} catch (CancellationException e) {
|
||||
LOG.error("User cancels downloading JavaFX", e);
|
||||
exit(0);
|
||||
@@ -231,6 +223,20 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnableNativeAccess() {
|
||||
if (JavaRuntime.CURRENT_VERSION > 21) {
|
||||
try {
|
||||
|
||||
@@ -17,35 +17,37 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SambaException;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.CrashReporter;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.CommandBuilder;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -63,10 +65,11 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class Launcher extends Application {
|
||||
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
||||
@@ -96,47 +99,51 @@ public final class Launcher extends Application {
|
||||
|
||||
try {
|
||||
try {
|
||||
ConfigHolder.init();
|
||||
SettingsManager.init();
|
||||
initializeSettingsRuntime();
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& ConfigHolder.isNewlyCreated()
|
||||
&& SettingsManager.isNewlyCreated()
|
||||
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
||||
return;
|
||||
} else {
|
||||
checkConfigInTempDir();
|
||||
}
|
||||
|
||||
if (ConfigHolder.isOwnerChanged()) {
|
||||
if (SettingsManager.isOwnerChanged()) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfigHolder.isUnsupportedVersion()) {
|
||||
if (SettingsManager.hasReadOnlyCoreSettings()) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
|
||||
}
|
||||
|
||||
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
|
||||
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
// runLater to ensure SettingsManager.init() finished initialization
|
||||
Platform.runLater(() -> {
|
||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
||||
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
Themes.applyNativeDarkMode(primaryStage);
|
||||
|
||||
UpdateChecker.init();
|
||||
|
||||
primaryStage.show();
|
||||
@@ -146,6 +153,26 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes modules and runtime services that depend on loaded settings.
|
||||
private static void initializeSettingsRuntime() {
|
||||
DownloadProviders.init();
|
||||
ProxyManager.init();
|
||||
Accounts.init();
|
||||
GameDirectoryManager.init();
|
||||
AuthlibInjectorServers.init();
|
||||
AnimationUtils.init();
|
||||
|
||||
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
|
||||
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
|
||||
String commonDirectory = settings().getResolvedCommonDirectory();
|
||||
if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) {
|
||||
return commonDirectory;
|
||||
} else {
|
||||
return LauncherSettings.getDefaultCommonDirectory();
|
||||
}
|
||||
}, settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
|
||||
}
|
||||
|
||||
private static void appendScreen(StringBuilder builder, Screen screen) {
|
||||
Rectangle2D bounds = screen.getBounds();
|
||||
double scale = screen.getOutputScaleX();
|
||||
@@ -182,8 +209,29 @@ public final class Launcher extends Application {
|
||||
return new Alert(alertType, contentText, buttons).showAndWait().orElse(null);
|
||||
}
|
||||
|
||||
private static boolean confirmWithCountdown(Alert.AlertType alertType, String contentText, int seconds) {
|
||||
Alert alert = new Alert(alertType, contentText, ButtonType.YES, ButtonType.NO);
|
||||
Button okButton = (Button) alert.getDialogPane().lookupButton(ButtonType.YES);
|
||||
|
||||
okButton.setDisable(true);
|
||||
|
||||
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
|
||||
for (int i = 0; i < seconds; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(okButton.textProperty(), i18n("button.ok.countdown", seconds - i)));
|
||||
}
|
||||
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
|
||||
new KeyValue(okButton.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(okButton.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
alert.setOnShown(e -> timeline.play());
|
||||
alert.setOnCloseRequest(e -> timeline.stop());
|
||||
return alert.showAndWait().orElse(null) == ButtonType.YES;
|
||||
}
|
||||
|
||||
private static boolean isConfigInTempDir() {
|
||||
String configPath = ConfigHolder.configLocation().toString();
|
||||
String configPath = SettingsManager.localConfigDirectory().toString();
|
||||
|
||||
String tmpdir = System.getProperty("java.io.tmpdir");
|
||||
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
||||
@@ -220,8 +268,8 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static void checkConfigInTempDir() {
|
||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
||||
&& showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) {
|
||||
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
}
|
||||
@@ -231,29 +279,32 @@ public final class Launcher extends Application {
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
|
||||
if (Files.isWritable(configDirectory) || 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());
|
||||
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().add("sudo", "chown", "-R", userName).addAll(files).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,
|
||||
@@ -300,8 +351,8 @@ 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 User Home: " + Metadata.HMCL_USER_HOME);
|
||||
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
||||
@@ -321,6 +372,8 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
launch(Launcher.class, args);
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
@@ -46,9 +45,9 @@ public final class Metadata {
|
||||
public static final int RECOMMENDED_JAVA_VERSION = 21;
|
||||
|
||||
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
|
||||
public static final String ABOUT_URL = PUBLISH_URL + "/about";
|
||||
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
|
||||
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
|
||||
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
|
||||
|
||||
public static final String DOCS_URL = "https://docs.hmcl.net";
|
||||
public static final String CONTACT_URL = DOCS_URL + "/help.html";
|
||||
@@ -59,34 +58,38 @@ public final class Metadata {
|
||||
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
||||
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
||||
|
||||
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
public static final Path HMCL_GLOBAL_DIRECTORY;
|
||||
public static final Path HMCL_CURRENT_DIRECTORY;
|
||||
public static final Path HMCL_USER_HOME;
|
||||
public static final Path HMCL_LOCAL_HOME;
|
||||
public static final Path DEPENDENCIES_DIRECTORY;
|
||||
|
||||
static {
|
||||
String hmclHome = System.getProperty("hmcl.home");
|
||||
if (hmclHome == null) {
|
||||
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
|
||||
if (StringUtils.isBlank(hmclHome)) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||
if (StringUtils.isNotBlank(xdgData)) {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir");
|
||||
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
|
||||
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
|
||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
|
||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||
: HMCL_LOCAL_HOME.resolve("dependencies");
|
||||
}
|
||||
|
||||
public static boolean isStable() {
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.countly;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
|
||||
@@ -21,17 +21,20 @@ import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AuthInfo;
|
||||
import org.jackhuang.hmcl.launch.DefaultLauncher;
|
||||
import org.jackhuang.hmcl.launch.ProcessListener;
|
||||
import org.jackhuang.hmcl.util.NativePatcher;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.CommandBuilder;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -60,7 +63,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
if (config().isDisableAutoGameOptions())
|
||||
if (options.isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(version.getId());
|
||||
@@ -150,4 +153,60 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
generateOptionsTxt();
|
||||
super.makeLaunchScript(scriptFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void appendJvmArgs(CommandBuilder result) {
|
||||
super.appendJvmArgs(result);
|
||||
|
||||
if (options.isAllowAutoAgent()
|
||||
&& !options.isNoGeneratedJVMArgs()
|
||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
|
||||
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
||||
try {
|
||||
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to extract lwjgl-unsafe-agent", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path extractLwjglUnsafeAgent() throws IOException {
|
||||
String agentVersion = JarUtils.getAttribute("hmcl.lwjgl-unsafe-agent.version", null);
|
||||
if (agentVersion == null) {
|
||||
throw new IOException("Missing hmcl.lwjgl-unsafe-agent.version attribute");
|
||||
}
|
||||
|
||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||
String fileName = library.getArtifact().getFileName();
|
||||
|
||||
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
|
||||
if (agentPath.toString().contains("=")) {
|
||||
throw new IOException("Invalid library path: " + agentPath);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try (InputStream input = DefaultLauncher.class.getResourceAsStream("/assets/" + fileName)) {
|
||||
if (input == null) {
|
||||
throw new IOException("/assets/" + fileName + " not found");
|
||||
}
|
||||
|
||||
bytes = input.readAllBytes();
|
||||
}
|
||||
|
||||
if (Files.isRegularFile(agentPath)) {
|
||||
try {
|
||||
if (Files.size(agentPath) == bytes.length) {
|
||||
return agentPath;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to check size of " + agentPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
Files.createDirectories(agentPath.getParent());
|
||||
FileUtils.saveSafely(agentPath, output -> output.write(bytes));
|
||||
return agentPath;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,25 +17,39 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.StringBinding;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.mod.ModAdviser;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.modpack.ModAdviser;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.setting.DefaultIsolationType;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameWindowType;
|
||||
import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.ProxyType;
|
||||
import org.jackhuang.hmcl.setting.SettingFileUtils;
|
||||
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
@@ -43,6 +57,7 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -54,53 +69,136 @@ import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL game repository implementation backed by a GameDirectory and per-instance game settings.
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
private final Profile profile;
|
||||
/// References an optional game instance in a repository.
|
||||
///
|
||||
/// @param repository the owning game repository
|
||||
/// @param instanceId the game instance ID, or `null` when only repository context is available
|
||||
@NotNullByDefault
|
||||
public record InstanceReference(HMCLGameRepository repository, @Nullable String instanceId) {
|
||||
}
|
||||
|
||||
// local version settings
|
||||
private final Map<String, VersionSetting> localVersionSettings = new HashMap<>();
|
||||
/// Directory under the version 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";
|
||||
|
||||
/// The persistent game directory for this repository.
|
||||
private final GameDirectory gameDirectory;
|
||||
|
||||
/// The selected instance ID persisted for this repository's game directory.
|
||||
private final StringBinding selectedInstance;
|
||||
|
||||
// instance game settings
|
||||
private final Map<String, GameSettings.Instance> instanceGameSettings = new HashMap<>();
|
||||
/// Instance IDs whose local game settings file has already been checked.
|
||||
private final Set<String> loadedInstanceGameSettings = new HashSet<>();
|
||||
private final Set<String> readOnlyInstanceGameSettings = new HashSet<>();
|
||||
private final Set<String> beingModpackVersions = new HashSet<>();
|
||||
|
||||
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
|
||||
|
||||
public HMCLGameRepository(Profile profile, Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
this.profile = profile;
|
||||
/// Creates a repository backed by the given game directory.
|
||||
public HMCLGameRepository(GameDirectory gameDirectory) {
|
||||
super(gameDirectory.getPath().toPath());
|
||||
this.gameDirectory = gameDirectory;
|
||||
this.selectedInstance = Bindings.stringValueAt(settings().getSelectedInstance(), gameDirectory.getId());
|
||||
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
/// Returns the persistent game directory for this repository.
|
||||
public GameDirectory getGameDirectory() {
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameDirectoryType getGameDirectoryType(String id) {
|
||||
if (beingModpackVersions.contains(id) || isModpack(id)) {
|
||||
return GameDirectoryType.VERSION_FOLDER;
|
||||
} else {
|
||||
return getVersionSetting(id).getGameDirType();
|
||||
/// Returns the selected instance ID property for this repository's game directory.
|
||||
public StringBinding selectedInstanceProperty() {
|
||||
return selectedInstance;
|
||||
}
|
||||
|
||||
/// Returns the selected instance ID for this repository's game directory.
|
||||
public @Nullable String getSelectedInstance() {
|
||||
return selectedInstance.get();
|
||||
}
|
||||
|
||||
/// Sets the selected instance ID for this repository's game directory.
|
||||
public void setSelectedInstance(@Nullable String instance) {
|
||||
settings().setSelectedInstance(gameDirectory.getId(), instance);
|
||||
}
|
||||
|
||||
/// Refreshes the selected instance ID after versions are loaded.
|
||||
public void refreshSelectedInstance() {
|
||||
@Nullable String selectedInstance = settings().getSelectedInstance(gameDirectory.getId());
|
||||
@Nullable String refreshedInstance = selectedInstance;
|
||||
if (!hasVersion(refreshedInstance)) {
|
||||
refreshedInstance = getVersions().isEmpty() ? null : getVersions().iterator().next().getId();
|
||||
}
|
||||
if (!Objects.equals(selectedInstance, refreshedInstance)) {
|
||||
setSelectedInstance(refreshedInstance);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a dependency manager using the currently selected download provider.
|
||||
public DefaultDependencyManager getDependency() {
|
||||
return getDependency(DownloadProviders.getDownloadProvider());
|
||||
}
|
||||
|
||||
/// Returns a dependency manager using the given download provider.
|
||||
public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) {
|
||||
return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory(String id) {
|
||||
switch (getGameDirectoryType(id)) {
|
||||
case VERSION_FOLDER:
|
||||
return getVersionRoot(id);
|
||||
case ROOT_FOLDER:
|
||||
return super.getRunDirectory(id);
|
||||
case CUSTOM:
|
||||
try {
|
||||
return Path.of(getVersionSetting(id).getGameDir());
|
||||
} catch (InvalidPathException ignored) {
|
||||
return getVersionRoot(id);
|
||||
}
|
||||
default:
|
||||
throw new AssertionError("Unreachable");
|
||||
if (beingModpackVersions.contains(id) || isModpack(id)) {
|
||||
return getVersionRoot(id);
|
||||
}
|
||||
|
||||
GameSettings.Instance localSetting = getInstanceGameSettings(id);
|
||||
boolean useInstanceRunningDirectory =
|
||||
localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory);
|
||||
if (StringUtils.isBlank(runningDirectory)) {
|
||||
return useInstanceRunningDirectory ? getVersionRoot(id) : super.getRunDirectory(id);
|
||||
}
|
||||
|
||||
try {
|
||||
return Path.of(runningDirectory);
|
||||
} catch (InvalidPathException ignored) {
|
||||
return getVersionRoot(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the running directory string selected by the current source.
|
||||
private String getSelectedRunningDirectory(
|
||||
@Nullable GameSettings.Instance localSetting,
|
||||
boolean useInstanceRunningDirectory) {
|
||||
if (useInstanceRunningDirectory) {
|
||||
if (localSetting == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
GameSettings.Preset parent = getParentGameSettings(localSetting);
|
||||
//noinspection DataFlowIssue
|
||||
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
public Stream<Version> getDisplayVersions() {
|
||||
@@ -112,14 +210,11 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
|
||||
@Override
|
||||
protected void refreshVersionsImpl() {
|
||||
localVersionSettings.clear();
|
||||
instanceGameSettings.clear();
|
||||
loadedInstanceGameSettings.clear();
|
||||
readOnlyInstanceGameSettings.clear();
|
||||
super.refreshVersionsImpl();
|
||||
versions.keySet().forEach(this::loadLocalVersionSetting);
|
||||
versions.keySet().forEach(version -> {
|
||||
if (isModpack(version)) {
|
||||
specializeVersionSetting(version);
|
||||
}
|
||||
});
|
||||
versions.keySet().forEach(this::loadInstanceGameSettings);
|
||||
|
||||
try {
|
||||
Path file = getBaseDirectory().resolve("launcher_profiles.json");
|
||||
@@ -147,6 +242,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
clean(getRunDirectory(id));
|
||||
}
|
||||
|
||||
/// Removes a version from disk and drops any cached instance settings for that version.
|
||||
@Override
|
||||
public boolean removeVersionFromDisk(String id) {
|
||||
boolean removed = super.removeVersionFromDisk(id);
|
||||
if (removed) {
|
||||
instanceGameSettings.remove(id);
|
||||
loadedInstanceGameSettings.remove(id);
|
||||
readOnlyInstanceGameSettings.remove(id);
|
||||
beingModpackVersions.remove(id);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
|
||||
Path srcDir = getVersionRoot(srcId);
|
||||
Path dstDir = getVersionRoot(dstId);
|
||||
@@ -174,91 +282,287 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
Files.copy(fromJson, toJson);
|
||||
|
||||
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId));
|
||||
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
|
||||
|
||||
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
|
||||
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
|
||||
oldVersionSetting.setUsesGlobal(false);
|
||||
oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
|
||||
saveVersionSetting(dstId);
|
||||
boolean copyOriginalGameDir;
|
||||
try {
|
||||
copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getVersionRoot(srcId));
|
||||
} catch (IOException e) {
|
||||
copyOriginalGameDir = true;
|
||||
}
|
||||
|
||||
Path srcGameDir = getRunDirectory(srcId);
|
||||
|
||||
GameSettings.Instance newGameSettings = copyInstanceGameSettings(srcId);
|
||||
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
newGameSettings.runningDirectoryProperty().setValue("");
|
||||
initInstanceGameSettings(dstId, newGameSettings);
|
||||
saveGameSettingsSync(dstId);
|
||||
|
||||
Path dstGameDir = getRunDirectory(dstId);
|
||||
|
||||
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
|
||||
if (copyOriginalGameDir)
|
||||
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
}
|
||||
|
||||
private Path getLocalVersionSettingFile(String id) {
|
||||
return getVersionRoot(id).resolve("hmclversion.cfg");
|
||||
private GameSettings.Instance copyInstanceGameSettings(String id) {
|
||||
GameSettings.Instance setting = getInstanceGameSettings(id);
|
||||
if (setting != null) {
|
||||
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
|
||||
}
|
||||
|
||||
GameSettings.Instance copied = new GameSettings.Instance();
|
||||
copied.parentProperty().setValue(getEffectiveGameSettings(id).getPreset().idProperty().getValue());
|
||||
return copied;
|
||||
}
|
||||
|
||||
private void loadLocalVersionSetting(String id) {
|
||||
Path file = getLocalVersionSettingFile(id);
|
||||
if (Files.exists(file))
|
||||
/// Returns the HMCL-managed metadata directory under the version root.
|
||||
///
|
||||
/// This directory stores instance-scoped files owned by HMCL.
|
||||
public Path getInstanceMetadataDirectory(String id) {
|
||||
return getVersionRoot(id).resolve(INSTANCE_METADATA_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
|
||||
public Path getInstanceConfigDirectory(String id) {
|
||||
return getInstanceMetadataDirectory(id).resolve(INSTANCE_CONFIG_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed state directory under the instance metadata directory.
|
||||
public Path getInstanceStateDirectory(String id) {
|
||||
return getInstanceMetadataDirectory(id).resolve(INSTANCE_STATE_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the current local game settings path under the instance configuration directory.
|
||||
private Path getInstanceGameSettingsFile(String id) {
|
||||
return getInstanceConfigDirectory(id).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
|
||||
}
|
||||
|
||||
private void loadInstanceGameSettings(String id) {
|
||||
loadedInstanceGameSettings.add(id);
|
||||
InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(id));
|
||||
if (result.setting() != null) {
|
||||
initInstanceGameSettings(id, result.setting(), result.allowSave());
|
||||
return;
|
||||
}
|
||||
if (!result.allowSave()) {
|
||||
readOnlyInstanceGameSettings.add(id);
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = gameDirectory.getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
|
||||
this, id,
|
||||
legacyParent);
|
||||
if (migrationResult != null) {
|
||||
initInstanceGameSettings(id, migrationResult.setting());
|
||||
try {
|
||||
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
|
||||
initLocalVersionSetting(id, versionSetting);
|
||||
} catch (Exception ex) {
|
||||
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
|
||||
initLocalVersionSetting(id, new VersionSetting());
|
||||
saveGameSettingsSync(id);
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + id, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new version setting if version id has no version setting.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return new version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting createLocalVersionSetting(String id) {
|
||||
if (!hasVersion(id))
|
||||
/// Loads a new-format instance game settings file.
|
||||
private InstanceGameSettingsLoadResult loadGameSettingsFile(Path file) {
|
||||
if (!Files.exists(file)) {
|
||||
return new InstanceGameSettingsLoadResult(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 InstanceGameSettingsLoadResult(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: "
|
||||
+ file + ", Actual: " + schemaResult.invalidValue());
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: "
|
||||
+ file + ", Actual: " + schemaResult.actual());
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: "
|
||||
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA ->
|
||||
LOG.warning("Unsupported instance game settings schema. Expected: "
|
||||
+ GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual());
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setSavable(false);
|
||||
return new InstanceGameSettingsLoadResult(fallback, false);
|
||||
}
|
||||
|
||||
GameSettings.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 InstanceGameSettingsLoadResult(fallback, true);
|
||||
}
|
||||
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
}
|
||||
return new InstanceGameSettingsLoadResult(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 InstanceGameSettingsLoadResult(fallback, true);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load game setting " + file, ex);
|
||||
return new InstanceGameSettingsLoadResult(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable GameSettings.Instance createInstanceGameSettings(String id) {
|
||||
if (!hasVersion(id)) {
|
||||
return null;
|
||||
if (localVersionSettings.containsKey(id))
|
||||
return getLocalVersionSetting(id);
|
||||
else
|
||||
return initLocalVersionSetting(id, new VersionSetting());
|
||||
}
|
||||
if (readOnlyInstanceGameSettings.contains(id)) {
|
||||
return null;
|
||||
}
|
||||
if (instanceGameSettings.containsKey(id)) {
|
||||
return getInstanceGameSettings(id);
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = new GameSettings.Instance();
|
||||
return initInstanceGameSettings(id, setting);
|
||||
}
|
||||
|
||||
private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) {
|
||||
localVersionSettings.put(id, vs);
|
||||
vs.addListener(a -> saveVersionSetting(id));
|
||||
return vs;
|
||||
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting) {
|
||||
return initInstanceGameSettings(id, setting, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version setting for version id.
|
||||
*
|
||||
* @param id version id
|
||||
* @return corresponding version setting, null if the version has no its own version setting.
|
||||
*/
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
loadLocalVersionSetting(id);
|
||||
VersionSetting setting = localVersionSettings.get(id);
|
||||
if (setting != null && isModpack(id))
|
||||
setting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
private GameSettings.Instance initInstanceGameSettings(String id, GameSettings.Instance setting, boolean allowSave) {
|
||||
normalizeRunningDirectoryOverride(setting);
|
||||
setting.setSavable(allowSave);
|
||||
loadedInstanceGameSettings.add(id);
|
||||
instanceGameSettings.put(id, setting);
|
||||
if (allowSave) {
|
||||
readOnlyInstanceGameSettings.remove(id);
|
||||
setting.addListener(a -> saveGameSettings(id));
|
||||
} else {
|
||||
readOnlyInstanceGameSettings.add(id);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSettingOrCreate(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null) {
|
||||
vs = createLocalVersionSetting(id);
|
||||
/// Keeps old local custom running directories effective under the new source-selection model.
|
||||
private void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
|
||||
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
return vs;
|
||||
}
|
||||
|
||||
public VersionSetting getVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null || vs.isUsesGlobal()) {
|
||||
profile.getGlobal().setUsesGlobal(true);
|
||||
return profile.getGlobal();
|
||||
} else
|
||||
return vs;
|
||||
@Nullable
|
||||
public GameSettings.Instance getInstanceGameSettings(String id) {
|
||||
if (!loadedInstanceGameSettings.contains(id)) {
|
||||
loadInstanceGameSettings(id);
|
||||
}
|
||||
return instanceGameSettings.get(id);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public GameSettings.Instance getInstanceGameSettingsOrCreate(String id) {
|
||||
GameSettings.Instance setting = getInstanceGameSettings(id);
|
||||
if (setting == null) {
|
||||
setting = createInstanceGameSettings(id);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Returns whether the instance-specific game settings file cannot be overwritten safely.
|
||||
///
|
||||
/// @param id the instance ID
|
||||
/// @return whether the instance settings are loaded in read-only mode
|
||||
public boolean isInstanceGameSettingsReadOnly(String id) {
|
||||
if (!loadedInstanceGameSettings.contains(id)) {
|
||||
loadInstanceGameSettings(id);
|
||||
}
|
||||
|
||||
return readOnlyInstanceGameSettings.contains(id);
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the instance-specific game settings file with the currently loaded settings.
|
||||
///
|
||||
/// @param id the instance ID
|
||||
public void forceOverwriteInstanceGameSettings(String id) {
|
||||
if (!loadedInstanceGameSettings.contains(id)) {
|
||||
loadInstanceGameSettings(id);
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = instanceGameSettings.get(id);
|
||||
if (setting == null) {
|
||||
setting = new GameSettings.Instance();
|
||||
instanceGameSettings.put(id, setting);
|
||||
loadedInstanceGameSettings.add(id);
|
||||
}
|
||||
|
||||
boolean installAutoSave = !setting.isSavable();
|
||||
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
setting.setSavable(true);
|
||||
setting.setBackupOnNextSave(false);
|
||||
readOnlyInstanceGameSettings.remove(id);
|
||||
saveGameSettings(id);
|
||||
if (installAutoSave) {
|
||||
setting.addListener(a -> saveGameSettings(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the explicit parent preset of the instance, falling back to the default preset.
|
||||
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
|
||||
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
|
||||
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
|
||||
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
|
||||
}
|
||||
|
||||
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
|
||||
public boolean shouldIsolateNewInstance(boolean modded) {
|
||||
GameSettings.Preset preset = getParentGameSettings(null);
|
||||
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
|
||||
return switch (type) {
|
||||
case NEVER -> false;
|
||||
case ALWAYS -> true;
|
||||
case MODDED -> modded;
|
||||
};
|
||||
}
|
||||
|
||||
/// Applies default isolation to a new instance before the version metadata is saved.
|
||||
public void applyDefaultIsolationSettingForNewInstance(String id, boolean modded) {
|
||||
if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = getInstanceGameSettings(id);
|
||||
if (setting == null) {
|
||||
setting = initInstanceGameSettings(id, new GameSettings.Instance());
|
||||
}
|
||||
if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
|
||||
saveGameSettings(id);
|
||||
}
|
||||
}
|
||||
|
||||
public GameSettings.Effective getEffectiveGameSettings(String id) {
|
||||
GameSettings.Instance instance = getInstanceGameSettings(id);
|
||||
return GameSettings.resolve(getParentGameSettings(instance), instance);
|
||||
}
|
||||
|
||||
public Optional<Path> getVersionIconFile(String id) {
|
||||
@@ -297,19 +601,19 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public Image getVersionIconImage(String id) {
|
||||
public Image getVersionIconImage(@Nullable String id) {
|
||||
if (id == null || !isLoaded())
|
||||
return VersionIconType.DEFAULT.getIcon();
|
||||
|
||||
VersionSetting vs = getLocalVersionSettingOrCreate(id);
|
||||
VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
|
||||
GameSettings.Instance setting = getInstanceGameSettings(id);
|
||||
VersionIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
|
||||
|
||||
if (iconType == VersionIconType.DEFAULT) {
|
||||
Version version = getVersion(id).resolve(this);
|
||||
Optional<Path> iconFile = getVersionIconFile(id);
|
||||
if (iconFile.isPresent()) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile.get());
|
||||
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load version icon of " + id, e);
|
||||
}
|
||||
@@ -321,6 +625,8 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
return VersionIconType.FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
|
||||
return VersionIconType.QUILT.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
|
||||
return VersionIconType.LEGACY_FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
|
||||
return VersionIconType.NEO_FORGE.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
|
||||
@@ -350,45 +656,73 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public void saveVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
public void saveGameSettings(String id) {
|
||||
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id))
|
||||
return;
|
||||
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
|
||||
GameSettings.Instance setting = instanceGameSettings.get(id);
|
||||
if (setting == null) {
|
||||
return;
|
||||
}
|
||||
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Make version use self version settings instead of the global one.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return specialized version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting specializeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
vs = createLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
return null;
|
||||
if (vs.isUsesGlobal()) {
|
||||
vs.setUsesGlobal(false);
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
return vs;
|
||||
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
public void globalizeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs != null)
|
||||
vs.setUsesGlobal(true);
|
||||
/// Saves instance-specific game settings synchronously.
|
||||
///
|
||||
/// @param id the instance ID
|
||||
/// @throws IOException if saving the file fails
|
||||
private void saveGameSettingsSync(String id) throws IOException {
|
||||
if (!instanceGameSettings.containsKey(id) || readOnlyInstanceGameSettings.contains(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = instanceGameSettings.get(id);
|
||||
if (setting == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path file = getInstanceGameSettingsFile(id).toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// 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 InstanceGameSettingsLoadResult(
|
||||
@Nullable GameSettings.Instance setting,
|
||||
boolean allowSave) {
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
VersionSetting vs = getVersionSetting(version);
|
||||
GameSettings.Effective vs = getEffectiveGameSettings(version);
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(version));
|
||||
|
||||
@Nullable Integer maxMemory;
|
||||
if (autoMemory) {
|
||||
maxMemory = noJVMOptions
|
||||
? null
|
||||
: Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setGameDir(gameDir)
|
||||
@@ -396,17 +730,13 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(version)
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
|
||||
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
|
||||
vs.getMaxMemory() * 1024L * 1024L,
|
||||
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
|
||||
vs.isAutoMemory()
|
||||
) / 1024 / 1024))
|
||||
.setMinMemory(vs.getMinMemory())
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
|
||||
.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.getEnvironmentVariables())
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
@@ -417,36 +747,30 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.isFullscreen())
|
||||
.setWrapper(vs.getWrapper())
|
||||
.setPreLaunchCommand(vs.getPreLaunchCommand())
|
||||
.setPostExitCommand(vs.getPostExitCommand())
|
||||
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
|
||||
.setNativesDirType(vs.getNativesDirType())
|
||||
.setNativesDir(vs.getNativesDir())
|
||||
.setProcessPriority(vs.getProcessPriority())
|
||||
.setRenderer(vs.getRenderer())
|
||||
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
|
||||
.setUseNativeGLFW(vs.isUseNativeGLFW())
|
||||
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
|
||||
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
|
||||
.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))
|
||||
.setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty))
|
||||
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
|
||||
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
if (StringUtils.isNotBlank(vs.getServerIp())) {
|
||||
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
|
||||
}
|
||||
|
||||
if (config().hasProxy()) {
|
||||
builder.setProxyType(config().getProxyType());
|
||||
builder.setProxyHost(config().getProxyHost());
|
||||
builder.setProxyPort(config().getProxyPort());
|
||||
|
||||
if (config().hasProxyAuth()) {
|
||||
builder.setProxyUser(config().getProxyUser());
|
||||
builder.setProxyPass(config().getProxyPass());
|
||||
}
|
||||
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
|
||||
if (quickPlayOption != null) {
|
||||
builder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
Path json = getModpackConfiguration(version);
|
||||
@@ -461,7 +785,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
@@ -502,10 +826,6 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create();
|
||||
|
||||
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
|
||||
|
||||
|
||||
@@ -521,7 +841,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
return false;
|
||||
|
||||
return FileUtils.isNameValid(id);
|
||||
return FileUtils.isNameValidForJar(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,21 +861,51 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
|
||||
if (auto) {
|
||||
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (available <= 0) {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested = Math.min(available <= threshold
|
||||
? (long) (available * 0.8)
|
||||
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
|
||||
16L * 1024 * 1024 * 1024);
|
||||
return Math.max(minimum, suggested);
|
||||
} else {
|
||||
return minimum;
|
||||
public static long getAutoAllocatedMemory(long available) {
|
||||
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (usable <= 0) {
|
||||
return available;
|
||||
}
|
||||
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested;
|
||||
if (usable <= threshold)
|
||||
suggested = (long) (usable * 0.8);
|
||||
else
|
||||
suggested = Math.min(
|
||||
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
|
||||
16L * 1024 * 1024 * 1024);
|
||||
return suggested;
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,10 @@ 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.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;
|
||||
@@ -45,9 +44,9 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
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();
|
||||
public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, String name) {
|
||||
this.repository = repository;
|
||||
this.dependency = repository.getDependency();
|
||||
this.zipFile = zipFile;
|
||||
this.name = name;
|
||||
this.modpack = modpack;
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.mod.ModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
|
||||
public final class HMCLModpackManifest implements ModpackManifest {
|
||||
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
||||
|
||||
@@ -20,11 +20,10 @@ 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;
|
||||
@@ -56,9 +55,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||
}
|
||||
|
||||
Profile profile = repository.getProfile();
|
||||
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(repository, zipFile, modpack, name));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -79,8 +76,8 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
|
||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
|
||||
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,13 @@ import org.jackhuang.hmcl.download.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.launch.*;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
@@ -57,6 +60,7 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -65,33 +69,36 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static javafx.application.Platform.runLater;
|
||||
import static javafx.application.Platform.setImplicitExit;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
|
||||
|
||||
public final class LauncherHelper {
|
||||
|
||||
private final Profile profile;
|
||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||
|
||||
private final HMCLGameRepository repository;
|
||||
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(HMCLGameRepository repository, Account account, String selectedVersion) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.account = Objects.requireNonNull(account);
|
||||
this.selectedVersion = Objects.requireNonNull(selectedVersion);
|
||||
this.setting = profile.getVersionSetting(selectedVersion);
|
||||
this.launcherVisibility = setting.getLauncherVisibility();
|
||||
this.showLogs = setting.isShowLogs();
|
||||
this.setting = repository.getEffectiveGameSettings(selectedVersion);
|
||||
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
|
||||
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
|
||||
this.launchingStepsPane.setTitle(i18n("version.launch"));
|
||||
}
|
||||
|
||||
@@ -140,8 +147,7 @@ public final class LauncherHelper {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
DefaultDependencyManager dependencyManager = profile.getDependency();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
|
||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
||||
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
|
||||
@@ -151,11 +157,11 @@ public final class LauncherHelper {
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
|
||||
TaskExecutor executor = checkGameState(profile, setting, version.get())
|
||||
TaskExecutor executor = checkGameState(repository, setting, version.get())
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
|
||||
if (setting.isNotCheckGame())
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
|
||||
@@ -170,43 +176,66 @@ public final class LauncherHelper {
|
||||
}
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
Renderer renderer = setting.getRenderer();
|
||||
if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
}
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer)
|
||||
|| renderer.mesaDriverName() == null)
|
||||
return null;
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT);
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
javaAgents.add(agent);
|
||||
return null;
|
||||
}
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
javaAgents.add(agent);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
|
||||
.thenComposeAsync(() -> {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
enableAutoAgentForCurrentSetting();
|
||||
future.complete(null);
|
||||
}, () -> {
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
future.complete(null);
|
||||
});
|
||||
});
|
||||
return Task.fromCompletableFuture(future);
|
||||
}
|
||||
})
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
selectedVersion, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
if (quickPlayOption != null) {
|
||||
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
|
||||
@@ -249,11 +278,11 @@ public final class LauncherHelper {
|
||||
i18n("message.doing"),
|
||||
() -> launchingLatch.getCount() == 0, 6.95
|
||||
).withStage("launch.state.waiting_launching"))
|
||||
.withStagesHint(Lang.immutableListOf(
|
||||
"launch.state.java",
|
||||
"launch.state.dependencies",
|
||||
"launch.state.logging_in",
|
||||
"launch.state.waiting_launching"))
|
||||
.withStagesHints(
|
||||
new Task.StagesHint("launch.state.java"),
|
||||
new Task.StagesHint("launch.state.dependencies", List.of("hmcl.install.assets", "hmcl.install.libraries", "hmcl.modpack.download")),
|
||||
new Task.StagesHint("launch.state.logging_in"),
|
||||
new Task.StagesHint("launch.state.waiting_launching"))
|
||||
.executor();
|
||||
launchingStepsPane.setExecutor(executor, false);
|
||||
executor.addTaskListener(new TaskListener() {
|
||||
@@ -325,13 +354,13 @@ public final class LauncherHelper {
|
||||
message = i18n("launch.failed.command_too_long");
|
||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||
Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"),
|
||||
(result, resolve, reject) -> {
|
||||
(result, handler) -> {
|
||||
if (CommandBuilder.setExecutionPolicy()) {
|
||||
LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'");
|
||||
resolve.run();
|
||||
handler.resolve();
|
||||
} else {
|
||||
LOG.warning("Failed to set ExecutionPolicy");
|
||||
reject.accept(i18n("launch.failed.execution_policy.failed_to_set"));
|
||||
handler.reject(i18n("launch.failed.execution_policy.failed_to_set"));
|
||||
}
|
||||
})
|
||||
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint")))
|
||||
@@ -357,8 +386,8 @@ public final class LauncherHelper {
|
||||
executor.start();
|
||||
}
|
||||
|
||||
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
|
||||
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, Version version) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null));
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
|
||||
|
||||
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
||||
@@ -369,9 +398,10 @@ public final class LauncherHelper {
|
||||
}
|
||||
});
|
||||
Task<JavaRuntime> task;
|
||||
if (setting.isNotCheckJVM()) {
|
||||
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
|
||||
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
|
||||
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
|
||||
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
|
||||
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
|
||||
if (java != null) {
|
||||
return Task.completed(java);
|
||||
@@ -384,12 +414,21 @@ public final class LauncherHelper {
|
||||
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
||||
|
||||
GameJavaVersion targetJavaVersion = null;
|
||||
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
try {
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
|
||||
GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
|
||||
GameJavaVersion minimumJavaVersion = null;
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
}
|
||||
}
|
||||
|
||||
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.getMajorVersion()) {
|
||||
if (minimumJavaVersion == null)
|
||||
minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
|
||||
if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) {
|
||||
Controllers.dialog(
|
||||
i18n("launch.failed.java_version_too_low"),
|
||||
i18n("message.error"),
|
||||
@@ -402,11 +441,20 @@ public final class LauncherHelper {
|
||||
targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
} else
|
||||
targetJavaVersion = version.getJavaVersion();
|
||||
} else {
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
}
|
||||
}
|
||||
|
||||
if (targetJavaVersion == null)
|
||||
targetJavaVersion = version.getJavaVersion();
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||
downloadJava(targetJavaVersion, profile)
|
||||
downloadJava(targetJavaVersion, repository)
|
||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(downloadedJava);
|
||||
@@ -433,7 +481,7 @@ public final class LauncherHelper {
|
||||
if (java != null) {
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violatedMandatoryConstraints.add(constraint);
|
||||
} else {
|
||||
@@ -468,9 +516,14 @@ public final class LauncherHelper {
|
||||
return result;
|
||||
} else {
|
||||
GameJavaVersion gameJavaVersion;
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM_JAVA_21))
|
||||
gameJavaVersion = GameJavaVersion.JAVA_21;
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
|
||||
.orElse("");
|
||||
|
||||
gameJavaVersion = !cleanroomVersion.isEmpty()
|
||||
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
||||
: GameJavaVersion.JAVA_21;
|
||||
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
gameJavaVersion = version.getJavaVersion();
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
||||
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
@@ -478,7 +531,7 @@ public final class LauncherHelper {
|
||||
gameJavaVersion = null;
|
||||
|
||||
if (gameJavaVersion != null) {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
if (throwable == null) {
|
||||
setting.setJavaAutoSelected();
|
||||
future.complete(downloadedJava);
|
||||
@@ -491,7 +544,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
|
||||
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
|
||||
return result;
|
||||
} else {
|
||||
@@ -528,7 +581,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"));
|
||||
@@ -560,9 +613,14 @@ public final class LauncherHelper {
|
||||
case MODDED_JAVA_21:
|
||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||
break;
|
||||
case CLEANROOM_JAVA_21:
|
||||
suggestions.add(i18n("launch.advice.cleanroom"));
|
||||
case CLEANROOM: {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
|
||||
if (!cleanroomVersion.isEmpty())
|
||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||
else
|
||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||
break;
|
||||
}
|
||||
case VANILLA_JAVA_8_51:
|
||||
suggestions.add(i18n("launch.advice.java8_51_1_13"));
|
||||
break;
|
||||
@@ -570,8 +628,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
@@ -582,7 +640,7 @@ 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));
|
||||
}
|
||||
|
||||
@@ -631,14 +689,14 @@ public final class LauncherHelper {
|
||||
return task.withStage("launch.state.java");
|
||||
}
|
||||
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
|
||||
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("launch.advice.require_newer_java_version", javaVersion.getMajorVersion()),
|
||||
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
||||
i18n("message.warning"),
|
||||
MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
|
||||
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
|
||||
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
@@ -721,6 +779,16 @@ public final class LauncherHelper {
|
||||
}
|
||||
}
|
||||
|
||||
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 [JavaProcess], one [HMCLProcessListener].
|
||||
@@ -901,7 +969,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();
|
||||
|
||||
+35
-27
@@ -18,9 +18,8 @@
|
||||
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.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.ui.versions.ModTranslations;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
@@ -32,12 +31,12 @@ import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
|
||||
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
|
||||
private static final int CONTAIN_CHINESE_WEIGHT = 10;
|
||||
|
||||
private static final int INITIAL_CAPACITY = 16;
|
||||
|
||||
protected abstract RemoteModRepository getBackedRemoteModRepository();
|
||||
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
|
||||
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
|
||||
@@ -47,38 +46,47 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
||||
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
|
||||
if (englishSearchFiltersSet.contains(englishWord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
englishSearchFiltersSet.add(englishWord);
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count >= 3) break;
|
||||
}
|
||||
|
||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
||||
if (englishSearchFiltersSet.isEmpty()) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||
{
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
|
||||
SearchResult searchResult = null;
|
||||
List<RemoteAddon> remoteAddons = List.of();
|
||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
remoteAddons = searchResult.getUnsortedResults().toList();
|
||||
if (!remoteAddons.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (RemoteAddon remoteAddon : remoteAddons) {
|
||||
if (chineseIndex > englishIndex) {
|
||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoteMod remoteMod = iterator.next();
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
searchResultArray[chineseIndex++] = remoteMod;
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
searchResultArray[englishIndex--] = remoteMod;
|
||||
searchResultArray[englishIndex--] = remoteAddon;
|
||||
}
|
||||
}
|
||||
totalPages = searchResult.getTotalPages();
|
||||
@@ -86,7 +94,7 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
@@ -110,22 +118,22 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod getModById(String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getModById(id);
|
||||
public RemoteAddon getModById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getModById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
|
||||
public RemoteAddon.File getModFile(String modId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getModFile(modId, fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionsById(id);
|
||||
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,18 @@ package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
|
||||
public final class Log {
|
||||
public static final int DEFAULT_LOG_LINES = 2000;
|
||||
|
||||
public static int getLogLines() {
|
||||
Integer lines = config().getLogLines();
|
||||
Integer lines = settings().logLinesProperty().get();
|
||||
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
||||
}
|
||||
|
||||
private final String log;
|
||||
private Log4jLevel level;
|
||||
private boolean selected = false;
|
||||
|
||||
public Log(String log) {
|
||||
this.log = log;
|
||||
@@ -57,14 +56,6 @@ public final class Log {
|
||||
return level;
|
||||
}
|
||||
|
||||
public boolean isSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return log;
|
||||
|
||||
@@ -17,21 +17,19 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
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 java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -43,7 +41,9 @@ public final class LogExporter {
|
||||
private LogExporter() {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
|
||||
public static CompletableFuture<Void> exportLogs(
|
||||
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
|
||||
PathMatcher logMatcher) {
|
||||
Path runDirectory = gameRepository.getRunDirectory(versionId);
|
||||
Path baseDirectory = gameRepository.getBaseDirectory();
|
||||
List<String> versions = new ArrayList<>();
|
||||
@@ -64,11 +64,11 @@ public final class LogExporter {
|
||||
}
|
||||
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (Zipper zipper = new Zipper(zipFile)) {
|
||||
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper);
|
||||
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper);
|
||||
processLogs(runDirectory, "*.log", "runDirectory", zipper);
|
||||
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper);
|
||||
try (Zipper zipper = new Zipper(zipFile, true)) {
|
||||
processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher);
|
||||
processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher);
|
||||
processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher);
|
||||
processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher);
|
||||
|
||||
zipper.putTextFile(LOG.getLogs(), "hmcl.log");
|
||||
zipper.putTextFile(logs, "minecraft.log");
|
||||
@@ -86,14 +86,11 @@ public final class LogExporter {
|
||||
});
|
||||
}
|
||||
|
||||
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) {
|
||||
private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper, PathMatcher logMatcher) {
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, fileExtension)) {
|
||||
long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime();
|
||||
|
||||
for (Path file : stream) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
FileTime time = Files.readAttributes(file, BasicFileAttributes.class).lastModifiedTime();
|
||||
if (time.toMillis() >= processStartTime) {
|
||||
if (logMatcher == null || logMatcher.matches(file)) {
|
||||
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
|
||||
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
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;
|
||||
@@ -27,18 +26,20 @@ import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Profile profile;
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
|
||||
this.profile = profile;
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -20,28 +20,33 @@ 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.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameWindowType;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.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.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
@@ -51,13 +56,18 @@ import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
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() {
|
||||
}
|
||||
@@ -146,16 +156,16 @@ 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, String name, Modpack modpack) {
|
||||
repository.markVersionAsModpack(name);
|
||||
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
if (setting != null) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -165,39 +175,41 @@ public final class ModpackHelper {
|
||||
}
|
||||
};
|
||||
|
||||
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
|
||||
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download"));
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static boolean isExternalGameNameConflicts(String name) {
|
||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||
}
|
||||
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
|
||||
if (isExternalGameNameConflicts(name)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
Profile newProfile = new Profile(name, location);
|
||||
newProfile.setUseRelativePath(true);
|
||||
Profiles.getProfiles().add(newProfile);
|
||||
Profiles.setSelectedProfile(newProfile);
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
LocalizedText.plain(name),
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, String name, Modpack modpack, String iconUrl) {
|
||||
repository.markVersionAsModpack(name);
|
||||
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
if (setting != null) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -208,111 +220,149 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.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)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.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)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
.withStagesHint(List.of("hmcl.modpack", "hmcl.modpack.download"));
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
||||
.withStagesHint(Arrays.asList("hmcl.modpack", "hmcl.modpack.download"));
|
||||
return new ModpackUpdateTask(repository, name, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, name))
|
||||
.thenComposeAsync(repository.refreshVersionsAsync())
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
|
||||
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(repository.refreshVersionsAsync());
|
||||
else
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
|
||||
return provider.createUpdateTask(repository.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(repository.refreshVersionsAsync());
|
||||
}
|
||||
|
||||
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, String version) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, String version) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.toVersionSetting(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
|
||||
ModpackHelper.toGameSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
|
||||
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, String version) {
|
||||
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(version);
|
||||
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(version));
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,15 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.OAuth;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -37,12 +39,14 @@ import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.thread;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
private final int port;
|
||||
private final CompletableFuture<String> future = new CompletableFuture<>();
|
||||
private final String codeVerifier;
|
||||
private final String state;
|
||||
|
||||
public static String lastlyOpenedURL;
|
||||
|
||||
@@ -52,6 +56,34 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
super(port);
|
||||
|
||||
this.port = port;
|
||||
|
||||
var encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
var random = new SecureRandom();
|
||||
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
|
||||
byte[] bytes = new byte[32];
|
||||
random.nextBytes(bytes);
|
||||
this.state = encoder.encodeToString(bytes);
|
||||
}
|
||||
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||
byte[] bytes = new byte[64];
|
||||
random.nextBytes(bytes);
|
||||
this.codeVerifier = encoder.encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCodeVerifier() {
|
||||
return codeVerifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,19 +125,32 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
String parameters = session.getQueryParameterString();
|
||||
|
||||
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
|
||||
if (query.containsKey("code")) {
|
||||
idToken = query.get("id_token");
|
||||
future.complete(query.get("code"));
|
||||
|
||||
String code = query.get("code");
|
||||
if (code != null) {
|
||||
if (this.state.equals(query.get("state"))) {
|
||||
idToken = query.get("id_token");
|
||||
future.complete(code);
|
||||
} else if (query.containsKey("state")) {
|
||||
LOG.warning("Failed to authenticate: invalid state in parameters");
|
||||
future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state"));
|
||||
} else {
|
||||
LOG.warning("Failed to authenticate: missing state in parameters");
|
||||
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state"));
|
||||
}
|
||||
} else {
|
||||
LOG.warning("Error: " + parameters);
|
||||
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
|
||||
LOG.warning("Failed to authenticate: missing authorization code in parameters");
|
||||
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
|
||||
}
|
||||
|
||||
String html;
|
||||
try {
|
||||
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
|
||||
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
|
||||
.replace("%lang%", Locale.getDefault().toLanguageTag())
|
||||
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
|
||||
.replace("%success%", i18n("message.success"))
|
||||
.replace("%ok%", i18n("button.ok"))
|
||||
.replace("%close_page%", i18n("account.methods.microsoft.close_page"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load html", e);
|
||||
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "");
|
||||
@@ -121,9 +166,18 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!future.isDone())
|
||||
future.completeExceptionally(new AuthenticationException("OAuth server is closing"));
|
||||
stop();
|
||||
}
|
||||
|
||||
public static class Factory implements OAuth.Callback {
|
||||
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
|
||||
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
|
||||
|
||||
@Override
|
||||
public OAuth.Session startServer() throws IOException, AuthenticationException {
|
||||
@@ -150,11 +204,18 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openBrowser(String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
FXUtils.openLink(url);
|
||||
public void loginCompletedDeviceCode() {
|
||||
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
|
||||
}
|
||||
|
||||
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
|
||||
@Override
|
||||
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
|
||||
switch (grantFlow) {
|
||||
case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url));
|
||||
case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -162,17 +223,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
return System.getProperty("hmcl.microsoft.auth.id",
|
||||
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getClientSecret() {
|
||||
return System.getProperty("hmcl.microsoft.auth.secret",
|
||||
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPublicClient() {
|
||||
return true; // We have turned on the device auth flow.
|
||||
}
|
||||
}
|
||||
|
||||
public static class GrantDeviceCodeEvent extends Event {
|
||||
@@ -194,6 +244,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoginCompletedDeviceCodeEvent extends Event {
|
||||
public LoginCompletedDeviceCodeEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
|
||||
public static class OpenBrowserEvent extends Event {
|
||||
private final String url;
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ import java.io.InputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -65,29 +68,18 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
// ==== Texture Loading ====
|
||||
public static class LoadedTexture {
|
||||
private final Image image;
|
||||
private final Map<String, String> metadata;
|
||||
|
||||
public LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
this.image = requireNonNull(image);
|
||||
this.metadata = requireNonNull(metadata);
|
||||
}
|
||||
|
||||
public Image getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata;
|
||||
public record LoadedTexture(Image image, Map<String, String> metadata) {
|
||||
public LoadedTexture {
|
||||
requireNonNull(image);
|
||||
requireNonNull(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins");
|
||||
private static final Path TEXTURES_DIR = Metadata.HMCL_USER_HOME.resolve("skins");
|
||||
|
||||
private static Path getTexturePath(Texture texture) {
|
||||
String url = texture.getUrl();
|
||||
String url = texture.url();
|
||||
int slash = url.lastIndexOf('/');
|
||||
int dot = url.lastIndexOf('.');
|
||||
if (dot < slash) {
|
||||
@@ -99,7 +91,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static LoadedTexture loadTexture(Texture texture) throws Throwable {
|
||||
if (StringUtils.isBlank(texture.getUrl())) {
|
||||
if (StringUtils.isBlank(texture.url())) {
|
||||
throw new IOException("Texture url is empty");
|
||||
}
|
||||
|
||||
@@ -107,14 +99,14 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(texture.getUrl(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.getUrl());
|
||||
new FileDownloadTask(texture.url(), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.url());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
// concurrency conflict?
|
||||
LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e);
|
||||
LOG.warning("Failed to download texture " + texture.url() + ", but the file is available", e);
|
||||
} else {
|
||||
throw new IOException("Failed to download texture " + texture.getUrl());
|
||||
throw new IOException("Failed to download texture " + texture.url());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +119,7 @@ public final class TexturesLoader {
|
||||
if (img.isError())
|
||||
throw img.getException();
|
||||
|
||||
Map<String, String> metadata = texture.getMetadata();
|
||||
Map<String, String> metadata = texture.metadata();
|
||||
if (metadata == null) {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
@@ -158,7 +150,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static TextureModel getDefaultModel(UUID uuid) {
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model"))
|
||||
return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).metadata().get("model"))
|
||||
? TextureModel.WIDE
|
||||
: TextureModel.SLIM;
|
||||
}
|
||||
@@ -176,7 +168,7 @@ public final class TexturesLoader {
|
||||
}
|
||||
})
|
||||
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN)))
|
||||
.filter(it -> StringUtils.isNotBlank(it.getUrl())))
|
||||
.filter(it -> StringUtils.isNotBlank(it.url())))
|
||||
.asyncMap(it -> {
|
||||
if (it.isPresent()) {
|
||||
Texture texture = it.get();
|
||||
@@ -184,7 +176,7 @@ public final class TexturesLoader {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -195,29 +187,29 @@ public final class TexturesLoader {
|
||||
}
|
||||
|
||||
public static ObservableValue<LoadedTexture> skinBinding(Account account) {
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getUUID());
|
||||
LoadedTexture uuidFallback = getDefaultSkin(account.getProfileID());
|
||||
if (account instanceof OfflineAccount) {
|
||||
OfflineAccount offlineAccount = (OfflineAccount) account;
|
||||
|
||||
SimpleObjectProperty<LoadedTexture> binding = new SimpleObjectProperty<>();
|
||||
InvalidationListener listener = o -> {
|
||||
Skin skin = offlineAccount.getSkin();
|
||||
String username = offlineAccount.getUsername();
|
||||
String profileName = offlineAccount.getProfileName();
|
||||
|
||||
binding.set(uuidFallback);
|
||||
if (skin != null) {
|
||||
skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
skin.load(profileName).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to load texture", exception);
|
||||
} else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) {
|
||||
} else if (result != null && result.skin() != null && result.skin().image() != null) {
|
||||
Map<String, String> metadata;
|
||||
if (result.getModel() != null) {
|
||||
metadata = singletonMap("model", result.getModel().modelName);
|
||||
if (result.model() != null) {
|
||||
metadata = singletonMap("model", result.model().modelName);
|
||||
} else {
|
||||
metadata = emptyMap();
|
||||
}
|
||||
|
||||
binding.set(new LoadedTexture(result.getSkin().getImage(), metadata));
|
||||
binding.set(new LoadedTexture(result.skin().image(), metadata));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -234,12 +226,12 @@ public final class TexturesLoader {
|
||||
.asyncMap(textures -> {
|
||||
if (textures.isPresent()) {
|
||||
Texture texture = textures.get().get(TextureType.SKIN);
|
||||
if (texture != null && StringUtils.isNotBlank(texture.getUrl())) {
|
||||
if (texture != null && StringUtils.isNotBlank(texture.url())) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return loadTexture(texture);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
|
||||
LOG.warning("Failed to load texture " + texture.url() + ", using fallback texture", e);
|
||||
return uuidFallback;
|
||||
}
|
||||
}, POOL);
|
||||
@@ -318,7 +310,7 @@ public final class TexturesLoader {
|
||||
fxAvatarBinding(canvas, skinBinding(account));
|
||||
else {
|
||||
unbindAvatar(canvas);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getUUID()).image);
|
||||
drawAvatar(canvas, getDefaultSkin(account.getProfileID()).image);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -57,7 +59,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
public Path getJavaDir(Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
|
||||
return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,7 +68,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
public Path getManifestFile(Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
|
||||
return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
|
||||
}
|
||||
|
||||
public boolean isInstalled(Platform platform, String name) {
|
||||
@@ -74,7 +76,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
|
||||
return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
|
||||
}
|
||||
|
||||
public @Nullable Path getJavaExecutable(Platform platform, String name) {
|
||||
@@ -94,10 +96,10 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
public @Nullable Path getJavaExecutable(Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.getComponent());
|
||||
return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component());
|
||||
}
|
||||
|
||||
private static void getAllJava(List<JavaRuntime> list, Platform platform, Path platformRoot, boolean isManaged) {
|
||||
private static void getAllJava(List<Path> list, Platform platform, Path platformRoot) {
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(platformRoot)) {
|
||||
for (Path file : stream) {
|
||||
try {
|
||||
@@ -115,8 +117,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
if (Files.isDirectory(javaDir)) {
|
||||
JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class);
|
||||
list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged));
|
||||
list.add(executable);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
@@ -129,28 +130,23 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<JavaRuntime> getAllJava(Platform platform) {
|
||||
public Collection<Path> getAllJava(Platform platform) {
|
||||
Path platformRoot = getPlatformRoot(platform);
|
||||
if (!Files.isDirectory(platformRoot))
|
||||
return Collections.emptyList();
|
||||
|
||||
ArrayList<JavaRuntime> list = new ArrayList<>();
|
||||
|
||||
getAllJava(list, platform, platformRoot, true);
|
||||
if (platform.getOperatingSystem() == OperatingSystem.MACOS) {
|
||||
platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName());
|
||||
if (Files.isDirectory(platformRoot))
|
||||
getAllJava(list, platform, platformRoot, false);
|
||||
}
|
||||
ArrayList<Path> list = new ArrayList<>();
|
||||
|
||||
getAllJava(list, platform, platformRoot);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<JavaRuntime> getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) {
|
||||
Path javaDir = getJavaDir(platform, gameJavaVersion);
|
||||
Path tempDir = getPlatformRoot(platform).resolve(".tmp").resolve(javaDir.getFileName());
|
||||
|
||||
return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
|
||||
return new MojangJavaDownloadTask(downloadProvider, javaDir, tempDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> {
|
||||
Path executable;
|
||||
try {
|
||||
executable = JavaManager.getExecutable(javaDir).toRealPath();
|
||||
@@ -163,16 +159,16 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
|
||||
JavaInfo info;
|
||||
if (JavaManager.isCompatible(platform))
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
info = JavaInfoUtils.fromExecutable(executable);
|
||||
else
|
||||
info = new JavaInfo(platform, result.download.getVersion().getName(), null);
|
||||
info = new JavaInfo(platform, result.download().version().name(), null);
|
||||
|
||||
Map<String, Object> update = new LinkedHashMap<>();
|
||||
update.put("provider", "mojang");
|
||||
update.put("component", gameJavaVersion.getComponent());
|
||||
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) {
|
||||
@@ -194,12 +190,12 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
public Task<JavaRuntime> getInstallJavaTask(Platform platform, String name, Map<String, Object> update, Path archiveFile) {
|
||||
Path javaDir = getJavaDir(platform, name);
|
||||
return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> {
|
||||
if (!result.getInfo().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform());
|
||||
if (!result.info().getPlatform().equals(platform))
|
||||
throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.info().getPlatform());
|
||||
|
||||
Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath();
|
||||
JsonUtils.writeToJsonFile(getManifestFile(platform, name), result);
|
||||
return JavaRuntime.of(executable, result.getInfo(), true);
|
||||
return JavaRuntime.of(executable, result.info(), true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
@@ -27,7 +28,6 @@ import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
@@ -39,35 +39,10 @@ public final class JavaInfoUtils {
|
||||
private JavaInfoUtils() {
|
||||
}
|
||||
|
||||
private static Path tryFindReleaseFile(Path executable) {
|
||||
Path parent = executable.getParent();
|
||||
if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) {
|
||||
Path javaHome = parent.getParent();
|
||||
if (javaHome != null && javaHome.getFileName() != null) {
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
String javaHomeName = javaHome.getFileName().toString();
|
||||
if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9"))
|
||||
&& Files.isRegularFile(releaseFile)) {
|
||||
return releaseFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException {
|
||||
public static @NotNull JavaInfo fromExecutable(Path executable) throws IOException {
|
||||
assert executable.isAbsolute();
|
||||
|
||||
Path releaseFile;
|
||||
if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) {
|
||||
try {
|
||||
return JavaInfo.fromReleaseFile(releaseFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
Path thisPath = JarUtils.thisJarPath();
|
||||
|
||||
if (thisPath == null) {
|
||||
throw new IOException("Failed to find current HMCL location");
|
||||
}
|
||||
@@ -94,7 +69,6 @@ public final class JavaInfoUtils {
|
||||
? architecture
|
||||
: Architecture.SYSTEM_ARCH);
|
||||
|
||||
|
||||
return new JavaInfo(platform, result.javaVersion, result.javaVendor);
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
@@ -103,14 +77,9 @@ public final class JavaInfoUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Result {
|
||||
@SerializedName("os.name")
|
||||
public String osName;
|
||||
@SerializedName("os.arch")
|
||||
public String osArch;
|
||||
@SerializedName("java.version")
|
||||
public String javaVersion;
|
||||
@SerializedName("java.vendor")
|
||||
public String javaVendor;
|
||||
@JsonSerializable
|
||||
private record Result(@SerializedName("os.name") String osName, @SerializedName("os.arch") String osArch,
|
||||
@SerializedName("java.version") String javaVersion,
|
||||
@SerializedName("java.vendor") String javaVendor) {
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -25,14 +30,20 @@ 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.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -40,9 +51,14 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -55,8 +71,18 @@ public final class JavaManager {
|
||||
private JavaManager() {
|
||||
}
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
|
||||
private static final String[] KNOWN_VENDOR_DIRECTORIES = {
|
||||
"Java",
|
||||
"BellSoft",
|
||||
"AdoptOpenJDK",
|
||||
"Zulu",
|
||||
"Microsoft",
|
||||
"Eclipse Foundation",
|
||||
"Semeru"
|
||||
};
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java"));
|
||||
|
||||
public static String getMojangJavaPlatform(Platform platform) {
|
||||
if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) {
|
||||
@@ -103,16 +129,27 @@ public final class JavaManager {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
// Windows x86-64 platform is compatible with x86 programs
|
||||
return architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
|
||||
// Since Windows 10 Build 21277, Windows Arm64 has been compatible with x86-64 programs via translation
|
||||
if (architecture == Architecture.X86_64 && Platform.isSupportedTranslationX86_64())
|
||||
return true;
|
||||
|
||||
// Windows Arm64 is compatible with x86 programs via translation
|
||||
if (architecture == Architecture.X86)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
return architecture == Architecture.X86;
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
return architecture == Architecture.X86_64;
|
||||
break;
|
||||
}
|
||||
@@ -160,12 +197,12 @@ public final class JavaManager {
|
||||
return javaRuntime;
|
||||
}
|
||||
|
||||
JavaInfo info = JavaInfoUtils.fromExecutable(executable, true);
|
||||
JavaInfo info = JavaInfoUtils.fromExecutable(executable);
|
||||
return JavaRuntime.of(executable, info, false);
|
||||
}
|
||||
|
||||
public static void refresh() {
|
||||
Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
Task.supplyAsync(() -> searchPotentialJavaExecutables(false)).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (result != null) {
|
||||
LATCH.await();
|
||||
allJava = result;
|
||||
@@ -183,9 +220,11 @@ public final class JavaManager {
|
||||
|
||||
String pathString = javaRuntime.getBinary().toString();
|
||||
|
||||
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
|
||||
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
if (!SettingsManager.isUserSettingsReadOnly()) {
|
||||
SettingsManager.userSettings().getDisabledJava().remove(pathString);
|
||||
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
}
|
||||
}
|
||||
return javaRuntime;
|
||||
});
|
||||
@@ -237,7 +276,7 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void addJava(JavaRuntime java) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (!oldMap.containsKey(java.getBinary())) {
|
||||
@@ -248,12 +287,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)) {
|
||||
@@ -304,7 +343,7 @@ public final class JavaManager {
|
||||
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violationMandatory = true;
|
||||
} else {
|
||||
@@ -326,7 +365,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables();
|
||||
Map<Path, JavaRuntime> allJava = searchPotentialJavaExecutables(true);
|
||||
JavaManager.allJava = allJava;
|
||||
LATCH.countDown();
|
||||
FXUtils.runInFX(() -> updateAllJavaProperty(allJava));
|
||||
@@ -334,56 +373,56 @@ public final class JavaManager {
|
||||
|
||||
// search java
|
||||
|
||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables() {
|
||||
Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM);
|
||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
|
||||
Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json"));
|
||||
if (useCache)
|
||||
searcher.loadCache();
|
||||
|
||||
searcher.searchAllJavaInRepository(Platform.SYSTEM_PLATFORM);
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64);
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86);
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
|
||||
queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE");
|
||||
searcher.queryJavaInRegistryKey(WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK");
|
||||
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files");
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)");
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)");
|
||||
}
|
||||
searcher.searchJavaInProgramFiles("ProgramFiles", "C:\\Program Files");
|
||||
searcher.searchJavaInProgramFiles("ProgramFiles(x86)", "C:\\Program Files (x86)");
|
||||
break;
|
||||
case LINUX:
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/java")); // Oracle RPMs
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib32/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of("/usr/lib64/jvm")); // General locations
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN!
|
||||
break;
|
||||
case MACOS:
|
||||
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines"));
|
||||
searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
|
||||
searcher.searchJavaInMacJavaVirtualMachines(Path.of("/Library/Java/JavaVirtualMachines"));
|
||||
searcher.searchJavaInMacJavaVirtualMachines(Path.of(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines"));
|
||||
searcher.tryAddJavaExecutable(Path.of("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java"));
|
||||
searcher.tryAddJavaExecutable(Path.of("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java"));
|
||||
// Homebrew
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java"));
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk"));
|
||||
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) {
|
||||
searcher.tryAddJavaExecutable(Path.of("/opt/homebrew/opt/java/bin/java"));
|
||||
searcher.searchAllJavaInDirectory(Path.of("/opt/homebrew/Cellar/openjdk"));
|
||||
try (DirectoryStream<Path> dirs = Files.newDirectoryStream(Path.of("/opt/homebrew/Cellar"), "openjdk@*")) {
|
||||
for (Path dir : dirs) {
|
||||
searchAllJavaInDirectory(javaRuntimes, dir);
|
||||
searcher.searchAllJavaInDirectory(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar");
|
||||
@@ -397,16 +436,16 @@ public final class JavaManager {
|
||||
// Search Minecraft bundled runtimes
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false));
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
}
|
||||
searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
|
||||
// Search in PATH.
|
||||
if (System.getenv("PATH") != null) {
|
||||
@@ -420,7 +459,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
try {
|
||||
tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
searcher.tryAddJavaExecutable(Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable()));
|
||||
} catch (InvalidPathException ignored) {
|
||||
}
|
||||
}
|
||||
@@ -430,16 +469,17 @@ public final class JavaManager {
|
||||
String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator);
|
||||
for (String path : paths) {
|
||||
try {
|
||||
tryAddJavaHome(javaRuntimes, Paths.get(path));
|
||||
searcher.tryAddJavaHome(Path.of(path));
|
||||
} catch (InvalidPathException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
|
||||
try {
|
||||
tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath));
|
||||
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path: " + javaPath);
|
||||
}
|
||||
@@ -447,233 +487,406 @@ public final class JavaManager {
|
||||
|
||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||
if (currentJava != null
|
||||
&& !javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
javaRuntimes.put(currentJava.getBinary(), currentJava);
|
||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||
}
|
||||
|
||||
LOG.trace(javaRuntimes.values().stream().sorted()
|
||||
searcher.saveCache();
|
||||
|
||||
LOG.trace(searcher.javaRuntimes.values().stream().sorted()
|
||||
.map(it -> String.format(" - %s %s (%s, %s): %s",
|
||||
it.isJDK() ? "JDK" : "JRE",
|
||||
it.getVersion(),
|
||||
it.getPlatform().getArchitecture().getDisplayName(),
|
||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
it.getBinary()))
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", "")));
|
||||
|
||||
return javaRuntimes;
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||
return searcher.javaRuntimes;
|
||||
}
|
||||
|
||||
private static void tryAddJavaHome(Map<Path, JavaRuntime> javaRuntimes, Path javaHome) {
|
||||
Path executable = getExecutable(javaHome);
|
||||
if (!Files.isRegularFile(executable)) {
|
||||
return;
|
||||
private static final class Searcher {
|
||||
private final Path cacheFile;
|
||||
final Map<Path, JavaRuntime> javaRuntimes = new HashMap<>();
|
||||
private final LinkedHashMap<Path, JavaInfoCache> caches = new LinkedHashMap<>();
|
||||
private final Set<Path> failed = new HashSet<>();
|
||||
private boolean needRefreshCache = false;
|
||||
|
||||
Searcher(Path cacheFile) {
|
||||
this.cacheFile = cacheFile;
|
||||
}
|
||||
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to resolve path " + executable, e);
|
||||
return;
|
||||
private static final Pattern CACHE_VERSION_PATTERN = Pattern.compile("(?<major>\\d+)(?:\\.(?<minor>\\d+))?");
|
||||
private static final int CACHE_MAJOR_VERSION = 0;
|
||||
private static final int CACHE_MINOR_VERSION = 0;
|
||||
|
||||
private record JavaInfoCache(String key, JavaInfo info) {
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
void loadCache() {
|
||||
if (Files.notExists(cacheFile))
|
||||
return;
|
||||
|
||||
JavaInfo info = null;
|
||||
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
if (Files.exists(releaseFile)) {
|
||||
try {
|
||||
info = JavaInfo.fromReleaseFile(releaseFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read release file " + releaseFile, e);
|
||||
JsonObject jsonFile = JsonUtils.fromJsonFile(cacheFile, JsonObject.class);
|
||||
JsonElement fileVersion = jsonFile.get("version");
|
||||
|
||||
Matcher matcher;
|
||||
if (fileVersion instanceof JsonPrimitive version
|
||||
&& (matcher = CACHE_VERSION_PATTERN.matcher(version.getAsString())).matches()) {
|
||||
int major = Integer.parseInt(matcher.group("major"));
|
||||
|
||||
String minorString = matcher.group("minor");
|
||||
int minor = minorString != null ? Integer.parseInt(minorString) : 0;
|
||||
|
||||
if (major != CACHE_MAJOR_VERSION || minor < CACHE_MINOR_VERSION)
|
||||
throw new IOException("Unsupported cache file, version: %s".formatted(version.getAsString()));
|
||||
} else
|
||||
throw new IOException("Invalid version JSON: " + fileVersion);
|
||||
|
||||
JsonArray cachesArray = jsonFile.getAsJsonArray("caches");
|
||||
|
||||
for (JsonElement element : cachesArray) {
|
||||
try {
|
||||
var obj = (JsonObject) element;
|
||||
|
||||
Path realPath = Path.of(obj.getAsJsonPrimitive("path").getAsString()).toRealPath();
|
||||
String key = obj.getAsJsonPrimitive("key").getAsString();
|
||||
|
||||
OperatingSystem osName = OperatingSystem.parseOSName(obj.getAsJsonPrimitive("os.name").getAsString());
|
||||
Architecture osArch = Architecture.parseArchName(obj.getAsJsonPrimitive("os.arch").getAsString());
|
||||
String javaVersion = obj.getAsJsonPrimitive("java.version").getAsString();
|
||||
|
||||
JavaInfo.Builder infoBuilder = JavaInfo.newBuilder(Platform.getPlatform(osName, osArch), javaVersion);
|
||||
|
||||
if (obj.get("java.vendor") instanceof JsonPrimitive vendor)
|
||||
infoBuilder.setVendor(vendor.getAsString());
|
||||
|
||||
caches.put(realPath, new JavaInfoCache(key, infoBuilder.build()));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Invalid cache: " + element);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load cache file: " + cacheFile);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (info == null) {
|
||||
void saveCache() {
|
||||
if (!needRefreshCache)
|
||||
return;
|
||||
|
||||
needRefreshCache = false;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, false);
|
||||
FileUtils.saveSafely(cacheFile, output -> {
|
||||
try (var writer = new JsonWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) {
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("version").value("%d.%d".formatted(CACHE_MAJOR_VERSION, CACHE_MINOR_VERSION));
|
||||
|
||||
writer.name("caches");
|
||||
writer.beginArray();
|
||||
for (Map.Entry<Path, JavaInfoCache> entry : caches.entrySet()) {
|
||||
Path path = entry.getKey();
|
||||
JavaInfoCache cache = entry.getValue();
|
||||
JavaInfo info = cache.info();
|
||||
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("path").value(path.toString());
|
||||
writer.name("key").value(cache.key());
|
||||
|
||||
writer.name("os.name").value(info.getPlatform().os().getCheckedName());
|
||||
writer.name("os.arch").value(info.getPlatform().arch().getCheckedName());
|
||||
writer.name("java.version").value(info.getVersion());
|
||||
if (info.getVendor() != null)
|
||||
writer.name("java.vendor").value(info.getVendor());
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
writer.endArray();
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to save cache file: " + cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String createCacheKey(Path realPath) {
|
||||
Path binDir = realPath.getParent();
|
||||
if (binDir == null || !FileUtils.getName(binDir).equals("bin"))
|
||||
return null;
|
||||
|
||||
if (Files.isRegularFile(realPath.resolveSibling("ikvm.properties")))
|
||||
return null;
|
||||
|
||||
Path javaHome = binDir.getParent();
|
||||
if (javaHome == null)
|
||||
return null;
|
||||
|
||||
Path libDir = javaHome.resolve("lib");
|
||||
if (!Files.isDirectory(libDir))
|
||||
return null;
|
||||
|
||||
BasicFileAttributes launcherAttributes;
|
||||
String releaseHash = null;
|
||||
BasicFileAttributes coreLibsAttributes = null;
|
||||
|
||||
try {
|
||||
launcherAttributes = Files.readAttributes(realPath, BasicFileAttributes.class);
|
||||
|
||||
Path releaseFile = javaHome.resolve("release");
|
||||
if (Files.exists(releaseFile)) {
|
||||
releaseHash = DigestUtils.digestToString("SHA-1", releaseFile);
|
||||
} else {
|
||||
Path coreLibsFile = libDir.resolve("rt.jar");
|
||||
if (!Files.isRegularFile(coreLibsFile)) {
|
||||
coreLibsFile = javaHome.resolve("jre/lib/rt.jar");
|
||||
if (!Files.isRegularFile(coreLibsFile))
|
||||
return null;
|
||||
|
||||
coreLibsAttributes = Files.readAttributes(coreLibsFile, BasicFileAttributes.class);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to create cache key for " + realPath, e);
|
||||
return null;
|
||||
}
|
||||
|
||||
StringJoiner joiner = new StringJoiner(",");
|
||||
|
||||
joiner.add("sz:" + launcherAttributes.size());
|
||||
joiner.add("lm:" + launcherAttributes.lastModifiedTime().toMillis());
|
||||
|
||||
if (releaseHash != null)
|
||||
joiner.add(releaseHash);
|
||||
|
||||
if (coreLibsAttributes != null) {
|
||||
joiner.add("rsz:" + coreLibsAttributes.size());
|
||||
joiner.add("rlm:" + coreLibsAttributes.lastModifiedTime().toMillis());
|
||||
}
|
||||
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
void addResult(Path realPath, JavaRuntime javaRuntime) {
|
||||
javaRuntimes.put(realPath, javaRuntime);
|
||||
}
|
||||
|
||||
void tryAddJavaHome(Path javaHome) {
|
||||
tryAddJavaExecutable(getExecutable(javaHome));
|
||||
}
|
||||
|
||||
void tryAddJavaExecutable(Path executable) {
|
||||
tryAddJavaExecutable(executable, false);
|
||||
}
|
||||
|
||||
void tryAddJavaExecutable(Path executable, boolean isManaged) {
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable)
|
||||
|| failed.contains(executable)
|
||||
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = createCacheKey(executable);
|
||||
if (cacheKey != null) {
|
||||
JavaInfoCache cache = caches.get(executable);
|
||||
if (cache != null) {
|
||||
if (isCompatible(cache.info().getPlatform()) && cacheKey.equals(cache.key())) {
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, cache.info(), isManaged));
|
||||
return;
|
||||
} else {
|
||||
caches.remove(executable);
|
||||
needRefreshCache = true;
|
||||
}
|
||||
}
|
||||
} else if (caches.remove(executable) != null) {
|
||||
needRefreshCache = true;
|
||||
}
|
||||
|
||||
JavaInfo info;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e);
|
||||
failed.add(executable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cacheKey != null) {
|
||||
caches.put(executable, new JavaInfoCache(cacheKey, info));
|
||||
needRefreshCache = true;
|
||||
}
|
||||
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, isManaged));
|
||||
}
|
||||
|
||||
void tryAddJavaInComponentDir(String platform, Path component, boolean verify) {
|
||||
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
|
||||
if (!Files.isRegularFile(sha1File))
|
||||
return;
|
||||
|
||||
Path dir = component.resolve(platform).resolve(component.getFileName());
|
||||
|
||||
if (verify) {
|
||||
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
int idx = line.indexOf(" /#//");
|
||||
if (idx <= 0)
|
||||
throw new IOException("Illegal line: " + line);
|
||||
|
||||
Path file = dir.resolve(line.substring(0, idx));
|
||||
|
||||
// Should we check the sha1 of files? This will take a lot of time.
|
||||
if (Files.notExists(file))
|
||||
throw new NoSuchFileException(file.toAbsolutePath().toString());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to verify Java in " + component, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Path macPath = dir.resolve("jre.bundle/Contents/Home");
|
||||
if (Files.exists(macPath)) {
|
||||
tryAddJavaHome(macPath);
|
||||
return;
|
||||
} else
|
||||
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
|
||||
}
|
||||
|
||||
tryAddJavaHome(dir);
|
||||
}
|
||||
|
||||
void searchAllJavaInRepository(Platform platform) {
|
||||
for (Path java : REPOSITORY.getAllJava(platform)) {
|
||||
tryAddJavaExecutable(java, true);
|
||||
}
|
||||
|
||||
for (Path java : LOCAL_REPOSITORY.getAllJava(platform)) {
|
||||
tryAddJavaExecutable(java, true);
|
||||
}
|
||||
|
||||
if (platform.os() == OperatingSystem.MACOS) {
|
||||
// In the past, we used 'osx' as the checked name for macOS
|
||||
Path platformRoot = REPOSITORY.getPlatformRoot(platform).resolveSibling("osx-" + platform.getArchitecture().getCheckedName());
|
||||
searchAllJavaInDirectory(platformRoot);
|
||||
}
|
||||
}
|
||||
|
||||
if (info != null && isCompatible(info.getPlatform()))
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
|
||||
}
|
||||
void searchAllOfficialJava(Path directory, boolean verify) {
|
||||
if (!Files.isDirectory(directory))
|
||||
return;
|
||||
// Examples:
|
||||
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
|
||||
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
|
||||
|
||||
private static void tryAddJavaExecutable(Map<Path, JavaRuntime> javaRuntimes, Path executable) {
|
||||
try {
|
||||
executable = executable.toRealPath();
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
|
||||
if (javaPlatform != null) {
|
||||
searchAllOfficialJava(directory, javaPlatform, verify);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
}
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
JavaInfo info = null;
|
||||
try {
|
||||
info = JavaInfoUtils.fromExecutable(executable, true);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to lookup Java executable at " + executable, e);
|
||||
}
|
||||
|
||||
if (info != null && isCompatible(info.getPlatform())) {
|
||||
javaRuntimes.put(executable, JavaRuntime.of(executable, info, false));
|
||||
}
|
||||
}
|
||||
|
||||
private static void tryAddJavaInComponentDir(Map<Path, JavaRuntime> javaRuntimes, String platform, Path component, boolean verify) {
|
||||
Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1");
|
||||
if (!Files.isRegularFile(sha1File))
|
||||
return;
|
||||
|
||||
Path dir = component.resolve(platform).resolve(component.getFileName());
|
||||
|
||||
if (verify) {
|
||||
try (BufferedReader reader = Files.newBufferedReader(sha1File)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
int idx = line.indexOf(" /#//");
|
||||
if (idx <= 0)
|
||||
throw new IOException("Illegal line: " + line);
|
||||
|
||||
Path file = dir.resolve(line.substring(0, idx));
|
||||
|
||||
// Should we check the sha1 of files? This will take a lot of time.
|
||||
if (Files.notExists(file))
|
||||
throw new NoSuchFileException(file.toAbsolutePath().toString());
|
||||
void searchAllOfficialJava(Path directory, String platform, boolean verify) {
|
||||
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
|
||||
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
|
||||
for (Path component : dir) {
|
||||
tryAddJavaInComponentDir(platform, component, verify);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to verify Java in " + component, e);
|
||||
return;
|
||||
LOG.warning("Failed to list java-runtime directory " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Path macPath = dir.resolve("jre.bundle/Contents/Home");
|
||||
if (Files.exists(macPath)) {
|
||||
tryAddJavaHome(javaRuntimes, macPath);
|
||||
void searchAllJavaInDirectory(Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
} else
|
||||
LOG.warning("The Java is not in 'jre.bundle/Contents/Home'");
|
||||
}
|
||||
}
|
||||
|
||||
tryAddJavaHome(javaRuntimes, dir);
|
||||
}
|
||||
|
||||
private static void searchAllJavaInRepository(Map<Path, JavaRuntime> javaRuntimes, Platform platform) {
|
||||
for (JavaRuntime java : REPOSITORY.getAllJava(platform)) {
|
||||
javaRuntimes.put(java.getBinary(), java);
|
||||
}
|
||||
|
||||
for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) {
|
||||
javaRuntimes.put(java.getBinary(), java);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, boolean verify) {
|
||||
if (!Files.isDirectory(directory))
|
||||
return;
|
||||
// Examples:
|
||||
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
|
||||
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
|
||||
|
||||
String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM);
|
||||
if (javaPlatform != null) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(subDir);
|
||||
}
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllOfficialJava(Map<Path, JavaRuntime> javaRuntimes, Path directory, String platform, boolean verify) {
|
||||
try (DirectoryStream<Path> dir = Files.newDirectoryStream(directory)) {
|
||||
// component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future.
|
||||
for (Path component : dir) {
|
||||
tryAddJavaInComponentDir(javaRuntimes, platform, component, verify);
|
||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(programFiles);
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to list java-runtime directory " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchAllJavaInDirectory(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(javaRuntimes, subDir);
|
||||
for (String vendor : KNOWN_VENDOR_DIRECTORIES) {
|
||||
searchAllJavaInDirectory(path.resolve(vendor));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchJavaInProgramFiles(Map<Path, JavaRuntime> javaRuntimes, String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Paths.get(programFiles);
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) {
|
||||
searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor));
|
||||
}
|
||||
}
|
||||
|
||||
private static void searchJavaInMacJavaVirtualMachines(Map<Path, JavaRuntime> javaRuntimes, Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home"));
|
||||
void searchJavaInMacJavaVirtualMachines(Path directory) {
|
||||
if (!Files.isDirectory(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
|
||||
for (Path subDir : stream) {
|
||||
tryAddJavaHome(subDir.resolve("Contents/Home"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to find Java in " + directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Windows Registry Support ====
|
||||
private static void queryJavaInRegistryKey(Map<Path, JavaRuntime> javaRuntimes, WinReg.HKEY hkey, String location) {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg == null)
|
||||
return;
|
||||
// ==== Windows Registry Support ====
|
||||
void queryJavaInRegistryKey(WinReg.HKEY hkey, String location) {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg == null)
|
||||
return;
|
||||
|
||||
for (String java : reg.querySubKeys(hkey, location)) {
|
||||
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
|
||||
continue;
|
||||
Object home = reg.queryValue(hkey, java, "JavaHome");
|
||||
if (home instanceof String) {
|
||||
try {
|
||||
tryAddJavaHome(javaRuntimes, Paths.get((String) home));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path in system registry: " + home);
|
||||
for (String java : reg.querySubKeys(hkey, location)) {
|
||||
if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI"))
|
||||
continue;
|
||||
if (reg.queryValue(hkey, java, "JavaHome") instanceof String home) {
|
||||
try {
|
||||
tryAddJavaHome(Path.of(home));
|
||||
} catch (InvalidPathException e) {
|
||||
LOG.warning("Invalid Java path in system registry: " + home);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,33 +34,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
* @author Glavo
|
||||
*/
|
||||
@JsonAdapter(JavaManifest.Serializer.class)
|
||||
public final class JavaManifest {
|
||||
|
||||
private final JavaInfo info;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, Object> update;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, JavaLocalFiles.Local> files;
|
||||
|
||||
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
this.info = info;
|
||||
this.update = update;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public JavaInfo getInfo() {
|
||||
return info;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUpdate() {
|
||||
return update;
|
||||
}
|
||||
|
||||
public Map<String, JavaLocalFiles.Local> getFiles() {
|
||||
return files;
|
||||
}
|
||||
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
|
||||
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
||||
|
||||
@@ -69,16 +43,16 @@ public final class JavaManifest {
|
||||
@Override
|
||||
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.getInfo().getVersion());
|
||||
res.addProperty("java.vendor", src.getInfo().getVendor());
|
||||
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.info().getVersion());
|
||||
res.addProperty("java.vendor", src.info().getVendor());
|
||||
|
||||
if (src.getUpdate() != null)
|
||||
res.add("update", context.serialize(src.getUpdate()));
|
||||
if (src.update() != null)
|
||||
res.add("update", context.serialize(src.update()));
|
||||
|
||||
if (src.getFiles() != null)
|
||||
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
|
||||
if (src.files() != null)
|
||||
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores account metadata records in a detached JSON file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory and stores account metadata in the `accounts` list.
|
||||
/// Private account data is persisted separately in [AccountPrivateData].
|
||||
@JsonAdapter(AccountMetadataStore.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountMetadataStore extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account metadata store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("accounts", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty account metadata store.
|
||||
AccountMetadataStore() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(accounts);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates an account metadata store from already serialized account records.
|
||||
///
|
||||
/// @param records the serialized account records
|
||||
/// @return an account metadata store containing the given records
|
||||
static AccountMetadataStore fromRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.getAccounts().setAll(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Creates a copy of this account metadata store with replacement account records.
|
||||
///
|
||||
/// @param records the replacement account records
|
||||
/// @return a copy preserving this store's schema and unknown top-level members
|
||||
AccountMetadataStore copyWithRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.setSchema(getSchema());
|
||||
result.getAccounts().setAll(records);
|
||||
result.unknownFields.putAll(unknownFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The schema used by this account metadata store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account metadata store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account metadata store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this account metadata store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Serialized account records.
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<JsonObject> accounts = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns serialized account records.
|
||||
public ObservableList<JsonObject> getAccounts() {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountMetadataStore].
|
||||
static final class Adapter extends ObservableSetting.Adapter<AccountMetadataStore> {
|
||||
/// Creates an empty account metadata store for deserialization.
|
||||
@Override
|
||||
protected AccountMetadataStore createInstance() {
|
||||
return new AccountMetadataStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonSerializationContext;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores private account data in a protected payload.
|
||||
@JsonAdapter(AccountPrivateData.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountPrivateData extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account private data store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("account-private-data", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// JVM system property selecting the account private data protection mode.
|
||||
static final String PROTECTION_PROPERTY = "hmcl.account.privateData.protection";
|
||||
|
||||
/// Creates an empty account private data store.
|
||||
AccountPrivateData() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(privateData);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this account private data store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Private account data keyed by account ID.
|
||||
private final ObservableMap<AccountID, JsonObject> privateData =
|
||||
FXCollections.observableMap(new LinkedHashMap<>());
|
||||
|
||||
/// Whether this account private data store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account private data store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account private data store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Returns private account data keyed by account ID.
|
||||
ObservableMap<AccountID, JsonObject> getPrivateData() {
|
||||
return privateData;
|
||||
}
|
||||
|
||||
/// Returns the protection mode selected for writing account private data.
|
||||
private static ProtectedPayload.ProtectionMode protectionMode() {
|
||||
return ProtectedPayload.ProtectionMode.fromConfiguredId(System.getProperty(PROTECTION_PROPERTY));
|
||||
}
|
||||
|
||||
/// Returns whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Finds the first private data entry matching an account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param privateDataStores private data stores searched in order
|
||||
/// @return the first matching private data object, or `null` if no store contains one
|
||||
static @Nullable JsonObject findPrivateData(
|
||||
AccountID accountID,
|
||||
List<AccountPrivateData> privateDataStores) {
|
||||
for (AccountPrivateData privateDataStore : privateDataStores) {
|
||||
@Nullable JsonObject accountPrivateData = privateDataStore.privateData.get(accountID);
|
||||
if (accountPrivateData != null) {
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns whether this store contains private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether this store contains private data for the account ID
|
||||
boolean containsPrivateData(AccountID accountID) {
|
||||
return privateData.containsKey(accountID);
|
||||
}
|
||||
|
||||
/// Removes private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether private data was removed
|
||||
boolean removePrivateData(AccountID accountID) {
|
||||
return privateData.remove(accountID) != null;
|
||||
}
|
||||
|
||||
/// Stores private data for the account ID, or removes existing private data when the object is empty.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param accountPrivateData the private account data
|
||||
void putPrivateData(AccountID accountID, JsonObject accountPrivateData) {
|
||||
if (accountPrivateData.isEmpty()) {
|
||||
privateData.remove(accountID);
|
||||
} else {
|
||||
privateData.put(accountID, accountPrivateData);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces this private data store with another store.
|
||||
///
|
||||
/// @param other the private data store to copy from
|
||||
void replaceWith(AccountPrivateData other) {
|
||||
privateData.clear();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : other.privateData.entrySet()) {
|
||||
putPrivateData(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountPrivateData].
|
||||
static final class Adapter implements JsonSerializer<AccountPrivateData>,
|
||||
com.google.gson.JsonDeserializer<AccountPrivateData> {
|
||||
/// Creates the JSON payload protected inside an account private data file.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to serialize
|
||||
/// @param context the JSON serialization context
|
||||
/// @return the plain payload before protection
|
||||
private static JsonArray createPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonSerializationContext context) {
|
||||
JsonArray payload = new JsonArray();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : accountPrivateData.privateData.entrySet()) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.add("accountID", context.serialize(entry.getKey(), AccountID.class));
|
||||
item.add("privateData", entry.getValue());
|
||||
payload.add(item);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Reads an account ID from a private data entry.
|
||||
private static @Nullable AccountID readAccountID(
|
||||
@Nullable JsonElement accountIDElement,
|
||||
JsonDeserializationContext context) {
|
||||
if (accountIDElement == null || accountIDElement.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return context.deserialize(accountIDElement, AccountID.class);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the protected payload into the given private data store.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to update
|
||||
/// @param payload the revealed payload
|
||||
/// @param context the JSON deserialization context
|
||||
private static void readPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonArray payload,
|
||||
JsonDeserializationContext context) {
|
||||
for (JsonElement itemElement : payload) {
|
||||
if (!(itemElement instanceof JsonObject item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable AccountID accountID = readAccountID(item.get("accountID"), context);
|
||||
if (accountID == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement privateDataElement = item.get("privateData");
|
||||
if (privateDataElement == null || !privateDataElement.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accountPrivateData.putPrivateData(accountID, privateDataElement.getAsJsonObject());
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the private data store as a protected payload envelope.
|
||||
@Override
|
||||
public JsonElement serialize(
|
||||
AccountPrivateData accountPrivateData,
|
||||
Type typeOfSrc,
|
||||
JsonSerializationContext context) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty(JsonSchema.PROPERTY_SCHEMA, accountPrivateData.getSchema().url());
|
||||
protectionMode().writePayload(result, createPayload(accountPrivateData, context));
|
||||
accountPrivateData.unknownFields.forEach(result::add);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Deserializes the private data store from a protected payload envelope.
|
||||
@Override
|
||||
public @Nullable AccountPrivateData deserialize(
|
||||
@Nullable JsonElement json,
|
||||
Type typeOfT,
|
||||
JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json == null || json.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
if (!(json instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Account private data is not an object");
|
||||
}
|
||||
|
||||
AccountPrivateData accountPrivateData = new AccountPrivateData();
|
||||
Map<String, JsonElement> values = new LinkedHashMap<>(object.asMap());
|
||||
JsonElement schema = values.remove(JsonSchema.PROPERTY_SCHEMA);
|
||||
if (schema != null && schema.isJsonPrimitive() && schema.getAsJsonPrimitive().isString()) {
|
||||
accountPrivateData.setSchema(new JsonSchema(schema.getAsString()));
|
||||
}
|
||||
values.remove(ProtectedPayload.PROPERTY_PROTECTION);
|
||||
values.remove(ProtectedPayload.PROPERTY_NONCE);
|
||||
values.remove(ProtectedPayload.PROPERTY_PAYLOAD);
|
||||
accountPrivateData.unknownFields.putAll(values);
|
||||
|
||||
readPayload(accountPrivateData, ProtectedPayload.read(object, JsonArray.class), context);
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -35,28 +35,27 @@ 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.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
|
||||
import static org.jackhuang.hmcl.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;
|
||||
|
||||
@@ -120,9 +119,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 +127,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 +348,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 +367,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 +380,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 +403,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 +425,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 +472,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 +490,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);
|
||||
}
|
||||
@@ -414,6 +514,12 @@ public final class Accounts {
|
||||
return i18n("account.failed.no_character");
|
||||
} else if (exception instanceof ServerDisconnectException) {
|
||||
if (exception.getCause() instanceof SSLException) {
|
||||
if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) {
|
||||
return i18n("account.failed.connect_authentication_server");
|
||||
}
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("account.failed.dns");
|
||||
}
|
||||
return i18n("account.failed.ssl");
|
||||
} else {
|
||||
return i18n("account.failed.connect_authentication_server");
|
||||
@@ -462,10 +568,12 @@ 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_probably");
|
||||
return i18n("account.methods.microsoft.error.add_family");
|
||||
} else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) {
|
||||
return i18n("account.methods.microsoft.snapshot");
|
||||
} else if (exception instanceof OAuthAccount.WrongAccountException) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace authlib-injector authentication servers.
|
||||
///
|
||||
/// The JSON representation is saved as `config/authlib-injector-servers.json`
|
||||
/// under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(AuthlibInjectorServerList.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
|
||||
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
|
||||
|
||||
/// The JSON schema supported by this authlib-injector server list.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty authlib-injector server list.
|
||||
public AuthlibInjectorServerList() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates the default authlib-injector server list for a newly created file.
|
||||
public static AuthlibInjectorServerList createDefault() {
|
||||
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
|
||||
result.addLittleSkinIfAbsent();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Adds the bundled LittleSkin server when it is not already present.
|
||||
public void addLittleSkinIfAbsent() {
|
||||
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
|
||||
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema used by this authlib-injector server list file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector authentication servers available for account login.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<AuthlibInjectorServer> servers =
|
||||
FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector authentication servers available for account login.
|
||||
public ObservableList<AuthlibInjectorServer> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerList].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerList> {
|
||||
/// Creates an empty authlib-injector server list for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerList createInstance() {
|
||||
return new AuthlibInjectorServerList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Stores derived authlib-injector server metadata separately from the user-managed server list.
|
||||
///
|
||||
/// The JSON representation is saved as `cache/authlib-injector-server-metadata.json`
|
||||
/// under the current HMCL directory.
|
||||
@JsonAdapter(AuthlibInjectorServerMetadataCache.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AuthlibInjectorServerMetadataCache extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this metadata cache.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-server-metadata", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty metadata cache.
|
||||
AuthlibInjectorServerMetadataCache() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this metadata cache file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this metadata cache file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this metadata cache file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current metadata cache file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector metadata entries.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<Entry> servers = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector metadata entries.
|
||||
ObservableList<Entry> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// Initializes metadata for a server that is being connected to this cache.
|
||||
///
|
||||
/// @param server the server to initialize
|
||||
/// @param storeExistingMetadata whether metadata already present on the server should replace cached metadata
|
||||
void initialize(AuthlibInjectorServer server, boolean storeExistingMetadata) {
|
||||
if (storeExistingMetadata && server.getMetadataResponse().isPresent()) {
|
||||
store(server);
|
||||
} else {
|
||||
restore(server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores cached metadata into the given server when a valid cache entry exists.
|
||||
void restore(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entry entry = servers.get(index);
|
||||
String metadataResponse = entry.getMetadataResponse();
|
||||
if (metadataResponse.isEmpty()) {
|
||||
servers.remove(index);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
server.restoreMetadataCache(metadataResponse, entry.getMetadataTimestamp());
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Ignoring malformed authlib-injector server metadata cache: " + server.getUrl(), e);
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the current metadata response from the given server when it has one.
|
||||
void store(AuthlibInjectorServer server) {
|
||||
server.getMetadataResponse().ifPresent(metadataResponse -> {
|
||||
Entry entry = new Entry(server.getUrl(), metadataResponse, server.getMetadataTimestamp());
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.set(index, entry);
|
||||
} else {
|
||||
servers.add(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Removes cached metadata for the given server.
|
||||
void remove(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the cache entry for the given normalized server URL.
|
||||
private int indexOf(String url) {
|
||||
for (int i = 0; i < servers.size(); i++) {
|
||||
if (url.equals(servers.get(i).getUrl())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Metadata cached for one authlib-injector server.
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
static final class Entry {
|
||||
/// Normalized authlib-injector server URL.
|
||||
@SerializedName("url")
|
||||
private @Nullable String url = "";
|
||||
|
||||
/// Raw metadata response returned by the authlib-injector endpoint.
|
||||
@SerializedName("metadataResponse")
|
||||
private @Nullable String metadataResponse = "";
|
||||
|
||||
/// Wall-clock time when the metadata response was fetched.
|
||||
@SerializedName("metadataTimestamp")
|
||||
private long metadataTimestamp;
|
||||
|
||||
/// Creates an empty metadata entry for deserialization.
|
||||
private Entry() {
|
||||
}
|
||||
|
||||
/// Creates a metadata entry.
|
||||
///
|
||||
/// @param url normalized authlib-injector server URL
|
||||
/// @param metadataResponse raw metadata response returned by the authlib-injector endpoint
|
||||
/// @param metadataTimestamp wall-clock time when the metadata response was fetched
|
||||
Entry(String url, String metadataResponse, long metadataTimestamp) {
|
||||
this.url = Objects.requireNonNull(url);
|
||||
this.metadataResponse = Objects.requireNonNull(metadataResponse);
|
||||
this.metadataTimestamp = metadataTimestamp;
|
||||
}
|
||||
|
||||
/// Returns the normalized authlib-injector server URL.
|
||||
String getUrl() {
|
||||
return Objects.requireNonNullElse(url, "");
|
||||
}
|
||||
|
||||
/// Returns the raw metadata response, or an empty string when the cache entry is incomplete.
|
||||
String getMetadataResponse() {
|
||||
return Objects.requireNonNullElse(metadataResponse, "");
|
||||
}
|
||||
|
||||
/// Returns the wall-clock time when the metadata response was fetched.
|
||||
long getMetadataTimestamp() {
|
||||
return metadataTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerMetadataCache].
|
||||
@NotNullByDefault
|
||||
static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerMetadataCache> {
|
||||
/// Creates an empty metadata cache for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerMetadataCache createInstance() {
|
||||
return new AuthlibInjectorServerMetadataCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonSerializable
|
||||
@@ -71,7 +72,7 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
configLocation = Paths.get(CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
AuthlibInjectorServers configInstance;
|
||||
try {
|
||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||
@@ -81,11 +82,11 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
}
|
||||
|
||||
if (!configInstance.urls.isEmpty()) {
|
||||
config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
for (String url : configInstance.urls) {
|
||||
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
||||
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
servers.add(server);
|
||||
})
|
||||
.start();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 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,756 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(value = Config.Adapter.class)
|
||||
public final class Config extends ObservableSetting {
|
||||
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
public static final int CURRENT_UI_VERSION = 0;
|
||||
|
||||
public static final Gson CONFIG_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
|
||||
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
@Nullable
|
||||
public static Config fromJson(String json) throws JsonParseException {
|
||||
return CONFIG_GSON.fromJson(json, Config.class);
|
||||
}
|
||||
|
||||
public Config() {
|
||||
tracker.markDirty(configVersion);
|
||||
tracker.markDirty(uiVersion);
|
||||
register();
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
@SerializedName("_version")
|
||||
private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION);
|
||||
|
||||
public IntegerProperty configVersionProperty() {
|
||||
return configVersion;
|
||||
}
|
||||
|
||||
public int getConfigVersion() {
|
||||
return configVersion.get();
|
||||
}
|
||||
|
||||
public void setConfigVersion(int configVersion) {
|
||||
this.configVersion.set(configVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of UI that the user have last used.
|
||||
* If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased.
|
||||
* When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown,
|
||||
* then this property is set to the same value as {@link #CURRENT_UI_VERSION}.
|
||||
* In particular, the property is default to 0, so that whoever open the application for the first time will see the guide.
|
||||
*/
|
||||
@SerializedName("uiVersion")
|
||||
private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION);
|
||||
|
||||
public IntegerProperty uiVersionProperty() {
|
||||
return uiVersion;
|
||||
}
|
||||
|
||||
public int getUiVersion() {
|
||||
return uiVersion.get();
|
||||
}
|
||||
|
||||
public void setUiVersion(int uiVersion) {
|
||||
this.uiVersion.set(uiVersion);
|
||||
}
|
||||
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
@SerializedName("localization")
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
public ObjectProperty<SupportedLocale> localizationProperty() {
|
||||
return localization;
|
||||
}
|
||||
|
||||
public SupportedLocale getLocalization() {
|
||||
return localization.get();
|
||||
}
|
||||
|
||||
public void setLocalization(SupportedLocale localization) {
|
||||
this.localization.set(localization);
|
||||
}
|
||||
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
public String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
public void setPromptedVersion(String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
public boolean isAcceptPreviewUpdate() {
|
||||
return acceptPreviewUpdate.get();
|
||||
}
|
||||
|
||||
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
|
||||
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
|
||||
}
|
||||
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
@SerializedName("commonDirType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirTypeProperty() {
|
||||
return commonDirType;
|
||||
}
|
||||
|
||||
public EnumCommonDirectory getCommonDirType() {
|
||||
return commonDirType.get();
|
||||
}
|
||||
|
||||
public void setCommonDirType(EnumCommonDirectory commonDirType) {
|
||||
this.commonDirType.set(commonDirType);
|
||||
}
|
||||
|
||||
@SerializedName("commonpath")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString());
|
||||
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
public String getCommonDirectory() {
|
||||
return commonDirectory.get();
|
||||
}
|
||||
|
||||
public void setCommonDirectory(String commonDirectory) {
|
||||
this.commonDirectory.set(commonDirectory);
|
||||
}
|
||||
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
public ObjectProperty<Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
public Integer getLogLines() {
|
||||
return logLines.get();
|
||||
}
|
||||
|
||||
public void setLogLines(Integer logLines) {
|
||||
this.logLines.set(logLines);
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
@SerializedName("themeBrightness")
|
||||
private final StringProperty themeBrightness = new SimpleStringProperty("light");
|
||||
|
||||
public StringProperty themeBrightnessProperty() {
|
||||
return themeBrightness;
|
||||
}
|
||||
|
||||
public String getThemeBrightness() {
|
||||
return themeBrightness.get();
|
||||
}
|
||||
|
||||
public void setThemeBrightness(String themeBrightness) {
|
||||
this.themeBrightness.set(themeBrightness);
|
||||
}
|
||||
|
||||
@SerializedName("theme")
|
||||
private final ObjectProperty<ThemeColor> themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
public ObjectProperty<ThemeColor> themeColorProperty() {
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
public ThemeColor getThemeColor() {
|
||||
return themeColor.get();
|
||||
}
|
||||
|
||||
public void setThemeColor(ThemeColor themeColor) {
|
||||
this.themeColor.set(themeColor);
|
||||
}
|
||||
|
||||
@SerializedName("fontFamily")
|
||||
private final StringProperty fontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty fontFamilyProperty() {
|
||||
return fontFamily;
|
||||
}
|
||||
|
||||
public String getFontFamily() {
|
||||
return fontFamily.get();
|
||||
}
|
||||
|
||||
public void setFontFamily(String fontFamily) {
|
||||
this.fontFamily.set(fontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("fontSize")
|
||||
private final DoubleProperty fontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
public DoubleProperty fontSizeProperty() {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
public double getFontSize() {
|
||||
return fontSize.get();
|
||||
}
|
||||
|
||||
public void setFontSize(double fontSize) {
|
||||
this.fontSize.set(fontSize);
|
||||
}
|
||||
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
public String getLauncherFontFamily() {
|
||||
return launcherFontFamily.get();
|
||||
}
|
||||
|
||||
public void setLauncherFontFamily(String launcherFontFamily) {
|
||||
this.launcherFontFamily.set(launcherFontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("animationDisabled")
|
||||
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
return animationDisabled.get();
|
||||
}
|
||||
|
||||
public void setAnimationDisabled(boolean animationDisabled) {
|
||||
this.animationDisabled.set(animationDisabled);
|
||||
}
|
||||
|
||||
@SerializedName("titleTransparent")
|
||||
private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty titleTransparentProperty() {
|
||||
return titleTransparent;
|
||||
}
|
||||
|
||||
public boolean isTitleTransparent() {
|
||||
return titleTransparent.get();
|
||||
}
|
||||
|
||||
public void setTitleTransparent(boolean titleTransparent) {
|
||||
this.titleTransparent.set(titleTransparent);
|
||||
}
|
||||
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<EnumBackgroundImage> backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumBackgroundImage> backgroundImageTypeProperty() {
|
||||
return backgroundImageType;
|
||||
}
|
||||
|
||||
public EnumBackgroundImage getBackgroundImageType() {
|
||||
return backgroundImageType.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) {
|
||||
this.backgroundImageType.set(backgroundImageType);
|
||||
}
|
||||
|
||||
@SerializedName("bgpath")
|
||||
private final StringProperty backgroundImage = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageProperty() {
|
||||
return backgroundImage;
|
||||
}
|
||||
|
||||
public String getBackgroundImage() {
|
||||
return backgroundImage.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImage(String backgroundImage) {
|
||||
this.backgroundImage.set(backgroundImage);
|
||||
}
|
||||
|
||||
@SerializedName("bgurl")
|
||||
private final StringProperty backgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageUrlProperty() {
|
||||
return backgroundImageUrl;
|
||||
}
|
||||
|
||||
public String getBackgroundImageUrl() {
|
||||
return backgroundImageUrl.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageUrl(String backgroundImageUrl) {
|
||||
this.backgroundImageUrl.set(backgroundImageUrl);
|
||||
}
|
||||
|
||||
@SerializedName("bgpaint")
|
||||
private final ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
public Paint getBackgroundPaint() {
|
||||
return backgroundPaint.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Paint> backgroundPaintProperty() {
|
||||
return backgroundPaint;
|
||||
}
|
||||
|
||||
public void setBackgroundPaint(Paint backgroundPaint) {
|
||||
this.backgroundPaint.set(backgroundPaint);
|
||||
}
|
||||
|
||||
@SerializedName("bgImageOpacity")
|
||||
private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
|
||||
|
||||
public IntegerProperty backgroundImageOpacityProperty() {
|
||||
return backgroundImageOpacity;
|
||||
}
|
||||
|
||||
public int getBackgroundImageOpacity() {
|
||||
return backgroundImageOpacity.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
|
||||
this.backgroundImageOpacity.set(backgroundImageOpacity);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
public boolean getAutoDownloadThreads() {
|
||||
return autoDownloadThreads.get();
|
||||
}
|
||||
|
||||
public void setAutoDownloadThreads(boolean autoDownloadThreads) {
|
||||
this.autoDownloadThreads.set(autoDownloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
public int getDownloadThreads() {
|
||||
return downloadThreads.get();
|
||||
}
|
||||
|
||||
public void setDownloadThreads(int downloadThreads) {
|
||||
this.downloadThreads.set(downloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadType")
|
||||
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID);
|
||||
|
||||
public StringProperty downloadTypeProperty() {
|
||||
return downloadType;
|
||||
}
|
||||
|
||||
public String getDownloadType() {
|
||||
return downloadType.get();
|
||||
}
|
||||
|
||||
public void setDownloadType(String downloadType) {
|
||||
this.downloadType.set(downloadType);
|
||||
}
|
||||
|
||||
@SerializedName("autoChooseDownloadType")
|
||||
private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoChooseDownloadTypeProperty() {
|
||||
return autoChooseDownloadType;
|
||||
}
|
||||
|
||||
public boolean isAutoChooseDownloadType() {
|
||||
return autoChooseDownloadType.get();
|
||||
}
|
||||
|
||||
public void setAutoChooseDownloadType(boolean autoChooseDownloadType) {
|
||||
this.autoChooseDownloadType.set(autoChooseDownloadType);
|
||||
}
|
||||
|
||||
@SerializedName("versionListSource")
|
||||
private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID);
|
||||
|
||||
public StringProperty versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
public String getVersionListSource() {
|
||||
return versionListSource.get();
|
||||
}
|
||||
|
||||
public void setVersionListSource(String versionListSource) {
|
||||
this.versionListSource.set(versionListSource);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxy")
|
||||
private final BooleanProperty hasProxy = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyProperty() {
|
||||
return hasProxy;
|
||||
}
|
||||
|
||||
public boolean hasProxy() {
|
||||
return hasProxy.get();
|
||||
}
|
||||
|
||||
public void setHasProxy(boolean hasProxy) {
|
||||
this.hasProxy.set(hasProxy);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
public boolean hasProxyAuth() {
|
||||
return hasProxyAuth.get();
|
||||
}
|
||||
|
||||
public void setHasProxyAuth(boolean hasProxyAuth) {
|
||||
this.hasProxyAuth.set(hasProxyAuth);
|
||||
}
|
||||
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<Proxy.Type> proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP);
|
||||
|
||||
public ObjectProperty<Proxy.Type> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
public Proxy.Type getProxyType() {
|
||||
return proxyType.get();
|
||||
}
|
||||
|
||||
public void setProxyType(Proxy.Type proxyType) {
|
||||
this.proxyType.set(proxyType);
|
||||
}
|
||||
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
public String getProxyHost() {
|
||||
return proxyHost.get();
|
||||
}
|
||||
|
||||
public void setProxyHost(String proxyHost) {
|
||||
this.proxyHost.set(proxyHost);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
public int getProxyPort() {
|
||||
return proxyPort.get();
|
||||
}
|
||||
|
||||
public void setProxyPort(int proxyPort) {
|
||||
this.proxyPort.set(proxyPort);
|
||||
}
|
||||
|
||||
@SerializedName("proxyUserName")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
public String getProxyUser() {
|
||||
return proxyUser.get();
|
||||
}
|
||||
|
||||
public void setProxyUser(String proxyUser) {
|
||||
this.proxyUser.set(proxyUser);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPass = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyPassProperty() {
|
||||
return proxyPass;
|
||||
}
|
||||
|
||||
public String getProxyPass() {
|
||||
return proxyPass.get();
|
||||
}
|
||||
|
||||
public void setProxyPass(String proxyPass) {
|
||||
this.proxyPass.set(proxyPass);
|
||||
}
|
||||
|
||||
// Game
|
||||
|
||||
@SerializedName("disableAutoGameOptions")
|
||||
private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty disableAutoGameOptionsProperty() {
|
||||
return disableAutoGameOptions;
|
||||
}
|
||||
|
||||
public boolean isDisableAutoGameOptions() {
|
||||
return disableAutoGameOptions.get();
|
||||
}
|
||||
|
||||
public void setDisableAutoGameOptions(boolean disableAutoGameOptions) {
|
||||
this.disableAutoGameOptions.set(disableAutoGameOptions);
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
@SerializedName("authlibInjectorServers")
|
||||
private final ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
|
||||
|
||||
public ObservableList<AuthlibInjectorServer> getAuthlibInjectorServers() {
|
||||
return authlibInjectorServers;
|
||||
}
|
||||
|
||||
@SerializedName("addedLittleSkin")
|
||||
private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty addedLittleSkinProperty() {
|
||||
return addedLittleSkin;
|
||||
}
|
||||
|
||||
public boolean isAddedLittleSkin() {
|
||||
return addedLittleSkin.get();
|
||||
}
|
||||
|
||||
public void setAddedLittleSkin(boolean addedLittleSkin) {
|
||||
this.addedLittleSkin.set(addedLittleSkin);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred login type to use when the user wants to add an account.
|
||||
*/
|
||||
@SerializedName("preferredLoginType")
|
||||
private final StringProperty preferredLoginType = new SimpleStringProperty();
|
||||
|
||||
public StringProperty preferredLoginTypeProperty() {
|
||||
return preferredLoginType;
|
||||
}
|
||||
|
||||
public String getPreferredLoginType() {
|
||||
return preferredLoginType.get();
|
||||
}
|
||||
|
||||
public void setPreferredLoginType(String preferredLoginType) {
|
||||
this.preferredLoginType.set(preferredLoginType);
|
||||
}
|
||||
|
||||
@SerializedName("selectedAccount")
|
||||
private final StringProperty selectedAccount = new SimpleStringProperty();
|
||||
|
||||
public StringProperty selectedAccountProperty() {
|
||||
return selectedAccount;
|
||||
}
|
||||
|
||||
public String getSelectedAccount() {
|
||||
return selectedAccount.get();
|
||||
}
|
||||
|
||||
public void setSelectedAccount(String selectedAccount) {
|
||||
this.selectedAccount.set(selectedAccount);
|
||||
}
|
||||
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<Map<Object, Object>> getAccountStorages() {
|
||||
return accountStorages;
|
||||
}
|
||||
|
||||
// Configurations
|
||||
|
||||
@SerializedName("last")
|
||||
private final StringProperty selectedProfile = new SimpleStringProperty("");
|
||||
|
||||
public StringProperty selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
public String getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public void setSelectedProfile(String selectedProfile) {
|
||||
this.selectedProfile.set(selectedProfile);
|
||||
}
|
||||
|
||||
@SerializedName("configurations")
|
||||
private final SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
|
||||
|
||||
public MapProperty<String, Profile> getConfigurations() {
|
||||
return configurations;
|
||||
}
|
||||
|
||||
public static final class Adapter extends ObservableSetting.Adapter<Config> {
|
||||
@Override
|
||||
protected Config createInstance() {
|
||||
return new Config();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ConfigHolder {
|
||||
|
||||
private ConfigHolder() {
|
||||
}
|
||||
|
||||
public static final String CONFIG_FILENAME = "hmcl.json";
|
||||
public static final String CONFIG_FILENAME_LINUX = ".hmcl.json";
|
||||
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json");
|
||||
|
||||
private static Path configLocation;
|
||||
private static Config configInstance;
|
||||
private static GlobalConfig globalConfigInstance;
|
||||
private static boolean newlyCreated;
|
||||
private static boolean ownerChanged = false;
|
||||
private static boolean unsupportedVersion = false;
|
||||
|
||||
public static Config config() {
|
||||
if (configInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return configInstance;
|
||||
}
|
||||
|
||||
public static GlobalConfig globalConfig() {
|
||||
if (globalConfigInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return globalConfigInstance;
|
||||
}
|
||||
|
||||
public static Path configLocation() {
|
||||
return configLocation;
|
||||
}
|
||||
|
||||
public static boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
public static boolean isOwnerChanged() {
|
||||
return ownerChanged;
|
||||
}
|
||||
|
||||
public static boolean isUnsupportedVersion() {
|
||||
return unsupportedVersion;
|
||||
}
|
||||
|
||||
public static void init() throws IOException {
|
||||
if (configInstance != null) {
|
||||
throw new IllegalStateException("Configuration is already loaded");
|
||||
}
|
||||
|
||||
configLocation = locateConfig();
|
||||
|
||||
LOG.info("Config location: " + configLocation);
|
||||
|
||||
configInstance = loadConfig();
|
||||
if (!unsupportedVersion)
|
||||
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
|
||||
|
||||
globalConfigInstance = loadGlobalConfig();
|
||||
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
|
||||
|
||||
Locale.setDefault(config().getLocalization().getLocale());
|
||||
I18n.setLocale(configInstance.getLocalization());
|
||||
LOG.setLogRetention(globalConfig().getLogRetention());
|
||||
Settings.init();
|
||||
|
||||
if (newlyCreated) {
|
||||
LOG.info("Creating config file " + configLocation);
|
||||
FileUtils.saveSafely(configLocation, configInstance.toJson());
|
||||
}
|
||||
|
||||
if (!Files.isWritable(configLocation)) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
|
||||
&& configLocation.getFileSystem() == FileSystems.getDefault()
|
||||
&& configLocation.toFile().canWrite()) {
|
||||
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
|
||||
// There are some serious problems with the implementation of Samba or OpenJDK
|
||||
throw new SambaException();
|
||||
} else {
|
||||
// the config cannot be saved
|
||||
// throw up the error now to prevent further data loss
|
||||
throw new IOException("Config at " + configLocation + " is not writable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Path locateConfig() {
|
||||
Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(defaultConfigFile))
|
||||
return defaultConfigFile;
|
||||
|
||||
try {
|
||||
Path jarPath = JarUtils.thisJarPath();
|
||||
if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) {
|
||||
jarPath = jarPath.getParent();
|
||||
|
||||
Path config = jarPath.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
}
|
||||
|
||||
} catch (Throwable ignore) {
|
||||
}
|
||||
|
||||
Path config = Paths.get(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
|
||||
// create new
|
||||
return defaultConfigFile;
|
||||
}
|
||||
|
||||
private static Config loadConfig() throws IOException {
|
||||
if (Files.exists(configLocation)) {
|
||||
try {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
&& "root".equals(System.getProperty("user.name"))
|
||||
&& !"root".equals(Files.getOwner(configLocation).getName())) {
|
||||
ownerChanged = true;
|
||||
}
|
||||
} catch (IOException e1) {
|
||||
LOG.warning("Failed to get owner");
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(configLocation);
|
||||
Config deserialized = Config.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
if (configVersion < Config.CURRENT_VERSION) {
|
||||
ConfigUpgrader.upgradeConfig(deserialized, content);
|
||||
} else if (configVersion > Config.CURRENT_VERSION) {
|
||||
unsupportedVersion = true;
|
||||
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion));
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreated = true;
|
||||
return new Config();
|
||||
}
|
||||
|
||||
// Global Config
|
||||
|
||||
private static GlobalConfig loadGlobalConfig() throws IOException {
|
||||
if (Files.exists(GLOBAL_CONFIG_PATH)) {
|
||||
try {
|
||||
String content = Files.readString(GLOBAL_CONFIG_PATH);
|
||||
GlobalConfig deserialized = GlobalConfig.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("Creating an empty global config");
|
||||
return new GlobalConfig();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
final class ConfigUpgrader {
|
||||
private ConfigUpgrader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is for the compatibility with old HMCL versions.
|
||||
*
|
||||
* @param deserialized deserialized config settings
|
||||
* @param rawContent raw json content of the config settings without modification
|
||||
*/
|
||||
static void upgradeConfig(Config deserialized, String rawContent) {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
|
||||
if (configVersion >= Config.CURRENT_VERSION)
|
||||
return;
|
||||
|
||||
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION));
|
||||
Map<?, ?> rawJson = Collections.unmodifiableMap(new Gson().<Map<?, ?>>fromJson(rawContent, Map.class));
|
||||
|
||||
if (configVersion < 1) {
|
||||
// Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important.
|
||||
tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> {
|
||||
tryCast(auth.get("offline"), Map.class).ifPresent(offline -> {
|
||||
String selected = rawJson.containsKey("selectedAccount") ? null
|
||||
: tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null);
|
||||
|
||||
tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> {
|
||||
((Map<?, ?>) uuidMap).forEach((key, value) -> {
|
||||
Map<Object, Object> storage = new HashMap<>();
|
||||
storage.put("type", "offline");
|
||||
storage.put("username", key);
|
||||
storage.put("uuid", value);
|
||||
if (key.equals(selected)) {
|
||||
storage.put("selected", true);
|
||||
}
|
||||
deserialized.getAccountStorages().add(storage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Upgrade configuration of HMCL earlier than 3.1.70
|
||||
if (!rawJson.containsKey("commonDirType"))
|
||||
deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM);
|
||||
if (!rawJson.containsKey("backgroundType"))
|
||||
deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT);
|
||||
if (!rawJson.containsKey("hasProxy"))
|
||||
deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost()));
|
||||
if (!rawJson.containsKey("hasProxyAuth"))
|
||||
deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser()));
|
||||
|
||||
if (!rawJson.containsKey("downloadType")) {
|
||||
tryCast(rawJson.get("downloadtype"), Number.class)
|
||||
.map(Number::intValue)
|
||||
.ifPresent(id -> {
|
||||
if (id == 0) {
|
||||
deserialized.setDownloadType("mojang");
|
||||
} else if (id == 1) {
|
||||
deserialized.setDownloadType("bmclapi");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deserialized.setConfigVersion(Config.CURRENT_VERSION);
|
||||
}
|
||||
}
|
||||
+11
-16
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -15,21 +15,16 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.wizard;
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
public enum WizardNavigationResult {
|
||||
PROCEED {
|
||||
@Override
|
||||
public boolean getDeferredComputation() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
DENY {
|
||||
@Override
|
||||
public boolean getDeferredComputation() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/// @author Glavo
|
||||
public enum DefaultIsolationType {
|
||||
/// Default to isolate all instances.
|
||||
ALWAYS,
|
||||
|
||||
public abstract boolean getDeferredComputation();
|
||||
/// Default to isolate instances with any modloader.
|
||||
MODDED,
|
||||
|
||||
/// No instances are isolated by default.
|
||||
NEVER,
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
@@ -33,82 +32,70 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
|
||||
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
|
||||
|
||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
||||
|
||||
private static final DownloadProvider MOJANG_PROVIDER;
|
||||
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
|
||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
||||
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
|
||||
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
|
||||
DownloadProvider mojang = new MojangDownloadProvider();
|
||||
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
|
||||
|
||||
DEFAULT_PROVIDER = mojang;
|
||||
DIRECT_PROVIDERS = Lang.mapOf(
|
||||
pair("mojang", mojang),
|
||||
pair("bmclapi", bmclapi)
|
||||
);
|
||||
|
||||
AUTO_PROVIDERS = Lang.mapOf(
|
||||
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
|
||||
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
|
||||
List.of(mojang, bmclapiRaw),
|
||||
List.of(bmclapiRaw, mojang)
|
||||
) : mojang),
|
||||
pair("mirror", bmclapi)
|
||||
);
|
||||
|
||||
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
MOJANG_PROVIDER = new MojangDownloadProvider();
|
||||
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Initializes download provider settings and synchronizes download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
|
||||
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
|
||||
? DEFAULT_CONCURRENCY
|
||||
: config().getDownloadThreads());
|
||||
: settings().downloadThreadsProperty().get());
|
||||
};
|
||||
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
if (config().isAutoChooseDownloadType()) {
|
||||
String versionListSource = config().getVersionListSource();
|
||||
DownloadProvider downloadProvider = versionListSource != null
|
||||
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER;
|
||||
PROVIDER_WRAPPER.setProvider(downloadProvider);
|
||||
} else {
|
||||
String downloadType = config().getDownloadType();
|
||||
PROVIDER_WRAPPER.setProvider(downloadType != null
|
||||
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER);
|
||||
}
|
||||
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
|
||||
settings().versionListSourceProperty().get(),
|
||||
settings().fileDownloadSourceProperty().get()));
|
||||
};
|
||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
config().downloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
}
|
||||
|
||||
/// Creates a download provider with independent version-list and file download preferences.
|
||||
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
|
||||
return new AutoDownloadProvider(
|
||||
getCandidates(versionListSource),
|
||||
getCandidates(fileDownloadSource));
|
||||
}
|
||||
|
||||
/// Returns provider candidates ordered by the given source preference.
|
||||
private static List<DownloadProvider> getCandidates(DownloadSource source) {
|
||||
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
|
||||
return switch (normalized) {
|
||||
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
|
||||
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
|
||||
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
|
||||
case OFFICIAL -> List.of(MOJANG_PROVIDER);
|
||||
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
@@ -134,7 +121,10 @@ public final class DownloadProviders {
|
||||
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) {
|
||||
} 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());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.text.Font;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -36,7 +37,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -55,17 +56,17 @@ public final class FontManager {
|
||||
|
||||
// Recommended
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
// Legacy
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -73,7 +74,7 @@ public final class FontManager {
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -94,31 +95,26 @@ public final class FontManager {
|
||||
return null;
|
||||
});
|
||||
|
||||
private static final ObjectProperty<FontReference> fontProperty;
|
||||
private static final ObjectProperty<FontReference> font = new SimpleObjectProperty<>();
|
||||
|
||||
static {
|
||||
String fontFamily = config().getLauncherFontFamily();
|
||||
updateFont();
|
||||
LOG.info("Font: " + (font.get() != null ? font.get().family() : "System"));
|
||||
}
|
||||
|
||||
private static void updateFont() {
|
||||
String fontFamily = settings().launcherFontFamilyProperty().get();
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getProperty("hmcl.font.override");
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getenv("HMCL_FONT");
|
||||
|
||||
FontReference fontReference;
|
||||
if (fontFamily == null) {
|
||||
Font defaultFont = DEFAULT_FONT.get();
|
||||
fontReference = defaultFont != null ? new FontReference(defaultFont) : null;
|
||||
} else
|
||||
fontReference = new FontReference(fontFamily);
|
||||
|
||||
fontProperty = new SimpleObjectProperty<>(fontReference);
|
||||
|
||||
LOG.info("Font: " + (fontReference != null ? fontReference.getFamily() : "System"));
|
||||
fontProperty.addListener((obs, oldValue, newValue) -> {
|
||||
if (newValue != null)
|
||||
config().setLauncherFontFamily(newValue.getFamily());
|
||||
else
|
||||
config().setLauncherFontFamily(null);
|
||||
});
|
||||
font.set(defaultFont != null ? new FontReference(defaultFont) : null);
|
||||
} else {
|
||||
font.set(new FontReference(fontFamily));
|
||||
}
|
||||
}
|
||||
|
||||
private static Font tryLoadDefaultFont(Path dir) {
|
||||
@@ -231,64 +227,35 @@ public final class FontManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectProperty<FontReference> fontProperty() {
|
||||
return fontProperty;
|
||||
public static ReadOnlyObjectProperty<FontReference> fontProperty() {
|
||||
return font;
|
||||
}
|
||||
|
||||
public static FontReference getFont() {
|
||||
return fontProperty.get();
|
||||
}
|
||||
|
||||
public static void setFont(FontReference font) {
|
||||
fontProperty.set(font);
|
||||
return font.get();
|
||||
}
|
||||
|
||||
public static void setFontFamily(String fontFamily) {
|
||||
setFont(fontFamily != null ? new FontReference(fontFamily) : null);
|
||||
settings().launcherFontFamilyProperty().set(fontFamily);
|
||||
updateFont();
|
||||
}
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4072
|
||||
public static final class FontReference {
|
||||
private final @NotNull String family;
|
||||
private final @Nullable String style;
|
||||
public record FontReference(@NotNull String family, @Nullable String style) {
|
||||
public FontReference {
|
||||
Objects.requireNonNull(family);
|
||||
}
|
||||
|
||||
public FontReference(@NotNull String family) {
|
||||
this.family = Objects.requireNonNull(family);
|
||||
this.style = null;
|
||||
this(family, null);
|
||||
}
|
||||
|
||||
public FontReference(@NotNull Font font) {
|
||||
this.family = font.getFamily();
|
||||
this.style = font.getStyle();
|
||||
}
|
||||
|
||||
public @NotNull String getFamily() {
|
||||
return family;
|
||||
}
|
||||
|
||||
public @Nullable String getStyle() {
|
||||
return style;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof FontReference))
|
||||
return false;
|
||||
FontReference that = (FontReference) o;
|
||||
return Objects.equals(family, that.family) && Objects.equals(style, that.style);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(family, style);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("FontReference[family='%s', style='%s']", family, style);
|
||||
this(font.getFamily(), font.getStyle());
|
||||
}
|
||||
}
|
||||
|
||||
private FontManager() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores game directories independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory in either the current
|
||||
/// HMCL directory or the user HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameDirectories.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameDirectories extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game directory store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-directories", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game directory store.
|
||||
public GameDirectories() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this game directory store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game directory store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game directory store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this game directory store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Game directories stored in this file.
|
||||
@SerializedName("directories")
|
||||
private final ObservableList<GameDirectory> gameDirectories =
|
||||
FXCollections.observableArrayList(profile -> new Observable[] { profile });
|
||||
|
||||
/// Whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
private transient boolean userFile;
|
||||
|
||||
/// Returns the game directories stored in this file.
|
||||
public ObservableList<GameDirectory> getGameDirectories() {
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Sets whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
void setUserFile(boolean userFile) {
|
||||
this.userFile = userFile;
|
||||
}
|
||||
|
||||
/// Returns whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
boolean isUserFile() {
|
||||
return userFile;
|
||||
}
|
||||
|
||||
/// Whether this store was created because its JSON file did not exist.
|
||||
private transient boolean newlyCreated;
|
||||
|
||||
/// Returns whether this store was created because its JSON file did not exist.
|
||||
boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
/// Sets whether this store was created because its JSON file did not exist.
|
||||
void setNewlyCreated(boolean newlyCreated) {
|
||||
this.newlyCreated = newlyCreated;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameDirectories].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameDirectories> {
|
||||
/// Creates an empty game directory store for deserialization.
|
||||
@Override
|
||||
protected GameDirectories createInstance() {
|
||||
return new GameDirectories();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
|
||||
/// 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(onInvalidating(this::invalidate));
|
||||
}
|
||||
|
||||
/// Returns a debug string containing the game directory path and display metadata.
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("path", getPath())
|
||||
.append("name", getName())
|
||||
.toString();
|
||||
}
|
||||
|
||||
/// Registers a listener that invalidates this game directory when any stored property changes.
|
||||
private void addPropertyChangedListener(InvalidationListener listener) {
|
||||
name.addListener(listener);
|
||||
path.addListener(listener);
|
||||
legacyGameSettings.addListener(listener);
|
||||
}
|
||||
|
||||
/// Helper that stores and dispatches invalidation listeners for this game directory.
|
||||
private final ObservableHelper observableHelper = new ObservableHelper(this);
|
||||
|
||||
/// Adds an invalidation listener.
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
observableHelper.addListener(listener);
|
||||
}
|
||||
|
||||
/// Removes an invalidation listener.
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
observableHelper.removeListener(listener);
|
||||
}
|
||||
|
||||
/// Notifies game directory observers on the JavaFX thread when the toolkit is available.
|
||||
private void invalidate() {
|
||||
try {
|
||||
Platform.runLater(observableHelper::invalidate);
|
||||
} catch (IllegalStateException e) {
|
||||
observableHelper.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes and deserializes game directories.
|
||||
@NotNullByDefault
|
||||
public static final class Serializer implements JsonSerializer<GameDirectory>, JsonDeserializer<GameDirectory> {
|
||||
/// Serializes a game directory to JSON.
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable GameDirectory src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null)
|
||||
return JsonNull.INSTANCE;
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("id", context.serialize(src.getId(), GameDirectoryID.class));
|
||||
if (src.getName() != null) {
|
||||
JsonElement name = context.serialize(src.getName(), LocalizedText.class);
|
||||
if (name != null && !name.isJsonNull()) {
|
||||
jsonObject.add("name", name);
|
||||
}
|
||||
}
|
||||
jsonObject.add("path", context.serialize(src.getPath(), PortablePath.class));
|
||||
if (src.getLegacyGameSettings() != null) {
|
||||
jsonObject.add("legacyGameSettings", context.serialize(src.getLegacyGameSettings(), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
/// Deserializes a game directory from JSON.
|
||||
@Override
|
||||
public @Nullable GameDirectory deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject obj)) return null;
|
||||
GameDirectoryID id = context.deserialize(obj.get("id"), GameDirectoryID.class);
|
||||
if (id == null) {
|
||||
throw new JsonParseException("Game directory ID cannot be null");
|
||||
} else if (GameDirectoryID.NIL.equals(id)) {
|
||||
throw new JsonParseException("Game directory ID cannot be nil");
|
||||
}
|
||||
PortablePath path = context.deserialize(obj.get("path"), PortablePath.class);
|
||||
if (path == null) {
|
||||
throw new JsonParseException("Game directory path cannot be null");
|
||||
}
|
||||
@Nullable LocalizedText name = context.deserialize(obj.get("name"), LocalizedText.class);
|
||||
|
||||
return new GameDirectory(id,
|
||||
name,
|
||||
path,
|
||||
context.deserialize(obj.get("legacyGameSettings"), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game directory entry.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameDirectoryID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameDirectoryID(UUID uuid) implements TypedID {
|
||||
/// The serialized game directory ID prefix.
|
||||
public static final String PREFIX = "game-directory";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameDirectoryID NIL = new GameDirectoryID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game directory ID.
|
||||
public GameDirectoryID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game directory ID from a prefixed UUID string.
|
||||
public static GameDirectoryID parse(String value) {
|
||||
return new GameDirectoryID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game directory ID.
|
||||
public static GameDirectoryID generate() {
|
||||
return new GameDirectoryID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game directory ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameDirectoryID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameDirectoryID> {
|
||||
/// Creates a game directory ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameDirectoryID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
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 ID projected from the selected repository.
|
||||
private static final ReadOnlyStringWrapper selectedInstance = new ReadOnlyStringWrapper(GameDirectoryManager.class, "selectedInstance");
|
||||
|
||||
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
|
||||
private static final ChangeListener<String> selectedRepositoryInstanceListener =
|
||||
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
|
||||
|
||||
/// Initializes game directory state from the stores loaded by [SettingsManager].
|
||||
///
|
||||
/// This method creates the built-in local and user-home game directories when required, rebuilds
|
||||
/// the merged game directory view, and restores the selected game directory from [LauncherSettings].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
initialized = true;
|
||||
|
||||
rebuildGameDirectories();
|
||||
|
||||
boolean needRebuildGameDirectories = false;
|
||||
if (localGameDirectories().isNewlyCreated() && localGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = localGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = userGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
|
||||
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
|
||||
if (needRebuildGameDirectories) {
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
assert !mergedGameDirectories.isEmpty();
|
||||
|
||||
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
|
||||
GameDirectory currentGameDirectory = null;
|
||||
|
||||
if (selectedId != null) {
|
||||
for (GameDirectory gameDirectory : mergedGameDirectories) {
|
||||
if (gameDirectory.getId().equals(selectedId)) {
|
||||
currentGameDirectory = gameDirectory;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedGameDirectory.addListener((a, b, newValue) -> {
|
||||
if (newValue == null) {
|
||||
throw new IllegalStateException("selectedGameDirectory cannot be null");
|
||||
}
|
||||
|
||||
settings().selectedGameDirectoryProperty().set(newValue.getId());
|
||||
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
|
||||
if (oldRepository != null) {
|
||||
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
|
||||
}
|
||||
HMCLGameRepository repository = getOrCreateRepository(newValue);
|
||||
selectedRepository.set(repository);
|
||||
selectedInstance.set(repository.getSelectedInstance());
|
||||
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
|
||||
repository.refreshVersionsAsync().start();
|
||||
});
|
||||
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
|
||||
|
||||
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
|
||||
runInFX(() -> {
|
||||
@Nullable HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository != null && repository == event.getSource()) {
|
||||
repository.refreshSelectedInstance();
|
||||
for (Consumer<HMCLGameRepository> listener : versionsListeners)
|
||||
listener.accept(repository);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates the built-in game directories only when no game directory exists.
|
||||
private static boolean createDefaultGameDirectoriesIfEmpty() {
|
||||
if (localGameDirectories().getGameDirectories().isEmpty()
|
||||
&& userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
localGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
userGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the merged runtime game directory view from the two backing stores.
|
||||
private static void rebuildGameDirectories() {
|
||||
GameDirectories userGameDirectories = userGameDirectories();
|
||||
GameDirectories localGameDirectories = localGameDirectories();
|
||||
Map<GameDirectoryID, GameDirectory> visibleGameDirectories = new LinkedHashMap<>();
|
||||
|
||||
for (GameDirectory gameDirectory : localGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.put(id, gameDirectory);
|
||||
}
|
||||
for (GameDirectory gameDirectory : userGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.putIfAbsent(id, gameDirectory);
|
||||
}
|
||||
|
||||
mergedGameDirectories.setAll(visibleGameDirectories.values());
|
||||
}
|
||||
|
||||
/// Returns the read-only merged game directory list.
|
||||
public static @UnmodifiableView ObservableList<GameDirectory> getGameDirectories() {
|
||||
return mergedGameDirectoriesUnmodifiable;
|
||||
}
|
||||
|
||||
/// Returns the repository for the given game directory, creating it when needed.
|
||||
private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the per-workspace store.
|
||||
public static void addLocalGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("Local game directories are read-only");
|
||||
}
|
||||
addGameDirectory(localGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the user store.
|
||||
public static void addUserGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("User game directories are read-only");
|
||||
}
|
||||
addGameDirectory(userGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether an existing game directory can be updated to the given path.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
/// @return whether both the current and target stores may be edited
|
||||
public static boolean canUpdateGameDirectory(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
return !isGameDirectoriesReadOnly(source) && !isGameDirectoriesReadOnly(target);
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to update a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
forceOverwriteGameDirectoriesIfReadOnly(source);
|
||||
if (target != source) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a game directory, moving it between local and user stores when the path type changes.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param name the new custom game directory name, or `null` for an unnamed game directory
|
||||
/// @param path the new game directory path
|
||||
public static void updateGameDirectory(GameDirectory gameDirectory, @Nullable LocalizedText name, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
if (isGameDirectoriesReadOnly(source) || isGameDirectoriesReadOnly(target)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
if (source == target) {
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
return;
|
||||
}
|
||||
|
||||
source.getGameDirectories().remove(gameDirectory);
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
addGameDirectory(target, gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether the game directory can be removed from all stores containing it.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
/// @return whether all containing stores may be edited
|
||||
public static boolean canRemoveGameDirectory(GameDirectory gameDirectory) {
|
||||
boolean localContains = localGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
boolean userContains = userGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
return (!localContains || !SettingsManager.isLocalGameDirectoriesReadOnly())
|
||||
&& (!userContains || !SettingsManager.isUserGameDirectoriesReadOnly());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to remove a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory) {
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(localGameDirectories());
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(userGameDirectories());
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a game directory to the given store, replacing a game directory with the same ID in that store.
|
||||
private static void addGameDirectory(GameDirectories gameDirectories, GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
ObservableList<GameDirectory> entries = gameDirectories.getGameDirectories();
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
if (entries.get(i).getId().equals(id)) {
|
||||
repositories.remove(entries.get(i));
|
||||
settings().setSelectedInstance(id, null);
|
||||
entries.set(i, gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
/// Removes a game directory and recreates the built-in game directories when the list becomes empty.
|
||||
public static void removeGameDirectory(GameDirectory gameDirectory) {
|
||||
if (!canRemoveGameDirectory(gameDirectory)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
userGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
localGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
repositories.remove(gameDirectory);
|
||||
createDefaultGameDirectoriesIfEmpty();
|
||||
rebuildGameDirectories();
|
||||
|
||||
if (!mergedGameDirectories.contains(selectedGameDirectory.get())) {
|
||||
setSelectedGameDirectory(mergedGameDirectories.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the store that should own game directories with the given path.
|
||||
private static GameDirectories getGameDirectoryStore(PortablePath path) {
|
||||
Objects.requireNonNull(path);
|
||||
return path.isAbsolute() ? userGameDirectories() : localGameDirectories();
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory.
|
||||
private static @Nullable GameDirectories findGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return localGameDirectories();
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return userGameDirectories();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory, or throws when it is detached.
|
||||
private static GameDirectories requireGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
@Nullable GameDirectories gameDirectories = findGameDirectoryStore(gameDirectory);
|
||||
if (gameDirectories == null) {
|
||||
throw new IllegalArgumentException("Game directory does not belong to a game directory store");
|
||||
}
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Returns whether the given game directory store is read-only.
|
||||
private static boolean isGameDirectoriesReadOnly(GameDirectories gameDirectories) {
|
||||
return gameDirectories.isUserFile()
|
||||
? SettingsManager.isUserGameDirectoriesReadOnly()
|
||||
: SettingsManager.isLocalGameDirectoriesReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the given game directory store when it is read-only.
|
||||
private static void forceOverwriteGameDirectoriesIfReadOnly(GameDirectories gameDirectories) {
|
||||
if (!isGameDirectoriesReadOnly(gameDirectories)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameDirectories.isUserFile()) {
|
||||
SettingsManager.forceOverwriteUserGameDirectories();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteLocalGameDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected game directory, creating built-in game directories first if the list is empty.
|
||||
public static GameDirectory getSelectedGameDirectory() {
|
||||
GameDirectory gameDirectory = selectedGameDirectory.get();
|
||||
if (gameDirectory == null) {
|
||||
throw new IllegalStateException("Selected game directory cannot be null");
|
||||
}
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository.
|
||||
public static HMCLGameRepository getSelectedRepository() {
|
||||
HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository == null) {
|
||||
throw new IllegalStateException("Selected repository cannot be null");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Sets the selected game directory.
|
||||
public static void setSelectedGameDirectory(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (!mergedGameDirectories.contains(gameDirectory)) {
|
||||
throw new IllegalArgumentException("Unknown game directory: " + gameDirectory);
|
||||
}
|
||||
selectedGameDirectory.set(gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns the selected game directory property.
|
||||
///
|
||||
/// The property is exposed for UI binding. Values should be changed through
|
||||
/// [#setSelectedGameDirectory(GameDirectory)] or by bidirectional bindings that only select
|
||||
/// game directories from [#getGameDirectories()].
|
||||
public static ObjectProperty<GameDirectory> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository property.
|
||||
public static ObjectProperty<HMCLGameRepository> selectedRepositoryProperty() {
|
||||
return selectedRepository;
|
||||
}
|
||||
|
||||
/// Returns the selected instance property projected from the selected repository.
|
||||
public static ReadOnlyStringProperty selectedInstanceProperty() {
|
||||
return selectedInstance.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the selected instance ID for the selected repository.
|
||||
public static @Nullable String getSelectedInstance() {
|
||||
return getSelectedRepository().getSelectedInstance();
|
||||
}
|
||||
|
||||
/// Sets the selected instance ID for the selected repository.
|
||||
public static void setSelectedInstance(@Nullable String 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game settings preset.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameSettingsPresetID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameSettingsPresetID(UUID uuid) implements TypedID {
|
||||
/// The serialized game settings preset ID prefix.
|
||||
public static final String PREFIX = "game-settings-preset";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameSettingsPresetID NIL = new GameSettingsPresetID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game settings preset ID.
|
||||
public GameSettingsPresetID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game settings preset ID from a prefixed UUID string.
|
||||
public static GameSettingsPresetID parse(String value) {
|
||||
return new GameSettingsPresetID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game settings preset ID.
|
||||
public static GameSettingsPresetID generate() {
|
||||
return new GameSettingsPresetID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game settings preset ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameSettingsPresetID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameSettingsPresetID> {
|
||||
/// Creates a game settings preset ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameSettingsPresetID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores reusable game settings presets independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved as `config/game-settings.json` under the current HMCL
|
||||
/// directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameSettingsPresets.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game settings preset store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game settings preset store.
|
||||
public GameSettingsPresets() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Copies another preset store into this instance.
|
||||
void copyFrom(GameSettingsPresets source) {
|
||||
if (source == this) {
|
||||
return;
|
||||
}
|
||||
|
||||
presets.setAll(source.getPresets());
|
||||
}
|
||||
|
||||
/// The schema used by this game settings preset store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game settings preset store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game settings preset store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this preset store may be saved back to `config/game-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Reusable game setting presets.
|
||||
@SerializedName("presets")
|
||||
private final ObservableList<GameSettings.Preset> presets =
|
||||
FXCollections.observableArrayList(setting -> new Observable[] { setting });
|
||||
|
||||
/// Returns the reusable game setting presets.
|
||||
public ObservableList<GameSettings.Preset> getPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
/// Creates a preset ID that does not collide with existing presets.
|
||||
public GameSettingsPresetID newPresetId() {
|
||||
GameSettingsPresetID id;
|
||||
do {
|
||||
id = GameSettingsPresetID.generate();
|
||||
} while (getPreset(id) != null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Returns the first automatic preset number that is not used by existing presets.
|
||||
public int newPresetAutoNameNumber() {
|
||||
mainLoop:
|
||||
for (int index = 1; ; index++) {
|
||||
for (GameSettings.Preset setting : getPresets()) {
|
||||
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
|
||||
if ((autoNameNumber != null && autoNameNumber == index)) {
|
||||
continue mainLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the preset with the given ID.
|
||||
public GameSettings.@Nullable Preset getPreset(@Nullable GameSettingsPresetID id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (GameSettings.Preset setting : presets) {
|
||||
if (id.equals(setting.idProperty().getValue())) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameSettingsPresets].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameSettingsPresets> {
|
||||
/// Creates an empty preset store for deserialization.
|
||||
@Override
|
||||
protected GameSettingsPresets createInstance() {
|
||||
return new GameSettingsPresets();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/// The initial display mode of the game window.
|
||||
///
|
||||
/// @author Glavo
|
||||
public enum GameWindowType {
|
||||
/// Start the game in a normal window.
|
||||
WINDOWED,
|
||||
|
||||
/// Start the game in fullscreen mode.
|
||||
FULLSCREEN,
|
||||
|
||||
/// Start the game in a maximized window.
|
||||
MAXIMIZED,
|
||||
}
|
||||
@@ -1,244 +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 javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(GlobalConfig.Serializer.class)
|
||||
public final class GlobalConfig implements Observable {
|
||||
|
||||
@Nullable
|
||||
public static GlobalConfig fromJson(String json) throws JsonParseException {
|
||||
GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
|
||||
if (loaded == null) {
|
||||
return null;
|
||||
}
|
||||
GlobalConfig instance = new GlobalConfig();
|
||||
PropertyUtils.copyProperties(loaded, instance);
|
||||
instance.unknownFields.putAll(loaded.unknownFields);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty logRetention = new SimpleIntegerProperty();
|
||||
|
||||
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
|
||||
|
||||
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
private final Map<String, Object> unknownFields = new HashMap<>();
|
||||
|
||||
private final transient ObservableHelper helper = new ObservableHelper(this);
|
||||
|
||||
public GlobalConfig() {
|
||||
PropertyUtils.attachListener(this, helper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
helper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
helper.removeListener(listener);
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return Config.CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
public int getAgreementVersion() {
|
||||
return agreementVersion.get();
|
||||
}
|
||||
|
||||
public IntegerProperty agreementVersionProperty() {
|
||||
return agreementVersion;
|
||||
}
|
||||
|
||||
public void setAgreementVersion(int agreementVersion) {
|
||||
this.agreementVersion.set(agreementVersion);
|
||||
}
|
||||
|
||||
public int getTerracottaAgreementVersion() {
|
||||
return terracottaAgreementVersion.get();
|
||||
}
|
||||
|
||||
public IntegerProperty terracottaAgreementVersionProperty() {
|
||||
return terracottaAgreementVersion;
|
||||
}
|
||||
|
||||
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
|
||||
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
|
||||
}
|
||||
|
||||
public int getPlatformPromptVersion() {
|
||||
return platformPromptVersion.get();
|
||||
}
|
||||
|
||||
public IntegerProperty platformPromptVersionProperty() {
|
||||
return platformPromptVersion;
|
||||
}
|
||||
|
||||
public void setPlatformPromptVersion(int platformPromptVersion) {
|
||||
this.platformPromptVersion.set(platformPromptVersion);
|
||||
}
|
||||
|
||||
public int getLogRetention() {
|
||||
return logRetention.get();
|
||||
}
|
||||
|
||||
public IntegerProperty logRetentionProperty() {
|
||||
return logRetention;
|
||||
}
|
||||
|
||||
public void setLogRetention(int logRetention) {
|
||||
this.logRetention.set(logRetention);
|
||||
}
|
||||
|
||||
public boolean isEnableOfflineAccount() {
|
||||
return enableOfflineAccount.get();
|
||||
}
|
||||
|
||||
public BooleanProperty enableOfflineAccountProperty() {
|
||||
return enableOfflineAccount;
|
||||
}
|
||||
|
||||
public void setEnableOfflineAccount(boolean value) {
|
||||
enableOfflineAccount.set(value);
|
||||
}
|
||||
|
||||
public StringProperty fontAntiAliasingProperty() {
|
||||
return fontAntiAliasing;
|
||||
}
|
||||
|
||||
public String getFontAntiAliasing() {
|
||||
return fontAntiAliasing.get();
|
||||
}
|
||||
|
||||
public void setFontAntiAliasing(String value) {
|
||||
this.fontAntiAliasing.set(value);
|
||||
}
|
||||
|
||||
public ObservableSet<String> getUserJava() {
|
||||
return userJava;
|
||||
}
|
||||
|
||||
public ObservableSet<String> getDisabledJava() {
|
||||
return disabledJava;
|
||||
}
|
||||
|
||||
public static final class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
|
||||
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
|
||||
"agreementVersion",
|
||||
"terracottaAgreementVersion",
|
||||
"platformPromptVersion",
|
||||
"logRetention",
|
||||
"userJava",
|
||||
"disabledJava",
|
||||
"enableOfflineAccount",
|
||||
"fontAntiAliasing"
|
||||
));
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(GlobalConfig src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null) {
|
||||
return JsonNull.INSTANCE;
|
||||
}
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
|
||||
jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion()));
|
||||
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
|
||||
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
|
||||
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
|
||||
if (src.enableOfflineAccount.get())
|
||||
jsonObject.addProperty("enableOfflineAccount", true);
|
||||
|
||||
if (!src.getUserJava().isEmpty())
|
||||
jsonObject.add("userJava", context.serialize(src.getUserJava()));
|
||||
|
||||
if (!src.getDisabledJava().isEmpty())
|
||||
jsonObject.add("disabledJava", context.serialize(src.getDisabledJava()));
|
||||
|
||||
for (Map.Entry<String, Object> entry : src.unknownFields.entrySet()) {
|
||||
jsonObject.add(entry.getKey(), context.serialize(entry.getValue()));
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GlobalConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject)) return null;
|
||||
|
||||
JsonObject obj = (JsonObject) json;
|
||||
|
||||
GlobalConfig config = new GlobalConfig();
|
||||
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
|
||||
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
config.setFontAntiAliasing(Optional.ofNullable(obj.get("fontAntiAliasing")).map(JsonElement::getAsString).orElse(null));
|
||||
|
||||
JsonElement userJava = obj.get("userJava");
|
||||
if (userJava != null && userJava.isJsonArray()) {
|
||||
for (JsonElement element : userJava.getAsJsonArray()) {
|
||||
config.userJava.add(element.getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
JsonElement disabledJava = obj.get("disabledJava");
|
||||
if (disabledJava != null && disabledJava.isJsonArray()) {
|
||||
for (JsonElement element : disabledJava.getAsJsonArray()) {
|
||||
config.disabledJava.add(element.getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
|
||||
if (!knownFields.contains(entry.getKey())) {
|
||||
config.unknownFields.put(entry.getKey(), context.deserialize(entry.getValue(), Object.class));
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,27 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Selects how HMCL resolves the Java runtime used to launch a game.
|
||||
///
|
||||
/// Additional payload for these modes is stored in `GameSettings`:
|
||||
/// - [VERSION] uses `customJavaVersion` as the user-entered Java version selector.
|
||||
/// - [DETECTED] uses `detectedJava` as the detected runtime reference.
|
||||
/// - [CUSTOM] uses `customJavaPath` as the executable path selected by the user.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum JavaVersionType {
|
||||
DEFAULT, AUTO, VERSION, DETECTED, CUSTOM
|
||||
/// Automatically selects a suitable Java runtime for the target game version.
|
||||
AUTO,
|
||||
|
||||
/// Selects an installed Java runtime by major version.
|
||||
VERSION,
|
||||
|
||||
/// Selects one runtime from the Java runtimes detected by HMCL.
|
||||
DETECTED,
|
||||
|
||||
/// Uses a user-provided Java executable path.
|
||||
CUSTOM
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Marks a detached settings object that stores its own JSON schema URL.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
interface JsonSchemaSetting {
|
||||
/// Returns the schema used by this JSON settings file.
|
||||
JsonSchema getSchema();
|
||||
|
||||
/// Sets the schema used by this JSON settings file.
|
||||
///
|
||||
/// @param schema the schema to store
|
||||
void setSchema(JsonSchema schema);
|
||||
|
||||
/// Returns whether this settings object may be saved back to its JSON file.
|
||||
boolean isSavable();
|
||||
|
||||
/// Sets whether this settings object may be saved back to its JSON file.
|
||||
///
|
||||
/// @param savable whether this settings object may be saved
|
||||
void setSavable(boolean savable);
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
boolean isBackupOnNextSave();
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
///
|
||||
/// @param backupOnNextSave whether the next successful save should back up the current on-disk file first
|
||||
void setBackupOnNextSave(boolean backupOnNextSave);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Loads, saves, and validates a detached JSON settings file with a [JsonSchema] marker.
|
||||
///
|
||||
/// @param <T> the settings object type
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
|
||||
/// The settings file location.
|
||||
private final Path location;
|
||||
|
||||
/// The human-readable file type name used in logs.
|
||||
private final String displayName;
|
||||
|
||||
/// The settings object type.
|
||||
private final Class<T> type;
|
||||
|
||||
/// The JSON schema supported by the current code.
|
||||
private final JsonSchema expectedSchema;
|
||||
|
||||
/// Creates a default settings object.
|
||||
private final Supplier<T> createDefault;
|
||||
|
||||
/// Creates a detached JSON settings file helper.
|
||||
///
|
||||
/// @param location the settings file location
|
||||
/// @param displayName the human-readable file type name used in logs
|
||||
/// @param type the settings object type
|
||||
/// @param expectedSchema the JSON schema supported by the current code
|
||||
/// @param createDefault creates a default settings object
|
||||
JsonSettingFile(
|
||||
Path location,
|
||||
String displayName,
|
||||
Class<T> type,
|
||||
JsonSchema expectedSchema,
|
||||
Supplier<T> createDefault) {
|
||||
this.location = Objects.requireNonNull(location);
|
||||
this.displayName = Objects.requireNonNull(displayName);
|
||||
this.type = Objects.requireNonNull(type);
|
||||
this.expectedSchema = Objects.requireNonNull(expectedSchema);
|
||||
this.createDefault = Objects.requireNonNull(createDefault);
|
||||
}
|
||||
|
||||
/// Loads the settings file, falling back to migrated data or a default object when absent.
|
||||
///
|
||||
/// @param migrated migrated settings data used when the file is absent
|
||||
/// @return the loaded settings object
|
||||
/// @throws IOException if reading the file fails
|
||||
LoadResult<T> load(@Nullable T migrated) throws IOException {
|
||||
if (Files.exists(location)) {
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(location, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning(displayName + " are empty: " + location);
|
||||
return result(createDefault.get(), true);
|
||||
} else {
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, expectedSchema);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in " + displayName + ": " + location);
|
||||
case INVALID -> LOG.warning("Invalid schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.invalidValue());
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.actual());
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected " + displayName + " schema. Expected: "
|
||||
+ expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported " + displayName
|
||||
+ " schema. Expected: " + expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
T deserialized = LauncherSettings.SETTINGS_GSON.<@Nullable T>fromJson(jsonObject, type);
|
||||
if (deserialized != null) {
|
||||
// Patch-compatible files keep their original schema because unknown members are preserved.
|
||||
if (!schemaResult.preserveSchema() && !expectedSchema.equals(deserialized.getSchema())) {
|
||||
deserialized.setSchema(expectedSchema);
|
||||
}
|
||||
|
||||
return result(deserialized, schemaResult.allowSave()
|
||||
? SettingFileAccess.READ_WRITE
|
||||
: SettingFileAccess.READ_ONLY);
|
||||
}
|
||||
|
||||
LOG.warning(displayName + " deserialized to null: " + location);
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed " + displayName + ".", e);
|
||||
return result(createDefault.get(), true, true);
|
||||
}
|
||||
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
return result(Objects.requireNonNullElseGet(migrated, createDefault), true);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable) {
|
||||
return result(value, savable, false);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable, boolean backupOnNextSave) {
|
||||
value.setSavable(savable);
|
||||
value.setBackupOnNextSave(backupOnNextSave);
|
||||
return new LoadResult<>(value, SettingFileAccess.READ_WRITE);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the access metadata on the settings object.
|
||||
private LoadResult<T> result(T value, SettingFileAccess access) {
|
||||
value.setSavable(access.canSave());
|
||||
value.setBackupOnNextSave(false);
|
||||
return new LoadResult<>(value, access);
|
||||
}
|
||||
|
||||
/// Installs an automatic save listener on a settings object.
|
||||
///
|
||||
/// @param value the settings object to observe
|
||||
void installAutoSave(T value) {
|
||||
value.addListener(source -> save(value));
|
||||
}
|
||||
|
||||
/// 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,683 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
|
||||
import org.jackhuang.hmcl.theme.BuiltinBackground;
|
||||
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.ThemeReference;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/// Stores the current workspace's main launcher settings.
|
||||
///
|
||||
/// This file keeps launcher-level choices such as UI preferences, network settings, selected game directory,
|
||||
/// selected instances, and account selection. Larger domain-specific stores, such as game directories,
|
||||
/// game settings presets, accounts, launcher state, and authlib-injector servers, are persisted in detached
|
||||
/// JSON files managed by [SettingsManager].
|
||||
@JsonAdapter(value = LauncherSettings.Adapter.class)
|
||||
@NotNullByDefault
|
||||
public final class LauncherSettings extends ObservableSetting implements JsonSchemaSetting {
|
||||
|
||||
/// The JSON schema supported by this launcher settings class.
|
||||
public static final JsonSchema CURRENT_SCHEMA = new JsonSchema("launcher-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// The JSON property name for the default game setting preset ID.
|
||||
static final String PROPERTY_DEFAULT_GAME_SETTINGS_PRESET = "defaultGameSettingsPreset";
|
||||
|
||||
/// The JSON property name for the selected game directory ID.
|
||||
static final String PROPERTY_SELECTED_GAME_DIRECTORY = "selectedGameDirectory";
|
||||
|
||||
/// The JSON property name for selected instance IDs keyed by game directory ID.
|
||||
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
|
||||
|
||||
/// Default launcher theme used when no stored theme reference is available.
|
||||
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
|
||||
|
||||
/// Theme appearance override key for theme brightness mode.
|
||||
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
|
||||
|
||||
/// Theme appearance override key for theme color seed.
|
||||
public static final String THEME_APPEARANCE_COLOR = "themeColor";
|
||||
|
||||
/// Theme appearance override key for theme color style.
|
||||
public static final String THEME_APPEARANCE_COLOR_STYLE = "themeColorStyle";
|
||||
|
||||
/// Theme appearance override key for title-bar transparency.
|
||||
public static final String THEME_APPEARANCE_TITLE_BAR_TRANSPARENT = "titleBarTransparent";
|
||||
|
||||
/// Theme appearance override key for window transparency.
|
||||
public static final String THEME_APPEARANCE_WINDOW_TRANSPARENT = "windowTransparent";
|
||||
|
||||
/// Theme appearance override key for the primary background source.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND = "background";
|
||||
|
||||
/// Theme appearance override key for background opacity.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND_OPACITY = "backgroundOpacity";
|
||||
|
||||
/// Gson instance used for launcher settings and related settings objects that depend on JavaFX properties.
|
||||
public static final Gson SETTINGS_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
/// Deserializes launcher settings from JSON.
|
||||
///
|
||||
/// @param json the JSON object to read
|
||||
/// @return the deserialized launcher settings
|
||||
/// @throws JsonParseException if the JSON cannot be deserialized as launcher settings
|
||||
public static LauncherSettings fromJson(JsonObject json) throws JsonParseException {
|
||||
return SETTINGS_GSON.fromJson(json, LauncherSettings.class);
|
||||
}
|
||||
|
||||
/// Creates empty launcher settings using current defaults.
|
||||
public LauncherSettings() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Serializes these launcher settings to formatted JSON.
|
||||
public String toJson() {
|
||||
return SETTINGS_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
/// The schema used by this launcher settings file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher settings file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher settings file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
private transient boolean backupOnNextSave = false;
|
||||
|
||||
/// Returns whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The launcher UI language.
|
||||
@SerializedName("language")
|
||||
private final ObjectProperty<SupportedLocale> language = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
/// Returns the launcher UI language property.
|
||||
public ObjectProperty<SupportedLocale> languageProperty() {
|
||||
return language;
|
||||
}
|
||||
|
||||
/// Whether preview builds are accepted by update checks.
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the preview update opt-in property.
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
/// Whether automatic update dialogs are disabled.
|
||||
@SerializedName("disableAutoShowUpdateDialog")
|
||||
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the automatic update dialog disable property.
|
||||
public BooleanProperty disableAutoShowUpdateDialogProperty() {
|
||||
return disableAutoShowUpdateDialog;
|
||||
}
|
||||
|
||||
/// Whether April Fools features are disabled.
|
||||
@SerializedName("disableAprilFools")
|
||||
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the April Fools disable property.
|
||||
public BooleanProperty disableAprilFoolsProperty() {
|
||||
return disableAprilFools;
|
||||
}
|
||||
|
||||
/// The common Minecraft directory selection mode.
|
||||
@SerializedName("commonDirectoryType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
/// Returns the common Minecraft directory selection mode property.
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirectoryTypeProperty() {
|
||||
return commonDirectoryType;
|
||||
}
|
||||
|
||||
/// The custom common Minecraft directory path.
|
||||
@SerializedName("commonDirectory")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty();
|
||||
|
||||
/// Returns the custom common Minecraft directory property.
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
/// Returns the default common Minecraft directory path.
|
||||
public static String getDefaultCommonDirectory() {
|
||||
return Metadata.MINECRAFT_DIRECTORY.toString();
|
||||
}
|
||||
|
||||
/// Resolves the effective common Minecraft directory from the current directory settings.
|
||||
///
|
||||
/// @return the effective directory path, or `null` when the configured mode is not recognized
|
||||
public String getResolvedCommonDirectory() {
|
||||
EnumCommonDirectory type = commonDirectoryType.get();
|
||||
String customPath = commonDirectory.get();
|
||||
|
||||
return type == EnumCommonDirectory.CUSTOM && StringUtils.isNotBlank(customPath)
|
||||
? customPath
|
||||
: getDefaultCommonDirectory();
|
||||
}
|
||||
|
||||
/// The maximum number of log lines kept in log views.
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<@Nullable Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the log line limit property.
|
||||
public ObjectProperty<@Nullable Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
// Theme selection
|
||||
|
||||
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
|
||||
@SerializedName("selectedTheme")
|
||||
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
|
||||
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
|
||||
|
||||
/// Returns the selected installed theme property.
|
||||
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
/// Returns the selected installed theme, falling back to the built-in default theme.
|
||||
public ThemeReference getSelectedThemeOrDefault() {
|
||||
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
|
||||
}
|
||||
|
||||
// Theme appearance overrides
|
||||
|
||||
/// Theme appearance setting keys overridden by the launcher.
|
||||
@SerializedName("themeAppearanceOverrides")
|
||||
private final ObservableSet<String> themeAppearanceOverrides = FXCollections.observableSet();
|
||||
|
||||
/// Returns the theme appearance setting keys overridden by the launcher.
|
||||
public ObservableSet<String> getThemeAppearanceOverrides() {
|
||||
return themeAppearanceOverrides;
|
||||
}
|
||||
|
||||
// Theme appearance values
|
||||
|
||||
/// The configured theme brightness mode identifier.
|
||||
@SerializedName("themeBrightnessMode")
|
||||
private final StringProperty themeBrightnessMode = new SimpleStringProperty("auto");
|
||||
|
||||
/// Returns the theme brightness mode property.
|
||||
public StringProperty themeBrightnessModeProperty() {
|
||||
return themeBrightnessMode;
|
||||
}
|
||||
|
||||
/// The custom launcher theme color preserved when dynamic color extraction is used.
|
||||
@SerializedName("customThemeColor")
|
||||
private final ObjectProperty<ThemeColor> customThemeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
/// Returns the custom launcher theme color property.
|
||||
public ObjectProperty<ThemeColor> customThemeColorProperty() {
|
||||
return customThemeColor;
|
||||
}
|
||||
|
||||
/// The source used to choose the launcher Monet theme color seed.
|
||||
@SerializedName("themeColorType")
|
||||
private final ObjectProperty<ThemeColorType> themeColorType = new RawPreservingObjectProperty<>(ThemeColorType.DEFAULT);
|
||||
|
||||
/// Returns the launcher theme color source type property.
|
||||
public ObjectProperty<ThemeColorType> themeColorTypeProperty() {
|
||||
return themeColorType;
|
||||
}
|
||||
|
||||
/// The MonetFX color style used to generate the launcher color scheme.
|
||||
@SerializedName("themeColorStyle")
|
||||
private final ObjectProperty<ColorStyle> themeColorStyle = new RawPreservingObjectProperty<>(ColorStyle.FIDELITY);
|
||||
|
||||
/// Returns the launcher theme color style property.
|
||||
public ObjectProperty<ColorStyle> themeColorStyleProperty() {
|
||||
return themeColorStyle;
|
||||
}
|
||||
|
||||
/// Whether the launcher title bar is transparent.
|
||||
@SerializedName("titleBarTransparent")
|
||||
private final BooleanProperty titleBarTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the transparent title-bar property.
|
||||
public BooleanProperty titleBarTransparentProperty() {
|
||||
return titleBarTransparent;
|
||||
}
|
||||
|
||||
/// Whether translucent launcher backgrounds reveal content behind the window.
|
||||
@SerializedName("windowTransparent")
|
||||
private final BooleanProperty windowTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the window transparency property.
|
||||
public BooleanProperty windowTransparentProperty() {
|
||||
return windowTransparent;
|
||||
}
|
||||
|
||||
// Background source
|
||||
|
||||
/// The launcher background source type.
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<BackgroundType> backgroundType = new RawPreservingObjectProperty<>(BackgroundType.DEFAULT);
|
||||
|
||||
/// Returns the launcher background source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundTypeProperty() {
|
||||
return backgroundType;
|
||||
}
|
||||
|
||||
/// The selected built-in launcher wallpaper ID.
|
||||
@SerializedName("builtinBackgroundId")
|
||||
private final StringProperty builtinBackgroundId = new SimpleStringProperty(BuiltinBackground.FALLBACK.id());
|
||||
|
||||
/// Returns the selected built-in launcher wallpaper ID property.
|
||||
public StringProperty builtinBackgroundIdProperty() {
|
||||
return builtinBackgroundId;
|
||||
}
|
||||
|
||||
/// The local custom launcher background image path.
|
||||
@SerializedName("customBackgroundImagePath")
|
||||
private final StringProperty customBackgroundImagePath = new SimpleStringProperty();
|
||||
|
||||
/// Returns the local custom launcher background image path property.
|
||||
public StringProperty customBackgroundImagePathProperty() {
|
||||
return customBackgroundImagePath;
|
||||
}
|
||||
|
||||
/// The remote network launcher background image URL.
|
||||
@SerializedName("networkBackgroundImageUrl")
|
||||
private final StringProperty networkBackgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
/// Returns the remote network launcher background image URL property.
|
||||
public StringProperty networkBackgroundImageUrlProperty() {
|
||||
return networkBackgroundImageUrl;
|
||||
}
|
||||
|
||||
/// The custom launcher background paint.
|
||||
@SerializedName("customBackgroundPaint")
|
||||
private final ObjectProperty<@Nullable Paint> customBackgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the custom launcher background paint property.
|
||||
public ObjectProperty<@Nullable Paint> customBackgroundPaintProperty() {
|
||||
return customBackgroundPaint;
|
||||
}
|
||||
|
||||
// Background appearance
|
||||
|
||||
/// The launcher background opacity value.
|
||||
@SerializedName("backgroundOpacity")
|
||||
private final DoubleProperty backgroundOpacity = new SimpleDoubleProperty(1.0);
|
||||
|
||||
/// Returns the custom launcher background opacity value property.
|
||||
public DoubleProperty backgroundOpacityProperty() {
|
||||
return backgroundOpacity;
|
||||
}
|
||||
|
||||
// Background loading
|
||||
|
||||
/// The URL image cache policy for network launcher backgrounds.
|
||||
@SerializedName("networkBackgroundImageCachePolicy")
|
||||
private final ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicy =
|
||||
new RawPreservingObjectProperty<>(NetworkBackgroundImageCachePolicy.ENABLED);
|
||||
|
||||
/// Returns the URL image cache policy for network launcher backgrounds.
|
||||
public ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicyProperty() {
|
||||
return networkBackgroundImageCachePolicy;
|
||||
}
|
||||
|
||||
/// The fallback source used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackType")
|
||||
private final ObjectProperty<BackgroundType> backgroundFallbackType =
|
||||
new RawPreservingObjectProperty<>(BackgroundType.BUILTIN);
|
||||
|
||||
/// Returns the launcher background fallback source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundFallbackTypeProperty() {
|
||||
return backgroundFallbackType;
|
||||
}
|
||||
|
||||
/// The fallback paint used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackPaint")
|
||||
private final ObjectProperty<Paint> backgroundFallbackPaint = new SimpleObjectProperty<>(Color.WHITE);
|
||||
|
||||
/// Returns the launcher background fallback paint property.
|
||||
public ObjectProperty<Paint> backgroundFallbackPaintProperty() {
|
||||
return backgroundFallbackPaint;
|
||||
}
|
||||
|
||||
/// How the launcher displays its window while the selected background is loading.
|
||||
@SerializedName("backgroundLoadPolicy")
|
||||
private final ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicy =
|
||||
new RawPreservingObjectProperty<>(BackgroundLoadPolicy.WAIT_FOR_BACKGROUND);
|
||||
|
||||
/// Returns the launcher background loading policy property.
|
||||
public ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicyProperty() {
|
||||
return backgroundLoadPolicy;
|
||||
}
|
||||
|
||||
// Fonts
|
||||
|
||||
/// The font family used by launcher log views.
|
||||
@SerializedName("logFontFamily")
|
||||
private final StringProperty logFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher log font family property.
|
||||
public StringProperty logFontFamilyProperty() {
|
||||
return logFontFamily;
|
||||
}
|
||||
|
||||
/// The launcher log font size.
|
||||
@SerializedName("logFontSize")
|
||||
private final DoubleProperty logFontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
/// Returns the launcher log font size property.
|
||||
public DoubleProperty logFontSizeProperty() {
|
||||
return logFontSize;
|
||||
}
|
||||
|
||||
/// The font family used by launcher chrome.
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher chrome font family property.
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
// General UI
|
||||
|
||||
/// Whether UI animations are disabled.
|
||||
@SerializedName("animationDisabled")
|
||||
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
/// Returns the UI animation disable property.
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
/// Whether HMCL automatically selects the number of download threads.
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
/// Returns the automatic download thread count property.
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
/// The configured number of download threads.
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
/// Returns the download thread count property.
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
/// The selected game version list download source.
|
||||
@SerializedName("versionListSource")
|
||||
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected game version list download source property.
|
||||
public ObjectProperty<DownloadSource> versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
/// The selected file download source.
|
||||
@SerializedName("fileDownloadSource")
|
||||
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected file download source property.
|
||||
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
|
||||
return fileDownloadSource;
|
||||
}
|
||||
|
||||
/// The selected default add-on source ID.
|
||||
@SerializedName("defaultAddonSource")
|
||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
||||
|
||||
/// Returns the selected default add-on source ID property.
|
||||
public StringProperty defaultAddonSourceProperty() {
|
||||
return defaultAddonSource;
|
||||
}
|
||||
|
||||
/// Whether proxy authentication is enabled.
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
/// Returns the proxy authentication enable property.
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
/// The configured network proxy selection mode.
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<ProxyType> proxyType = new SimpleObjectProperty<>(ProxyType.SYSTEM);
|
||||
|
||||
/// Returns the network proxy selection mode property.
|
||||
public ObjectProperty<ProxyType> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
/// The configured network proxy host.
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
/// Returns the network proxy host property.
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
/// The configured network proxy port.
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the network proxy port property.
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication username.
|
||||
@SerializedName("proxyUser")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication username property.
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication password.
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPassword = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication password property.
|
||||
public StringProperty proxyPasswordProperty() {
|
||||
return proxyPassword;
|
||||
}
|
||||
|
||||
/// The selected game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_GAME_DIRECTORY)
|
||||
private final ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectory =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_SELECTED_GAME_DIRECTORY);
|
||||
|
||||
/// Returns the selected game directory ID property.
|
||||
///
|
||||
/// This property is exposed for persistence and [GameDirectoryManager] integration. Code outside [GameDirectoryManager]
|
||||
/// should use `GameDirectoryManager.setSelectedGameDirectory` instead of modifying this property directly.
|
||||
public ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// The default game setting preset ID.
|
||||
@SerializedName(PROPERTY_DEFAULT_GAME_SETTINGS_PRESET)
|
||||
private final ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPreset =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_DEFAULT_GAME_SETTINGS_PRESET);
|
||||
|
||||
/// Returns the default game setting preset ID property.
|
||||
public ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPresetProperty() {
|
||||
return defaultGameSettingsPreset;
|
||||
}
|
||||
|
||||
/// Selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_INSTANCE)
|
||||
private final ObservableMap<GameDirectoryID, String> selectedInstance = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// The map stores persisted selected instance values by game directory ID.
|
||||
public ObservableMap<GameDirectoryID, String> getSelectedInstance() {
|
||||
return selectedInstance;
|
||||
}
|
||||
|
||||
/// Returns the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// The value is loaded by the game repository for the matching game directory.
|
||||
public @Nullable String getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
|
||||
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
|
||||
}
|
||||
|
||||
/// Sets the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// Blank values remove the persisted selected instance entry.
|
||||
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable String selectedInstance) {
|
||||
if (gameDirectoryId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(selectedInstance)) {
|
||||
this.selectedInstance.remove(gameDirectoryId);
|
||||
} else {
|
||||
this.selectedInstance.put(gameDirectoryId, selectedInstance);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,218 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace launcher runtime state independently from the main settings file.
|
||||
///
|
||||
/// The JSON representation is saved as `state/launcher-state.json` under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(LauncherState.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class LauncherState extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this launcher state store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("launcher-state", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty launcher state store.
|
||||
public LauncherState() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this launcher state file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher state file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher state file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The normalized launcher window content X position.
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content X position property.
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content X position.
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content X position.
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
/// The normalized launcher window content Y position.
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content Y position property.
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content Y position.
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content Y position.
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
/// The launcher window content width.
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content width property.
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content width.
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content width.
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
/// The launcher window content height.
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content height property.
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content height.
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content height.
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
/// The latest update version that has already prompted the user.
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
/// Returns the latest prompted update version.
|
||||
public @Nullable String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
/// Returns the latest prompted update version property.
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
/// Sets the latest prompted update version.
|
||||
public void setPromptedVersion(@Nullable String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
/// Tip markers that prevent repeated prompts.
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns tip markers that prevent repeated prompts.
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
/// JSON adapter for [LauncherState].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<LauncherState> {
|
||||
/// Creates an empty launcher state store for deserialization.
|
||||
@Override
|
||||
protected LauncherState createInstance() {
|
||||
return new LauncherState();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 740 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user