Compare commits
414
Commits
v3.13.1
...
v3.17.0.357
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5594d8c1 | ||
|
|
13ad50208f | ||
|
|
13910c24b7 | ||
|
|
cc9bdb62ca | ||
|
|
baa7c816b7 | ||
|
|
090f0b9822 | ||
|
|
6d9ae24912 | ||
|
|
d03851b196 | ||
|
|
97cf9dcf16 | ||
|
|
7bacc2bb5a | ||
|
|
c7637079d7 | ||
|
|
8f3282e9a2 | ||
|
|
8e491862b9 | ||
|
|
e0c276075c | ||
|
|
19ea64e14b | ||
|
|
5b26b8f4eb | ||
|
|
ed7ef92ad9 | ||
|
|
cd95891108 | ||
|
|
df52bc6e81 | ||
|
|
9d9fc78384 | ||
|
|
e287f672fd | ||
|
|
24702dc5a0 | ||
|
|
886a25a935 | ||
|
|
450504ceda | ||
|
|
8767cc0e98 | ||
|
|
7331f619cf | ||
|
|
7a8f931641 | ||
|
|
ce51dc4dd7 | ||
|
|
b97e7fbd55 | ||
|
|
7f578be08e | ||
|
|
513f53bc06 | ||
|
|
b2d4d36856 | ||
|
|
9c031c5ac1 | ||
|
|
e2d3ac847d | ||
|
|
0f4152406c | ||
|
|
1a258a2554 | ||
|
|
3742be0aa8 | ||
|
|
0e34554344 | ||
|
|
1acfc55004 | ||
|
|
1428183e36 | ||
|
|
cbc6daf3d5 | ||
|
|
0ad180c082 | ||
|
|
1da8c8d6ac | ||
|
|
ea232b0976 | ||
|
|
b4549b6f68 | ||
|
|
4cf5dbdf66 | ||
|
|
b179c44bec | ||
|
|
7dfc654d5d | ||
|
|
a4b930ded5 | ||
|
|
00cd20a6e9 | ||
|
|
9ebf29d060 | ||
|
|
d7d5e99185 | ||
|
|
5e9ed84605 | ||
|
|
2e01874e95 | ||
|
|
efb475879b | ||
|
|
cb69387151 | ||
|
|
70104cb9e5 | ||
|
|
931fbf5181 | ||
|
|
1d5ecb9236 | ||
|
|
b65104e38b | ||
|
|
8629a2bbe3 | ||
|
|
3142b2f894 | ||
|
|
b1759acb62 | ||
|
|
1363f0e70d | ||
|
|
45c4913b82 | ||
|
|
0d47ff95e1 | ||
|
|
4064c5c188 | ||
|
|
56a2f75c8b | ||
|
|
c4ebdeb77e | ||
|
|
13b87fff89 | ||
|
|
50072799d0 | ||
|
|
16d45890ad | ||
|
|
e0dacd0265 | ||
|
|
72071a41c9 | ||
|
|
c86c19a7f9 | ||
|
|
5475acdc9e | ||
|
|
6daeeb0b04 | ||
|
|
1a4ccd355d | ||
|
|
204a08342d | ||
|
|
ced06cb48c | ||
|
|
80b3883d4f | ||
|
|
60c7ea5a01 | ||
|
|
3323c6a50a | ||
|
|
7530f08ab1 | ||
|
|
15a983de28 | ||
|
|
5c0d4a7c7b | ||
|
|
1532247f2a | ||
|
|
587e92543a | ||
|
|
f0527e12b4 | ||
|
|
b0ac4fb5d0 | ||
|
|
083dbb18ad | ||
|
|
c9585198e1 | ||
|
|
0fd83e5177 | ||
|
|
5a017ecab6 | ||
|
|
da77f52492 | ||
|
|
dccfa7c9a9 | ||
|
|
f7bf2d468d | ||
|
|
a10c501d4f | ||
|
|
116629743e | ||
|
|
cfee31d339 | ||
|
|
3ac08d7f62 | ||
|
|
de643becf8 | ||
|
|
edbcc1f343 | ||
|
|
91fcc4e5ef | ||
|
|
34aeba1b1a | ||
|
|
9a36b86cfb | ||
|
|
71b4c02474 | ||
|
|
45f49d079b | ||
|
|
71d9daed16 | ||
|
|
5ae019ba17 | ||
|
|
770c58c2e7 | ||
|
|
a03c11be42 | ||
|
|
ca3746319f | ||
|
|
56629892f8 | ||
|
|
5238a361cd | ||
|
|
0323a7f321 | ||
|
|
a3c5e8e412 | ||
|
|
5c364f624a | ||
|
|
22275f2c57 | ||
|
|
11e54d199c | ||
|
|
cf21cf4a3c | ||
|
|
a0d1fa8f38 | ||
|
|
78b8d37891 | ||
|
|
0807c04572 | ||
|
|
8eec18450e | ||
|
|
15d4874366 | ||
|
|
c149401cf3 | ||
|
|
ff459a5bc4 | ||
|
|
5796dad18c | ||
|
|
0443ff7cd1 | ||
|
|
4c6fe865f5 | ||
|
|
1e54482f3b | ||
|
|
c329f0d2e8 | ||
|
|
af4d9feea5 | ||
|
|
af71c71663 | ||
|
|
4675442ecd | ||
|
|
c8929c80d9 | ||
|
|
81634b14df | ||
|
|
ccca396d91 | ||
|
|
5a14449b44 | ||
|
|
612998a98f | ||
|
|
a50e327f70 | ||
|
|
7aeb1642b1 | ||
|
|
5fc1536d6a | ||
|
|
463cf2ff0f | ||
|
|
144e822a85 | ||
|
|
87789beeb7 | ||
|
|
bb86100c54 | ||
|
|
2ceca9d8cc | ||
|
|
d5fc365a96 | ||
|
|
d5c86a8d88 | ||
|
|
bd6beb1369 | ||
|
|
b1719e77bb | ||
|
|
77f0a90f10 | ||
|
|
ff6110ba89 | ||
|
|
e7e964b3dd | ||
|
|
4e29c29a7a | ||
|
|
25ad1dbf44 | ||
|
|
d2e7e0d626 | ||
|
|
c68bbf2904 | ||
|
|
d0fc46f930 | ||
|
|
f1b861eb21 | ||
|
|
5cde4ce757 | ||
|
|
d297417909 | ||
|
|
2854eac27d | ||
|
|
c9fd7c6a1c | ||
|
|
0e0517ab3c | ||
|
|
7a4bea10d0 | ||
|
|
b87d40ffcc | ||
|
|
1277bf8b5d | ||
|
|
c2e807055a | ||
|
|
9021ec1960 | ||
|
|
82a47e9e2d | ||
|
|
4841204fa0 | ||
|
|
897f5fe11b | ||
|
|
4657e3273e | ||
|
|
4edea5f5b9 | ||
|
|
36bd7d4491 | ||
|
|
3aa8603bd1 | ||
|
|
ca7a61e20a | ||
|
|
21afc81cf8 | ||
|
|
94a0e26be5 | ||
|
|
14e8d2d0b5 | ||
|
|
49c408cb23 | ||
|
|
b81c3398ac | ||
|
|
2855df3693 | ||
|
|
8184dc6cee | ||
|
|
df21559c0e | ||
|
|
73ff3081f8 | ||
|
|
fe05bb58a4 | ||
|
|
4c9cd950e4 | ||
|
|
646d9a09a5 | ||
|
|
255fe75640 | ||
|
|
de15dd8e98 | ||
|
|
886319a6c7 | ||
|
|
3320f6c341 | ||
|
|
b85be78166 | ||
|
|
a34a7439d7 | ||
|
|
591fd51600 | ||
|
|
c1407a9620 | ||
|
|
e0a91579f2 | ||
|
|
b68f404c3c | ||
|
|
c1f50f9ba3 | ||
|
|
d8bb2131f2 | ||
|
|
aca761385b | ||
|
|
15faccf376 | ||
|
|
241c9c419c | ||
|
|
4b36f8ce03 | ||
|
|
5c0baad782 | ||
|
|
850a56405c | ||
|
|
e8487c467b | ||
|
|
9866d88a16 | ||
|
|
af2d46ae89 | ||
|
|
6f7c561ecb | ||
|
|
93abe31acf | ||
|
|
a6f8659349 | ||
|
|
8728c89093 | ||
|
|
2dccd8099d | ||
|
|
d8a03cfed8 | ||
|
|
26a874a030 | ||
|
|
7c5c3e2fb4 | ||
|
|
62d7b36248 | ||
|
|
dec6837528 | ||
|
|
3484792e81 | ||
|
|
2b310e035d | ||
|
|
6e960612dd | ||
|
|
73a776b021 | ||
|
|
31a2f49ed2 | ||
|
|
ab268cfe48 | ||
|
|
721aeccfc0 | ||
|
|
e4348f727a | ||
|
|
dfc96f10da | ||
|
|
a5c7fe08d8 | ||
|
|
a6e0acca45 | ||
|
|
3d312f9247 | ||
|
|
18dc5b69c0 | ||
|
|
98af2c85b6 | ||
|
|
c4c0b1a1ce | ||
|
|
5e621ce340 | ||
|
|
1d1c8dfdc9 | ||
|
|
9b68b8825d | ||
|
|
5c17783223 | ||
|
|
a921be45f1 | ||
|
|
2dd1c0f85e | ||
|
|
7ba229b3e5 | ||
|
|
59a299630b | ||
|
|
b41b89e233 | ||
|
|
af137e5ff1 | ||
|
|
74ddbf5623 | ||
|
|
accef4a93f | ||
|
|
9c3a955fad | ||
|
|
1c0e506d34 | ||
|
|
6374ec0de7 | ||
|
|
a6b3872af4 | ||
|
|
ad98a5a6d4 | ||
|
|
05393af537 | ||
|
|
3438da2ab9 | ||
|
|
3163b465ec | ||
|
|
573dac471c | ||
|
|
12f54f353a | ||
|
|
246699d571 | ||
|
|
3480e6974b | ||
|
|
6d14351833 | ||
|
|
fd0d83257d | ||
|
|
0eb1d3cf4b | ||
|
|
f9b8dad3d2 | ||
|
|
57fbd0910e | ||
|
|
7767a57f50 | ||
|
|
f024dba940 | ||
|
|
52736d3c86 | ||
|
|
f75b472068 | ||
|
|
d8e0c9e9c7 | ||
|
|
9601c1c1ea | ||
|
|
d5c7f9bef3 | ||
|
|
1714ffd260 | ||
|
|
fa06be865c | ||
|
|
9dc69dbc2b | ||
|
|
43b6fdb5ee | ||
|
|
e6598f23de | ||
|
|
0f51934e1c | ||
|
|
122d474eeb | ||
|
|
6223ceedff | ||
|
|
823b74b7e5 | ||
|
|
31642f9185 | ||
|
|
c8f66a457d | ||
|
|
65c76c53d5 | ||
|
|
7580c6da82 | ||
|
|
d8938e1fcb | ||
|
|
c0f699b90d | ||
|
|
3bc8d441a1 | ||
|
|
6c7681d015 | ||
|
|
24c36c8f20 | ||
|
|
c023242afc | ||
|
|
194ed4c255 | ||
|
|
28022461f7 | ||
|
|
55316c18bc | ||
|
|
beb6882921 | ||
|
|
2ba56a1137 | ||
|
|
a127254341 | ||
|
|
6086e6ba6e | ||
|
|
4baf983514 | ||
|
|
4dc8e2a6ff | ||
|
|
cf9dcf04c3 | ||
|
|
677c29e232 | ||
|
|
1d35f49af8 | ||
|
|
9e2f1d3151 | ||
|
|
8f9f800c51 | ||
|
|
1f8ba73bb1 | ||
|
|
415fcc1ac7 | ||
|
|
da3cf3056b | ||
|
|
d81d88d3f9 | ||
|
|
d5dee7146c | ||
|
|
436f664a1c | ||
|
|
0e04566b6c | ||
|
|
f9e9939b1f | ||
|
|
5d1e6fdc25 | ||
|
|
8011eee586 | ||
|
|
90eb622903 | ||
|
|
f0fcc4ac5e | ||
|
|
d35d1fc81e | ||
|
|
e9a9efd0cc | ||
|
|
30acc53ee8 | ||
|
|
f7b1c7cd8c | ||
|
|
506f1c1d60 | ||
|
|
ca2f8a5cda | ||
|
|
1f48b10eb9 | ||
|
|
40d21bbe24 | ||
|
|
d0be7efca3 | ||
|
|
3bed03c535 | ||
|
|
d295e606a9 | ||
|
|
36afe32146 | ||
|
|
87dbbdd96f | ||
|
|
9b8e67b1b3 | ||
|
|
f5a5e6a6da | ||
|
|
f0466c1d8e | ||
|
|
903ecbb127 | ||
|
|
4c6f3aeb99 | ||
|
|
d6a4b3480f | ||
|
|
5e84e5bcd6 | ||
|
|
6ce7af05b2 | ||
|
|
0d62a12353 | ||
|
|
880aab0ba5 | ||
|
|
0b64e89aaa | ||
|
|
7b6379674b | ||
|
|
55f4b0d8fc | ||
|
|
863c3c9c7f | ||
|
|
1f5fb983b0 | ||
|
|
856d5ec5af | ||
|
|
20a5670876 | ||
|
|
bdf6a58be8 | ||
|
|
7c74dd438f | ||
|
|
5803235407 | ||
|
|
afbf567fef | ||
|
|
72b3c1981a | ||
|
|
72ee82543b | ||
|
|
996c9f4c86 | ||
|
|
f8c1e97376 | ||
|
|
f146040ff3 | ||
|
|
0da28173ab | ||
|
|
be822666a7 | ||
|
|
8c99ee3b0d | ||
|
|
0e3b4a7853 | ||
|
|
615b1dbaae | ||
|
|
0fa51f830d | ||
|
|
007bb345d0 | ||
|
|
46536f11d3 | ||
|
|
b1232b5dc8 | ||
|
|
f8f047d67e | ||
|
|
86529c6f15 | ||
|
|
d63829c344 | ||
|
|
34359a7e86 | ||
|
|
aa335d8365 | ||
|
|
a565d3b7ce | ||
|
|
3bff3c7f23 | ||
|
|
4e6e832e9c | ||
|
|
d6648a840e | ||
|
|
f8c2af716f | ||
|
|
bd812ccb67 | ||
|
|
dad190f7e6 | ||
|
|
1b1ba64a38 | ||
|
|
e31268c39d | ||
|
|
cb9d32dc91 | ||
|
|
36568df5b1 | ||
|
|
b4ac42d789 | ||
|
|
32aafb11a3 | ||
|
|
36dab70b7a | ||
|
|
ebf253b1a7 | ||
|
|
539dcca5ca | ||
|
|
659b6c2a70 | ||
|
|
3dcc7ab311 | ||
|
|
e0ccb58272 | ||
|
|
d4ec8ebec8 | ||
|
|
f3b21e5099 | ||
|
|
202fd38180 | ||
|
|
f5768f2f95 | ||
|
|
2d377d06de | ||
|
|
42d1581ba0 | ||
|
|
995a23c2bc | ||
|
|
8969f55a1f | ||
|
|
312a285792 | ||
|
|
d85b052195 | ||
|
|
cd0d3d1b97 | ||
|
|
6ccc7df998 | ||
|
|
73e5909a69 | ||
|
|
ade487277d | ||
|
|
e755d17977 | ||
|
|
292288de9e | ||
|
|
57f6dec462 | ||
|
|
8e1a549c9d | ||
|
|
1d18eae377 | ||
|
|
6ef04d2fa5 | ||
|
|
1778e9d916 | ||
|
|
dbb63e9faa | ||
|
|
6b1c3182eb |
No files matched your search
@@ -1,3 +0,0 @@
|
||||
code_review:
|
||||
pull_request_opened:
|
||||
code_review: false
|
||||
@@ -0,0 +1,66 @@
|
||||
# Default: normalize all text files to LF in the repository and on checkout.
|
||||
# Git auto-detects binary files and leaves them untouched.
|
||||
* text=auto eol=lf
|
||||
|
||||
# ─── Explicitly text – source & build ────────────────────────────────────────
|
||||
*.java text eol=lf
|
||||
*.js text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.groovy text eol=lf
|
||||
*.gradle text eol=lf
|
||||
|
||||
# ─── Configuration & data ─────────────────────────────────────────────────────
|
||||
*.properties text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.MF text eol=lf
|
||||
|
||||
# Gradle dependency-lock files
|
||||
*.lockfile text eol=lf
|
||||
|
||||
# ─── Scripts ──────────────────────────────────────────────────────────────────
|
||||
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
|
||||
*.sh text eol=lf
|
||||
gradlew text eol=lf
|
||||
|
||||
# CI pipeline scripts
|
||||
Jenkinsfile text eol=lf
|
||||
|
||||
# Windows batch files must keep CRLF so cmd.exe can parse them.
|
||||
*.bat text eol=crlf
|
||||
|
||||
# ─── Binary files ─────────────────────────────────────────────────────────────
|
||||
# Raster / animated images
|
||||
*.png binary
|
||||
*.apng binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
|
||||
# Archives & compiled artifacts
|
||||
*.jar binary
|
||||
*.zip binary
|
||||
*.class binary
|
||||
|
||||
# Cryptographic / certificate files
|
||||
*.der binary
|
||||
|
||||
# Minecraft schematic
|
||||
*.litematic binary
|
||||
|
||||
# Compiled / intermediate build artefacts
|
||||
*.bin binary
|
||||
*.probe binary
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
const fs = require("fs");
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const prNumberPath = process.env.PULL_REQUEST_NUMBER_PATH;
|
||||
if (!prNumberPath || !fs.existsSync(prNumberPath) || !fs.statSync(prNumberPath).isFile()) {
|
||||
core.setFailed("Pull request number file not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reportsPath = process.env.REPORTS_PATH;
|
||||
if (!reportsPath || !fs.existsSync(reportsPath) || !fs.statSync(reportsPath).isFile()) {
|
||||
core.setFailed("Reports file not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const PULL_REQUEST_NUMBER = parseInt(fs.readFileSync(prNumberPath, "utf8").trim(), 10);
|
||||
if (isNaN(PULL_REQUEST_NUMBER)) {
|
||||
core.setFailed("Failed to parse the pull request number.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let pr;
|
||||
try {
|
||||
const response = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: PULL_REQUEST_NUMBER,
|
||||
});
|
||||
pr = response.data;
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to fetch PR #${PULL_REQUEST_NUMBER}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Current PR state: ${pr.state}`);
|
||||
if (pr.state !== "open") {
|
||||
core.setFailed("The pull request is not open. Skipping comment creation.");
|
||||
return;
|
||||
}
|
||||
|
||||
const run = context.payload.workflow_run;
|
||||
if (!run) {
|
||||
core.setFailed("context.payload.workflow_run is undefined. Ensure this script runs on the 'workflow_run' event.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.sha !== run.head_sha) {
|
||||
core.setFailed("PR head SHA does not match the workflow run head SHA. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.ref !== run.head_branch) {
|
||||
core.setFailed("PR head branch does not match the workflow run head branch. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.head.repo.full_name !== run.head_repository.full_name) {
|
||||
core.setFailed("PR head repository fullname does not match the workflow run head repository fullname. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = [];
|
||||
const maxCommentCount = 10;
|
||||
const reports = fs.readFileSync(reportsPath, "utf8").trim().split("\n").filter(Boolean);
|
||||
for (const report of reports) {
|
||||
if (comments.length >= maxCommentCount) break;
|
||||
try {
|
||||
const data = JSON.parse(report);
|
||||
if (typeof data !== "object" || Array.isArray(data)) continue;
|
||||
|
||||
const { location, message, severity, code } = data;
|
||||
if (typeof location !== "object" || Array.isArray(location)) continue;
|
||||
if (typeof message !== "string") continue;
|
||||
const severities = ["UNKNOWN_SEVERITY", "ERROR", "WARNING", "INFO", 0, 1, 2, 3];
|
||||
if (!severities.includes(severity)) continue;
|
||||
const severityText = typeof severity === "string" ? severity : severities[severity];
|
||||
if (typeof code !== "object" || Array.isArray(code)) continue;
|
||||
|
||||
const path = location.path;
|
||||
const line = location.range?.start?.line;
|
||||
const codeText = code.value ? `[${code.value}] ` : "";
|
||||
|
||||
if (path && line) {
|
||||
comments.push({
|
||||
path,
|
||||
line,
|
||||
side: "RIGHT",
|
||||
body: `**[${severityText}]** ${codeText}\n\n${message}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to parse report: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
core.info("No diagnostics found to report as comments.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Successfully prepared ${comments.length} comment${comments.length > 1 ? "s" : ""} to post.`);
|
||||
|
||||
try {
|
||||
await github.rest.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: PULL_REQUEST_NUMBER,
|
||||
commit_id: run.head_sha,
|
||||
body: "🤖 Static analysis found the following issues (max count 10):",
|
||||
event: "COMMENT",
|
||||
comments: comments
|
||||
});
|
||||
core.info("Review comments successfully posted to the pull request.");
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to post review comments to GitHub: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const ignoredFilePatterns = [
|
||||
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.github\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.idea\/.+\.xml$/i,
|
||||
/(?:^|\/)\.editorconfig$/i,
|
||||
/(?:^|\/)\.gitattributes$/i,
|
||||
/(?:^|\/)\.gitignore$/i,
|
||||
/(?:^|\/).+\.kts$/i,
|
||||
/(?:^|\/).+\.md$/i,
|
||||
/(?:^|\/)LICENSE(?:_.*)?$/i,
|
||||
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
|
||||
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
|
||||
/(?:^|\/)lang\/.+\.properties$/i,
|
||||
/(?:^|\/)l10n\/.+\.properties$/i,
|
||||
/(?:^|\/)checkstyle\.xml$/i,
|
||||
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
|
||||
];
|
||||
|
||||
async function processPR(issueNumber) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: issueNumber, per_page: 100,
|
||||
});
|
||||
|
||||
const countedFiles = [];
|
||||
const ignoredFiles = [];
|
||||
let totalChanges = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename));
|
||||
if (ignored) {
|
||||
ignoredFiles.push(file.filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0));
|
||||
totalChanges += fileChanges;
|
||||
countedFiles.push(`${file.filename} (${fileChanges})`);
|
||||
}
|
||||
|
||||
let targetLabel;
|
||||
if (totalChanges <= 9) targetLabel = "1+";
|
||||
else if (totalChanges <= 39) targetLabel = "10+";
|
||||
else if (totalChanges <= 99) targetLabel = "40+";
|
||||
else if (totalChanges <= 499) targetLabel = "100+";
|
||||
else if (totalChanges <= 999) targetLabel = "500+";
|
||||
else if (totalChanges <= 1999) targetLabel = "1000+";
|
||||
else if (totalChanges <= 4999) targetLabel = "2000+";
|
||||
else targetLabel = "5000+";
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner, repo, issue_number: issueNumber, per_page: 100,
|
||||
});
|
||||
|
||||
const sizeLabels = currentLabels.map(label => label.name).filter(name => /^\d+\+$/.test(name));
|
||||
|
||||
for (const name of sizeLabels) {
|
||||
if (name !== targetLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: issueNumber, name,
|
||||
}).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLabels.some(label => label.name === targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: issueNumber, labels: [targetLabel],
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Pull request #${issueNumber} has ${totalChanges} counted changed lines, applied ${targetLabel}.`);
|
||||
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(", ") : "none"}`);
|
||||
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(", ") : "none"}`);
|
||||
}
|
||||
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
core.info("Manual trigger detected. Fetching all open pull requests...");
|
||||
|
||||
const openPRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: "open", per_page: 100,
|
||||
});
|
||||
|
||||
const unlabelledPRs = openPRs.filter(pr => {
|
||||
return !pr.labels.some(label => /^\d+\+$/.test(label.name));
|
||||
});
|
||||
|
||||
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`);
|
||||
|
||||
for (const pr of unlabelledPRs) {
|
||||
core.info(`----------------------------------------`);
|
||||
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`);
|
||||
await processPR(pr.number);
|
||||
}
|
||||
} else {
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) {
|
||||
core.info("No pull request found in payload, skipping.");
|
||||
return;
|
||||
}
|
||||
await processPR(pr.number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Check Codes Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Check Codes"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: Checkout Script
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/check-codes-comment.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: checkstyle-artifact
|
||||
path: ${{ runner.temp }}/checkstyle-artifact
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate Review Comment
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REPORTS_PATH: ${{ runner.temp }}/checkstyle-artifact/ALL_REPORTS.jsonl
|
||||
PULL_REQUEST_NUMBER_PATH: ${{ runner.temp }}/checkstyle-artifact/PULL_REQUEST_NUMBER
|
||||
with:
|
||||
script: |
|
||||
const script = require("./.github/scripts/check-codes-comment.js");
|
||||
await script({ github, context, core });
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 17
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
@@ -27,3 +27,29 @@ jobs:
|
||||
cache-cleanup: never
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Setup reviewdog
|
||||
uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0
|
||||
with:
|
||||
reviewdog_version: v0.21.0
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Generate Artifact
|
||||
run: |
|
||||
echo "${{ github.event.number }}" > PULL_REQUEST_NUMBER
|
||||
> ALL_REPORTS.jsonl
|
||||
find . -path "*/build/reports/checkstyle/*.xml" | while read -r xml_file; do
|
||||
if [ -s "$xml_file" ]; then
|
||||
echo "Processing $xml_file..."
|
||||
reviewdog -f=checkstyle -name="checkstyle" -reporter=rdjsonl < "$xml_file" >> ALL_REPORTS.jsonl
|
||||
else
|
||||
echo "Skipping empty or missing report: $xml_file"
|
||||
fi
|
||||
done
|
||||
- if: failure() && github.event_name == 'pull_request'
|
||||
name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: checkstyle-artifact
|
||||
path: |
|
||||
ALL_REPORTS.jsonl
|
||||
PULL_REQUEST_NUMBER
|
||||
@@ -24,8 +24,9 @@ jobs:
|
||||
with:
|
||||
cache-cleanup: never
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build --no-daemon --parallel
|
||||
run: xvfb-run --auto-servernum ./gradlew build --no-daemon --parallel
|
||||
env:
|
||||
JAVA_TOOL_OPTIONS: -Djava.awt.headless=true
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
@@ -48,3 +49,9 @@ jobs:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-sh
|
||||
path: HMCL/build/libs/HMCL-*.sh
|
||||
archive: false
|
||||
- name: Upload DEB
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-deb
|
||||
path: HMCL/build/libs/HMCL-*.deb
|
||||
archive: false
|
||||
@@ -0,0 +1,31 @@
|
||||
name: PR Size Label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/pr-size-label.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label PR by filtered changed lines
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const script = require("./.github/scripts/pr-size-label.js");
|
||||
await script({ github, context, core });
|
||||
@@ -46,16 +46,20 @@ jobs:
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
|
||||
- name: Verify artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
|
||||
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
|
||||
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
|
||||
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
|
||||
|
||||
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
|
||||
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
|
||||
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
|
||||
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -67,6 +71,7 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
|
||||
|
||||
# CNB Release Note
|
||||
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
|
||||
@@ -76,6 +81,8 @@ jobs:
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
|
||||
|
||||
env:
|
||||
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
|
||||
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
|
||||
@@ -86,6 +93,7 @@ jobs:
|
||||
"HMCL-${{ env.HMCL_VERSION }}.exe" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.jar" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.sh" \
|
||||
"HMCL-${{ env.HMCL_VERSION }}.deb" \
|
||||
--target "${{ env.HMCL_COMMIT_SHA }}" \
|
||||
--title "${{ env.HMCL_TAG_NAME }}" \
|
||||
--notes-file RELEASE_NOTE \
|
||||
@@ -120,6 +128,9 @@ jobs:
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.sh"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
|
||||
|
||||
echo "Uploading HMCL-$HMCL_VERSION.deb"
|
||||
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
|
||||
CNB_REPO: HMCL-dev/HMCL
|
||||
@@ -4,7 +4,10 @@
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
*.jfr
|
||||
|
||||
.gradle
|
||||
gradle.properties
|
||||
|
||||
*.lck
|
||||
*.1
|
||||
@@ -16,6 +19,8 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
hmcl-mod-update-list-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
@@ -43,6 +48,7 @@ language-subtag-registry
|
||||
# eclipse
|
||||
/bin/
|
||||
/HMCL/bin/
|
||||
/HMCLBoot/bin/
|
||||
/HMCLCore/bin/
|
||||
/minecraft/libraries/HMCLTransformerDiscoveryService/bin/
|
||||
/minecraft/libraries/HMCLMultiMCBootstrap/bin/
|
||||
@@ -69,3 +75,5 @@ language-subtag-registry
|
||||
# AI
|
||||
/.gemini/
|
||||
!/.gemini/config.yaml
|
||||
.codex
|
||||
/.gradle-*/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Java Code Style Requirements
|
||||
|
||||
These rules apply to all Java code written or modified in this repository.
|
||||
|
||||
## Nullability
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class declared in a newly added Java source file must be annotated with JetBrains Annotations `@NotNullByDefault`.
|
||||
- When writing or modifying Java code, any type, field, parameter, return value, local variable, or generic type argument that may be `null` must be explicitly annotated with `@Nullable`.
|
||||
- Nullability in code being written or modified must never be implicit.
|
||||
|
||||
## Immutability
|
||||
|
||||
- Immutable arrays and collections must be explicitly annotated with JetBrains Annotations `@Unmodifiable` or `@UnmodifiableView` as appropriate.
|
||||
- For arrays, use type-use syntax such as `String @Unmodifiable []`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Apply the following requirements when writing or modifying code. Do not use them as code-review criteria.
|
||||
|
||||
- Every class, field, and method must have documentation.
|
||||
- Documentation must use `///` Markdown-style Javadoc comments.
|
||||
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
|
||||
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
|
||||
+61
-18
@@ -4,8 +4,11 @@ import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.SyncTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import org.jackhuang.hmcl.gradle.pack.CreateDeb
|
||||
import org.jackhuang.hmcl.gradle.pack.ReleaseType
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
@@ -50,17 +53,20 @@ if (buildNumber != null) {
|
||||
}
|
||||
}
|
||||
|
||||
val embedResources by configurations.registering
|
||||
val embedResources = configurations.register("embedResources")
|
||||
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation(project(":HMCLBoot"))
|
||||
implementation("libs:JFoenix")
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.jwebp)
|
||||
implementation(libs.fxsvgimage)
|
||||
implementation(libs.java.info)
|
||||
implementation(libs.monet.fx)
|
||||
implementation(libs.nayuki.qrcodegen)
|
||||
implementation(libs.uuid.tools)
|
||||
|
||||
testImplementation(libs.jimfs)
|
||||
|
||||
if (launcherExe.isBlank()) {
|
||||
implementation(libs.hmclauncher)
|
||||
@@ -161,7 +167,7 @@ val hmclProperties = buildList {
|
||||
}
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
val createPropertiesFile by tasks.registering {
|
||||
val createPropertiesFile = tasks.register("createPropertiesFile") {
|
||||
outputs.file(hmclPropertiesFile)
|
||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
||||
|
||||
@@ -195,7 +201,7 @@ tasks.shadowJar {
|
||||
exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi")
|
||||
|
||||
listOf(
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*",
|
||||
"aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*",
|
||||
"*-ppc", "*-ppc64le", "*-s390x", "*-armel",
|
||||
).forEach { exclude("com/sun/jna/$it/**") }
|
||||
|
||||
@@ -251,20 +257,22 @@ tasks.processResources {
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
val makeExecutables = tasks.register("makeExecutables") {
|
||||
val extensions = listOf("exe", "sh")
|
||||
|
||||
dependsOn(tasks.jar)
|
||||
|
||||
inputs.file(jarPath)
|
||||
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
|
||||
outputs.files(extensions.map { artifactFile(it) })
|
||||
|
||||
doLast {
|
||||
val jarContent = jarPath.readBytes()
|
||||
|
||||
ZipFile(jarPath).use { zipFile ->
|
||||
for (extension in extensions) {
|
||||
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
|
||||
val output = artifactFile(extension)
|
||||
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
|
||||
?: throw GradleException("HMCLauncher.$extension not found")
|
||||
|
||||
@@ -279,8 +287,32 @@ val makeExecutables by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
|
||||
dependsOn(makeExecutables)
|
||||
|
||||
val debFile = layout.file(provider { artifactFile("deb") })
|
||||
|
||||
val debChannel = when (versionType) {
|
||||
"stable" -> ReleaseType.STABLE
|
||||
"dev" -> ReleaseType.DEVELOPMENT
|
||||
else -> ReleaseType.NIGHTLY
|
||||
}
|
||||
|
||||
version.set(project.version.toString())
|
||||
releaseType.set(debChannel)
|
||||
launcherClassName.set("org.jackhuang.hmcl.Launcher")
|
||||
appShFile.set(layout.file(provider { artifactFile("sh") }))
|
||||
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
|
||||
outputFile.set(debFile)
|
||||
|
||||
doLast {
|
||||
createChecksum(debFile.get().asFile)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn(makeExecutables)
|
||||
dependsOn(makeDeb)
|
||||
}
|
||||
|
||||
fun parseToolOptions(options: String?): MutableList<String> {
|
||||
@@ -377,12 +409,14 @@ val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgra
|
||||
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
|
||||
val source = layout.projectDirectory.file("terracotta-template.json");
|
||||
|
||||
classifiers.set(listOf(
|
||||
"windows-x86_64", "windows-arm64",
|
||||
"macos-x86_64", "macos-arm64",
|
||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
||||
"freebsd-x86_64"
|
||||
))
|
||||
classifiers.set(
|
||||
listOf(
|
||||
"windows-x86_64", "windows-arm64",
|
||||
"macos-x86_64", "macos-arm64",
|
||||
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
|
||||
"freebsd-x86_64"
|
||||
)
|
||||
)
|
||||
|
||||
version.set(libs.versions.terracotta)
|
||||
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
|
||||
@@ -397,28 +431,37 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
|
||||
englishFile.set(dir.file("I18N.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh.properties"))
|
||||
simplifiedChineseFile.set(dir.file("I18N_zh_Hans.properties"))
|
||||
traditionalChineseFile.set(dir.file("I18N_zh_Hant.properties"))
|
||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||
}
|
||||
|
||||
// l10n
|
||||
|
||||
tasks.register<SyncTranslations>("syncTranslations") {
|
||||
group = "localization"
|
||||
description = "Synchronizes translation keys and blank lines with I18N.properties."
|
||||
|
||||
val dir = layout.projectDirectory.dir("src/main/resources/assets/lang")
|
||||
sourceFile.set(dir.file("I18N.properties"))
|
||||
translationFiles.from(fileTree(dir) { include("I18N_*.properties") })
|
||||
}
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
|
||||
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
|
||||
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
|
||||
}
|
||||
|
||||
val createLanguageList by tasks.registering(CreateLanguageList::class) {
|
||||
val createLanguageList = tasks.register<CreateLanguageList>("createLanguageList") {
|
||||
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||
resourceBundleBaseName.set("I18N")
|
||||
additionalLanguages.set(listOf("en-Qabs"))
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
val createLocaleNamesResourceBundle = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.jfoenix.controls;
|
||||
|
||||
import com.jfoenix.skins.JFXCheckBoxSkin;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.CheckBoxTreeItem;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Displays a [CheckBoxTreeItem] with a material-design checkbox.
|
||||
///
|
||||
/// The checkbox's selected and indeterminate states are bidirectionally bound to
|
||||
/// the current tree item. Reusing or clearing the cell removes the old bindings.
|
||||
/// Installing a checkbox skin or switching tree items applies the selection state
|
||||
/// immediately, without playing a selection animation.
|
||||
/// Ordinary tree items retain the display provided by [JFXTreeCell].
|
||||
/// Empty cells and `null` values display neither content nor a checkbox.
|
||||
///
|
||||
/// @param <T> the type of value stored in each tree item
|
||||
@NotNullByDefault
|
||||
public class JFXCheckTreeCell<T> extends JFXTreeCell<T> {
|
||||
|
||||
/// The checkbox bound to the currently displayed checkable tree item.
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
|
||||
/// Places the checkbox before the graphic supplied by the superclass.
|
||||
private final HBox graphic = new HBox();
|
||||
|
||||
/// The tree item whose selection properties are currently bound, if any.
|
||||
private @Nullable CheckBoxTreeItem<?> boundTreeItem;
|
||||
|
||||
/// Creates an empty cell whose checkbox toggles the current tree item's selection.
|
||||
public JFXCheckTreeCell() {
|
||||
checkBox.setAllowIndeterminate(false);
|
||||
checkBox.setFocusTraversable(false);
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
graphic.setPickOnBounds(false);
|
||||
checkBox.skinProperty().addListener(observable -> finishSelectionAnimation());
|
||||
treeItemProperty().addListener(observable -> updateDisplay(getItem(), isEmpty()));
|
||||
}
|
||||
|
||||
/// Finishes animations from the previous item and immediately displays the current state.
|
||||
private void finishSelectionAnimation() {
|
||||
if (checkBox.getSkin() instanceof JFXCheckBoxSkin skin) {
|
||||
skin.finishSelectionAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the checkbox to the inherited display and updates its selection bindings.
|
||||
@Override
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
// Release graphics before the superclass reuses or reparents them.
|
||||
graphic.getChildren().clear();
|
||||
super.updateDisplay(item, empty);
|
||||
|
||||
@Nullable CheckBoxTreeItem<?> treeItem = !empty && item != null
|
||||
&& getTreeItem() instanceof CheckBoxTreeItem<?> checkTreeItem ? checkTreeItem : null;
|
||||
if (boundTreeItem != treeItem) {
|
||||
if (boundTreeItem != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(boundTreeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().unbindBidirectional(boundTreeItem.indeterminateProperty());
|
||||
}
|
||||
boundTreeItem = treeItem;
|
||||
if (treeItem != null) {
|
||||
checkBox.selectedProperty().bindBidirectional(treeItem.selectedProperty());
|
||||
checkBox.indeterminateProperty().bindBidirectional(treeItem.indeterminateProperty());
|
||||
} else {
|
||||
checkBox.setSelected(false);
|
||||
checkBox.setIndeterminate(false);
|
||||
}
|
||||
finishSelectionAnimation();
|
||||
}
|
||||
|
||||
if (treeItem != null) {
|
||||
@Nullable Node itemGraphic = getGraphic();
|
||||
graphic.getChildren().add(checkBox);
|
||||
if (itemGraphic != null) {
|
||||
graphic.getChildren().add(itemGraphic);
|
||||
}
|
||||
setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
private void initialize() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
this.setCellFactory(listView -> new JFXListCell<T>() {
|
||||
this.setCellFactory(listView -> new JFXListCell<>() {
|
||||
@Override
|
||||
public void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
@@ -80,7 +80,7 @@ public class JFXComboBox<T> extends ComboBox<T> {
|
||||
|
||||
// had to refactor the code out of the skin class to allow
|
||||
// customization of the button cell
|
||||
this.setButtonCell(new ListCell<T>() {
|
||||
this.setButtonCell(new ListCell<>() {
|
||||
{
|
||||
// fixed clearing the combo box value is causing
|
||||
// java prompt text to be shown because the button cell is not updated
|
||||
|
||||
@@ -199,6 +199,9 @@ public class JFXListCell<T> extends ListCell<T> {
|
||||
*/
|
||||
@Override
|
||||
protected void updateItem(T item, boolean empty) {
|
||||
T oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
if (oldItem != item || oldEmpty != empty) cellRippler.releaseRippleImmediately();
|
||||
super.updateItem(item, empty);
|
||||
if (empty) {
|
||||
setText(null);
|
||||
|
||||
@@ -38,7 +38,6 @@ import javafx.scene.paint.Paint;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu;
|
||||
|
||||
@@ -68,14 +67,18 @@ public class JFXPasswordField extends PasswordField {
|
||||
|
||||
private void initialize() {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) {
|
||||
this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";");
|
||||
}
|
||||
|
||||
|
||||
useJFXContextMenu(this);
|
||||
}
|
||||
|
||||
/// Prevents the legacy JFoenix skin from continuously requesting another layout pass.
|
||||
// https://github.com/HMCL-dev/HMCL/issues/5822
|
||||
// TODO: This method may no longer be needed after we update JFXTextFieldSkin
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
this.setNeedsLayout(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the style class to 'jfx-password-field'.
|
||||
* <p>
|
||||
@@ -294,4 +297,3 @@ public class JFXPasswordField extends PasswordField {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -278,6 +278,10 @@ public class JFXRippler extends StackPane {
|
||||
rippler.releaseRipple();
|
||||
}
|
||||
|
||||
public void releaseRippleImmediately() {
|
||||
rippler.releaseRippleImmediately();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates Ripple effect in the center of the control
|
||||
*
|
||||
@@ -405,6 +409,21 @@ public class JFXRippler extends StackPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseRippleImmediately() {
|
||||
Ripple ripple = ripplesQueue.poll();
|
||||
if (ripple != null) {
|
||||
getChildren().remove(ripple);
|
||||
if (generating.getAndSet(false)) {
|
||||
if (overlayRect != null) {
|
||||
overlayRect.inAnimation.stop();
|
||||
if (!forceOverlay) {
|
||||
overlayRect.setOpacity(0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cacheRippleClip(boolean cached) {
|
||||
cacheRipplerClip = cached;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
StackPane toastContainer = new StackPane(toast);
|
||||
toastContainer.setPadding(new Insets(20));
|
||||
actionContainer = new StackPane();
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
actionContainer.setVisible(false);
|
||||
actionContainer.setManaged(false);
|
||||
|
||||
toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
|
||||
if (getPrefWidth() == -1) {
|
||||
@@ -78,6 +79,7 @@ public class JFXSnackbarLayout extends BorderPane {
|
||||
actionContainer.getChildren().add(action);
|
||||
|
||||
if (!actionText.isEmpty()) {
|
||||
actionContainer.setPadding(new Insets(0, 10, 0, 0));
|
||||
action.setVisible(true);
|
||||
actionContainer.setVisible(true);
|
||||
actionContainer.setManaged(true);
|
||||
|
||||
@@ -28,6 +28,7 @@ import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
@@ -83,8 +84,7 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
treeItemRef = new WeakReference<>(newTreeItem);
|
||||
}
|
||||
};
|
||||
final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener);
|
||||
treeItemProperty().addListener(weakTreeItemListener);
|
||||
treeItemProperty().addListener(treeItemInvalidationListener);
|
||||
if (getTreeItem() != null) {
|
||||
getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener);
|
||||
}
|
||||
@@ -104,13 +104,20 @@ public class JFXTreeCell<T> extends TreeCell<T> {
|
||||
selectedPane.setVisible(isSelected());
|
||||
}
|
||||
|
||||
private void updateDisplay(T item, boolean empty) {
|
||||
/// Updates the text and graphic for the current value and tree item.
|
||||
///
|
||||
/// @implSpec Called when the cell value or the current tree item's graphic changes.
|
||||
/// Subclasses may override this method to decorate the default display.
|
||||
///
|
||||
/// @param item the cell value, or `null` to clear the display
|
||||
/// @param empty whether the cell has no item
|
||||
protected void updateDisplay(@Nullable T item, boolean empty) {
|
||||
if (item == null || empty) {
|
||||
hbox = null;
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
TreeItem<T> treeItem = getTreeItem();
|
||||
@Nullable TreeItem<T> treeItem = getTreeItem();
|
||||
if (treeItem != null && treeItem.getGraphic() != null) {
|
||||
if (item instanceof Node) {
|
||||
setText(null);
|
||||
|
||||
@@ -12,31 +12,20 @@ import com.jfoenix.controls.JFXRippler.RipplerPos;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import com.jfoenix.transitions.JFXFillTransition;
|
||||
import com.jfoenix.utils.JFXNodeUtils;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.animation.Transition;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.skin.CheckBoxSkin;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.Background;
|
||||
import javafx.scene.layout.BackgroundFill;
|
||||
import javafx.scene.layout.Border;
|
||||
import javafx.scene.layout.BorderStroke;
|
||||
import javafx.scene.layout.BorderStrokeStyle;
|
||||
import javafx.scene.layout.BorderWidths;
|
||||
import javafx.scene.layout.CornerRadii;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
|
||||
public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
private final StackPane box = new StackPane();
|
||||
@@ -180,21 +169,41 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
};
|
||||
}
|
||||
|
||||
private void playSelectAnimation(Boolean selection) {
|
||||
if (selection == null) {
|
||||
selection = false;
|
||||
}
|
||||
|
||||
/// Animates a selection change, or applies it immediately when animations are disabled.
|
||||
private void playSelectAnimation(boolean selection) {
|
||||
JFXCheckBox control = (JFXCheckBox) this.getSkinnable();
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
|
||||
this.box.setBorder(new Border(new BorderStroke(
|
||||
selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(),
|
||||
BorderStrokeStyle.SOLID,
|
||||
new CornerRadii(2.0),
|
||||
new BorderWidths(this.lineThick))));
|
||||
|
||||
if (!AnimationUtils.isAnimationEnabled()) {
|
||||
finishSelectionAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
}
|
||||
|
||||
/// Stops selection animations and immediately displays the checkbox's current selected state.
|
||||
///
|
||||
/// The check mark, fill, and border are updated even if no animation is running.
|
||||
/// This also suppresses the initial selection animation on the first layout.
|
||||
/// The skin must not have been disposed.
|
||||
public void finishSelectionAnimation() {
|
||||
transition.stop();
|
||||
select.stop();
|
||||
invalid = false;
|
||||
boolean selected = getSkinnable().isSelected();
|
||||
mark.setVisible(selected);
|
||||
mark.setScaleX(selected ? 1.0 : 0.0);
|
||||
mark.setScaleY(selected ? 1.0 : 0.0);
|
||||
updateColors();
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ComboBoxListViewSkin;
|
||||
import com.jfoenix.concurrency.JFXUtilities;
|
||||
import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.event.EventTarget;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
protected final ObjectProperty<Paint> promptTextFill;
|
||||
private boolean invalid = true;
|
||||
private final StackPane customPane;
|
||||
private final StackPane line = new StackPane();
|
||||
private final StackPane focusedLine = new StackPane();
|
||||
private final Text promptText = new Text();
|
||||
private final double initScale = 0.05;
|
||||
private final Scale scale;
|
||||
private final Timeline linesAnimation;
|
||||
private ParallelTransition transition;
|
||||
private CachedTransition promptTextUpTransition;
|
||||
private CachedTransition promptTextDownTransition;
|
||||
private CachedTransition promptTextColorTransition;
|
||||
private final Scale promptTextScale;
|
||||
private Paint oldPromptTextFill;
|
||||
private final BooleanBinding usePromptText;
|
||||
|
||||
public JFXComboBoxListViewSkin(JFXComboBox<T> comboBox) {
|
||||
super(comboBox);
|
||||
this.scale = new Scale(this.initScale, 1.0F);
|
||||
this.linesAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(this.scale.xProperty(), this.initScale, Interpolator.EASE_BOTH), new KeyValue(this.focusedLine.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1.0F), new KeyValue(this.focusedLine.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(160.0F), new KeyValue(this.scale.xProperty(), 1, Interpolator.EASE_BOTH)));
|
||||
this.promptTextScale = new Scale(1.0F, 1.0F, 0.0F, 0.0F);
|
||||
this.promptTextFill = new SimpleObjectProperty(Color.valueOf("#B2B2B2"));
|
||||
this.usePromptText = Bindings.createBooleanBinding(this::usePromptText, ((JFXComboBox) this.getSkinnable()).valueProperty(), this.getSkinnable().promptTextProperty());
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.promptText.textProperty().bind(comboBox.promptTextProperty());
|
||||
this.promptText.fillProperty().bind(this.promptTextFill);
|
||||
this.promptText.getStyleClass().addAll("text", "prompt-text");
|
||||
this.promptText.getTransforms().add(this.promptTextScale);
|
||||
if (!comboBox.isLabelFloat()) {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.customPane = new StackPane();
|
||||
this.customPane.setMouseTransparent(true);
|
||||
this.customPane.getStyleClass().add("combo-box-button-container");
|
||||
this.customPane.backgroundProperty().bindBidirectional(this.getSkinnable().backgroundProperty());
|
||||
this.customPane.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
|
||||
this.customPane.getChildren().add(this.promptText);
|
||||
this.getChildren().add(0, this.customPane);
|
||||
StackPane.setAlignment(this.promptText, Pos.CENTER_LEFT);
|
||||
this.line.getStyleClass().add("input-line");
|
||||
this.focusedLine.getStyleClass().add("input-focused-line");
|
||||
this.getChildren().add(this.line);
|
||||
this.getChildren().add(this.focusedLine);
|
||||
this.line.setPrefHeight(1.0F);
|
||||
this.line.setTranslateY(1.0F);
|
||||
this.line.setManaged(false);
|
||||
this.line.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (this.getSkinnable().isDisabled()) {
|
||||
this.line.setBorder(new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(1.0F))));
|
||||
this.line.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
this.focusedLine.setPrefHeight(2.0F);
|
||||
this.focusedLine.setTranslateY(0.0F);
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(((JFXComboBox) this.getSkinnable()).getFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
this.focusedLine.getTransforms().add(this.scale);
|
||||
this.focusedLine.setManaged(false);
|
||||
if (comboBox.isEditable()) {
|
||||
comboBox.getEditor().setStyle("-fx-background-color:TRANSPARENT;-fx-padding: 4 8 4 8");
|
||||
comboBox.getEditor().promptTextProperty().unbind();
|
||||
comboBox.getEditor().setPromptText(null);
|
||||
comboBox.getEditor().textProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.usePromptText.invalidate();
|
||||
comboBox.setValue(this.getConverter().fromString(newVal));
|
||||
});
|
||||
}
|
||||
|
||||
comboBox.labelFloatProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.promptText.visibleProperty().unbind();
|
||||
JFXUtilities.runInFX(() -> this.createFloatingAnimation());
|
||||
} else {
|
||||
this.promptText.visibleProperty().bind(this.usePromptText);
|
||||
}
|
||||
|
||||
this.createFocusTransition();
|
||||
});
|
||||
comboBox.focusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.focusedLine.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, newVal, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.transition = null;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.unFocusColorProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.disabledProperty().addListener((o, oldVal, newVal) -> {
|
||||
this.line.setBorder(newVal ? new Border(new BorderStroke(((JFXComboBox) this.getSkinnable()).getUnFocusColor(), BorderStrokeStyle.DASHED, CornerRadii.EMPTY, new BorderWidths(this.line.getHeight()))) : Border.EMPTY);
|
||||
this.line.setBackground(new Background(new BackgroundFill(newVal ? Color.TRANSPARENT : ((JFXComboBox) this.getSkinnable()).getUnFocusColor(), CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
});
|
||||
comboBox.focusedProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (newVal) {
|
||||
this.focus();
|
||||
} else {
|
||||
this.unFocus();
|
||||
}
|
||||
|
||||
});
|
||||
comboBox.valueProperty().addListener((o, oldVal, newVal) -> {
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.animateFloatingLabel(newVal != null && !newVal.toString().isEmpty());
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
for (Node child : getChildren()) {
|
||||
if (child instanceof ListView<?> listView && "list-view".equals(listView.getId())) {
|
||||
// The default hideOnClick logic of ComboBoxListViewSkin doesn't work well with FloatScrollBarSkin.
|
||||
// We implement hideOnClick ourselves to prevent the popup from
|
||||
// hiding when releasing the mouse on the scrollbar.
|
||||
|
||||
this.setHideOnClick(false);
|
||||
listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> {
|
||||
EventTarget target = t.getTarget();
|
||||
if (target instanceof Node targetNode) {
|
||||
List<String> s = targetNode.getStyleClass();
|
||||
if (s.contains("thumb")
|
||||
|| s.contains("track")
|
||||
|| s.contains("decrement-arrow")
|
||||
|| s.contains("increment-arrow")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void layoutChildren(double x, double y, double w, double h) {
|
||||
super.layoutChildren(x, y, w, h);
|
||||
this.customPane.resizeRelocate(x, y, w, h);
|
||||
if (this.invalid) {
|
||||
this.invalid = false;
|
||||
if (!this.getSkinnable().isEditable()) {
|
||||
Text javaPromptText = (Text) super.getDisplayNode().lookup(".text");
|
||||
if (javaPromptText != null) {
|
||||
this.promptTextFill.set(javaPromptText.getFill());
|
||||
}
|
||||
}
|
||||
|
||||
this.createFloatingAnimation();
|
||||
if (((ComboBoxBase) this.getSkinnable()).getValue() != null) {
|
||||
this.animateFloatingLabel(true);
|
||||
}
|
||||
}
|
||||
|
||||
this.focusedLine.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.focusedLine.prefHeight(-1.0F));
|
||||
this.line.resizeRelocate(x, this.getSkinnable().getHeight(), w, this.line.prefHeight(-1.0F));
|
||||
this.scale.setPivotX(w / (double) 2.0F);
|
||||
}
|
||||
|
||||
private void createFloatingAnimation() {
|
||||
this.promptTextUpTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), -this.customPane.getHeight() + 6.05, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 0.85, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 0.85, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextColorTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptTextFill, ((JFXComboBox) this.getSkinnable()).getFocusColor(), Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(160.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXComboBoxListViewSkin.this.oldPromptTextFill = JFXComboBoxListViewSkin.this.promptTextFill.get();
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition = new CachedTransition(this.customPane, new Timeline(new KeyFrame(Duration.millis(1300.0F), new KeyValue(this.promptText.translateYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.xProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.promptTextScale.yProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setDelay(Duration.millis(0.0F));
|
||||
this.setCycleDuration(Duration.millis(240.0F));
|
||||
}
|
||||
};
|
||||
this.promptTextDownTransition.setOnFinished((finish) -> {
|
||||
this.promptText.setTranslateY(0.0F);
|
||||
this.promptTextScale.setX(1.0F);
|
||||
this.promptTextScale.setY(1.0F);
|
||||
});
|
||||
}
|
||||
|
||||
private void focus() {
|
||||
if (this.transition == null) {
|
||||
this.createFocusTransition();
|
||||
}
|
||||
|
||||
this.transition.play();
|
||||
}
|
||||
|
||||
private void animateFloatingLabel(boolean up) {
|
||||
if (this.promptText == null) {
|
||||
Platform.runLater(() -> this.animateFloatingLabel(up));
|
||||
} else {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
this.transition.getChildren().remove(this.promptTextUpTransition);
|
||||
this.transition.getChildren().remove(this.promptTextColorTransition);
|
||||
this.transition = null;
|
||||
}
|
||||
|
||||
if (up && this.promptText.getTranslateY() == (double) 0.0F) {
|
||||
this.promptTextDownTransition.stop();
|
||||
this.promptTextUpTransition.play();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextColorTransition.play();
|
||||
}
|
||||
} else if (!up) {
|
||||
this.promptTextUpTransition.stop();
|
||||
if (this.getSkinnable().isFocused()) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
}
|
||||
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void createFocusTransition() {
|
||||
this.transition = new ParallelTransition();
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat()) {
|
||||
this.transition.getChildren().add(this.promptTextUpTransition);
|
||||
this.transition.getChildren().add(this.promptTextColorTransition);
|
||||
}
|
||||
|
||||
this.transition.getChildren().add(this.linesAnimation);
|
||||
}
|
||||
|
||||
private void unFocus() {
|
||||
if (this.transition != null) {
|
||||
this.transition.stop();
|
||||
}
|
||||
|
||||
this.scale.setX(this.initScale);
|
||||
this.focusedLine.setOpacity(0.0F);
|
||||
if (((JFXComboBox) this.getSkinnable()).isLabelFloat() && this.oldPromptTextFill != null) {
|
||||
this.promptTextFill.set(this.oldPromptTextFill);
|
||||
if (this.usePromptText()) {
|
||||
this.promptTextDownTransition.play();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean usePromptText() {
|
||||
Object txt = ((JFXComboBox) this.getSkinnable()).getValue();
|
||||
String promptTxt = this.getSkinnable().getPromptText();
|
||||
return (txt == null || txt.toString().isEmpty()) && promptTxt != null && !promptTxt.isEmpty() && !this.promptTextFill.get().equals(Color.TRANSPARENT);
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,10 @@ 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();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||
if (!isInsideMacAppBundle())
|
||||
@@ -74,95 +72,30 @@ public final class EntryPoint {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
if (uiScale != null) {
|
||||
uiScale = uiScale.trim();
|
||||
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
float scaleValue;
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
|
||||
float lowerBound;
|
||||
float upperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
lowerBound = 0.25f;
|
||||
upperBound = 4f;
|
||||
} else {
|
||||
lowerBound = 0.01f;
|
||||
upperBound = 10f;
|
||||
}
|
||||
|
||||
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void createHMCLDirectories() {
|
||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_LOCAL_HOME);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
try {
|
||||
Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true);
|
||||
Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e);
|
||||
LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Logger has not been started yet, so print directly to System.err
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME);
|
||||
e.printStackTrace(System.err);
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY));
|
||||
showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME));
|
||||
}
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) {
|
||||
if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) {
|
||||
try {
|
||||
Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
Files.createDirectories(Metadata.HMCL_USER_HOME);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e);
|
||||
LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,15 +158,8 @@ public final class EntryPoint {
|
||||
|
||||
private static void checkWine() {
|
||||
if (OperatingSystem.isRunningUnderWine()) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
LOG.warning("HMCL is running under Wine or its distributions!");
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
showWarning(i18n("fatal.wine_warning"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,10 +199,21 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
static void showWarning(String message) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
||||
*/
|
||||
private static void showErrorAndExit(String message) {
|
||||
static void showErrorAndExit(String message) {
|
||||
SwingUtils.showErrorDialog(message);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -17,39 +17,50 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl;
|
||||
|
||||
import com.sun.jna.Pointer;
|
||||
import com.sun.jna.WString;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Application;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SambaException;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WindowsNativeUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.CrashReporter;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
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.*;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryPoolMXBean;
|
||||
import java.net.CookieHandler;
|
||||
@@ -59,16 +70,19 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// The main JavaFX application entry point.
|
||||
public final class Launcher extends Application {
|
||||
public static final CookieManager COOKIE_MANAGER = new CookieManager();
|
||||
|
||||
@@ -96,21 +110,11 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
ConfigHolder.init();
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
initializeSettingsRuntime();
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& ConfigHolder.isNewlyCreated()
|
||||
&& SettingsManager.isNewlyCreated()
|
||||
&& System.getProperty("user.dir").startsWith("/private/var/folders/")) {
|
||||
if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5))
|
||||
return;
|
||||
@@ -118,28 +122,32 @@ public final class Launcher extends Application {
|
||||
checkConfigInTempDir();
|
||||
}
|
||||
|
||||
if (ConfigHolder.isOwnerChanged()) {
|
||||
if (SettingsManager.isOwnerChanged()) {
|
||||
if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO)
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfigHolder.isUnsupportedVersion()) {
|
||||
if (SettingsManager.hasReadOnlyCoreSettings()) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version"));
|
||||
}
|
||||
|
||||
if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) {
|
||||
if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
// runLater to ensure SettingsManager.init() finished initialization
|
||||
Platform.runLater(() -> {
|
||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
||||
// Stage.show() cannot work again because JavaFX Toolkit have already shut down.
|
||||
Platform.setImplicitExit(false);
|
||||
Controllers.initialize(primaryStage);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
Themes.applyNativeDarkMode(primaryStage);
|
||||
|
||||
UpdateChecker.init();
|
||||
|
||||
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage);
|
||||
primaryStage.show();
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
@@ -147,6 +155,26 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes modules and runtime services that depend on loaded settings.
|
||||
private static void initializeSettingsRuntime() {
|
||||
DownloadProviders.init();
|
||||
ProxyManager.init();
|
||||
Accounts.init();
|
||||
GameDirectoryManager.init();
|
||||
AuthlibInjectorServers.init();
|
||||
AnimationUtils.init();
|
||||
|
||||
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
|
||||
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
|
||||
String commonDirectory = settings().getResolvedCommonDirectory();
|
||||
if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) {
|
||||
return commonDirectory;
|
||||
} else {
|
||||
return LauncherSettings.getDefaultCommonDirectory();
|
||||
}
|
||||
}, settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
|
||||
}
|
||||
|
||||
private static void appendScreen(StringBuilder builder, Screen screen) {
|
||||
Rectangle2D bounds = screen.getBounds();
|
||||
double scale = screen.getOutputScaleX();
|
||||
@@ -205,7 +233,7 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static boolean isConfigInTempDir() {
|
||||
String configPath = ConfigHolder.configLocation().toString();
|
||||
String configPath = SettingsManager.localConfigDirectory().toString();
|
||||
|
||||
String tmpdir = System.getProperty("java.io.tmpdir");
|
||||
if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir))
|
||||
@@ -242,55 +270,16 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
private static void checkConfigInTempDir() {
|
||||
if (ConfigHolder.isNewlyCreated() && isConfigInTempDir()
|
||||
if (SettingsManager.isNewlyCreated() && isConfigInTempDir()
|
||||
&& !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) {
|
||||
EntryPoint.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(ConfigHolder.configLocation()).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(ConfigHolder.configLocation().toString());
|
||||
if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY))
|
||||
files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString());
|
||||
if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY))
|
||||
files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
|
||||
|
||||
if (showAlert(AlertType.ERROR,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
|
||||
Clipboard.getSystemClipboard()
|
||||
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
Controllers.onApplicationStop();
|
||||
FileSaver.shutdown();
|
||||
SettingsManager.shutdown();
|
||||
LOG.shutdown();
|
||||
}
|
||||
|
||||
@@ -322,10 +311,10 @@ public final class Launcher extends Application {
|
||||
LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor"));
|
||||
LOG.info("Java Home: " + System.getProperty("java.home"));
|
||||
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
|
||||
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||
LOG.info("HMCL Jar Path: " + Objects.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Objects.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory()));
|
||||
try {
|
||||
for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) {
|
||||
@@ -343,8 +332,24 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
try {
|
||||
SettingsManager.init();
|
||||
} catch (SambaException e) {
|
||||
EntryPoint.showWarning(i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigOwner();
|
||||
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
setupWindowsAppUserModelID();
|
||||
|
||||
launch(Launcher.class, args);
|
||||
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
||||
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
||||
@@ -377,5 +382,217 @@ public final class Launcher extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets the process-level AppUserModelID on Windows so the launcher groups correctly on the taskbar.
|
||||
private static void setupWindowsAppUserModelID() {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS)
|
||||
return;
|
||||
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||
return;
|
||||
|
||||
try {
|
||||
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||
if (hr < 0) {
|
||||
LOG.warning("Failed to set AppUserModelID, HRESULT=0x" + Integer.toHexString(hr));
|
||||
} else {
|
||||
LOG.info("Set AppUserModelID: " + Metadata.WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to set AppUserModelID", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets JavaFX VM options before the toolkit starts.
|
||||
/// On Wayland, the UI scale is auto-detected when the user did not configure one explicitly.
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
setUpAnimationFrameRate:
|
||||
{
|
||||
if (System.getProperty("javafx.animation.pulse") != null) {
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
int value = Integer.parseInt(animationFrameRate);
|
||||
if (value <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
if (value != 60)
|
||||
System.setProperty("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
|
||||
// To avoid prematurely loading FXUtils, we only check if animationDisabled has been explicitly set to true
|
||||
if (Boolean.TRUE.equals(settings().animationDisabledProperty().get()))
|
||||
break setUpAnimationFrameRate;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (NativeUtils.USE_JNA && Gdi32.INSTANCE != null && User32.INSTANCE != null) {
|
||||
@Nullable Pointer pointer = User32.INSTANCE.GetDC(Pointer.NULL);
|
||||
if (pointer != null) {
|
||||
try {
|
||||
int refreshRate = Gdi32.INSTANCE.GetDeviceCaps(pointer, Gdi32.VREFRESH);
|
||||
|
||||
if (refreshRate > 0) {
|
||||
LOG.info("Detected refresh rate: " + refreshRate + "Hz");
|
||||
|
||||
if (refreshRate >= 90) {
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", String.valueOf(refreshRate));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
User32.INSTANCE.ReleaseDC(Pointer.NULL, pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float uiScaleLowerBound;
|
||||
float uiScaleUpperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
uiScaleLowerBound = 0.25f;
|
||||
uiScaleUpperBound = 4f;
|
||||
} else {
|
||||
uiScaleLowerBound = 0.01f;
|
||||
uiScaleUpperBound = 10f;
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
|
||||
float scaleValue = Float.NaN;
|
||||
if (uiScale != null && !(uiScale = uiScale.trim()).isEmpty()) {
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgSessionType = Objects.requireNonNullElse(System.getenv("XDG_SESSION_TYPE"), "");
|
||||
String xdgCurrentDesktop = Objects.requireNonNullElse(System.getenv("XDG_CURRENT_DESKTOP"), "");
|
||||
|
||||
if ("wayland".equals(xdgSessionType)
|
||||
&& StringUtils.startsWithIgnoreCase(xdgCurrentDesktop, "KDE")) {
|
||||
try {
|
||||
@Nullable Path xrdb = SystemUtils.which("xrdb");
|
||||
@Nullable String xftDpi = xrdb != null ? SystemUtils.run(List.of(FileUtils.getAbsolutePath(xrdb), "-query"), inputStream -> {
|
||||
@Nullable String dpiLine;
|
||||
|
||||
try (var reader = new InputStreamReader(inputStream, OperatingSystem.NATIVE_CHARSET);
|
||||
BufferedReader bufferedReader = new BufferedReader(reader);
|
||||
Stream<String> lines = bufferedReader.lines()) {
|
||||
dpiLine = lines.filter(line -> line.trim().startsWith("Xft.dpi:"))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
return dpiLine != null
|
||||
? dpiLine.substring("Xft.dpi:".length()).trim()
|
||||
: null;
|
||||
}, java.time.Duration.ofSeconds(1)) : null;
|
||||
|
||||
if (xftDpi != null) {
|
||||
float dpiValue = Float.parseFloat(xftDpi);
|
||||
float scale = dpiValue / 96f;
|
||||
|
||||
if (scale > 1 && scale <= 10) {
|
||||
LOG.info("Detected Xft.dpi: %s (%.1fx)".formatted(dpiValue, scale));
|
||||
scaleValue = scale;
|
||||
uiScale = Float.toString(scale);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read Xft.dpi from xrdb", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Float.isFinite(scaleValue)) {
|
||||
if (scaleValue >= uiScaleLowerBound && scaleValue <= uiScaleUpperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(configDirectory.toString());
|
||||
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
|
||||
int result = JOptionPane.showOptionDialog(null,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
i18n("message.error"),
|
||||
JOptionPane.DEFAULT_OPTION,
|
||||
JOptionPane.ERROR_MESSAGE,
|
||||
null,
|
||||
options,
|
||||
options[0]);
|
||||
|
||||
if (result == 0) {
|
||||
try {
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to copy command to clipboard", e);
|
||||
}
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -23,8 +23,8 @@ import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.EnumSet;
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,9 @@ public final class Metadata {
|
||||
public static final String FULL_NAME = "Hello Minecraft! Launcher";
|
||||
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@"));
|
||||
|
||||
/// Explicit Application User Model ID used for Windows taskbar grouping and pinning.
|
||||
public static final String WINDOWS_APP_USER_MODEL_ID = "org.jackhuang.hmcl";
|
||||
|
||||
public static final String TITLE = NAME + " " + VERSION;
|
||||
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
|
||||
|
||||
@@ -46,7 +49,6 @@ 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";
|
||||
@@ -60,34 +62,38 @@ public final class Metadata {
|
||||
public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly");
|
||||
public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null);
|
||||
|
||||
public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
|
||||
public static final Path HMCL_GLOBAL_DIRECTORY;
|
||||
public static final Path HMCL_CURRENT_DIRECTORY;
|
||||
public static final Path HMCL_USER_HOME;
|
||||
public static final Path HMCL_LOCAL_HOME;
|
||||
public static final Path DEPENDENCIES_DIRECTORY;
|
||||
|
||||
static {
|
||||
String hmclHome = System.getProperty("hmcl.home");
|
||||
if (hmclHome == null) {
|
||||
String hmclHome = System.getProperty("hmcl.home", System.getenv("HMCL_USER_HOME"));
|
||||
if (StringUtils.isBlank(hmclHome)) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
String xdgData = System.getenv("XDG_DATA_HOME");
|
||||
if (StringUtils.isNotBlank(xdgData)) {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize();
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize();
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl");
|
||||
}
|
||||
} else {
|
||||
HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize();
|
||||
HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir");
|
||||
HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null
|
||||
? Paths.get(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME"));
|
||||
HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir)
|
||||
? Path.of(hmclCurrentDir).toAbsolutePath().normalize()
|
||||
: CURRENT_DIRECTORY.resolve(".hmcl");
|
||||
DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies");
|
||||
|
||||
String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR"));
|
||||
DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies)
|
||||
? Path.of(hmclDependencies).toAbsolutePath().normalize()
|
||||
: HMCL_LOCAL_HOME.resolve("dependencies");
|
||||
}
|
||||
|
||||
public static boolean isStable() {
|
||||
@@ -128,4 +134,32 @@ public final class Metadata {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install.
|
||||
public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack";
|
||||
|
||||
/// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`).
|
||||
public static Path getBundledModpackDirectory() {
|
||||
return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
/// Returns the bundled modpack package under [getBundledModpackDirectory], if present.
|
||||
///
|
||||
/// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the
|
||||
/// signal to offer automatic install; the file is removed after a successful install.
|
||||
///
|
||||
/// @return the modpack path, or `null` when no package is present
|
||||
public static @Nullable Path findBundledModpackFile() {
|
||||
Path directory = getBundledModpackDirectory();
|
||||
Path zipModpack = directory.resolve("modpack.zip");
|
||||
if (Files.isRegularFile(zipModpack)) {
|
||||
return zipModpack;
|
||||
}
|
||||
Path mrpackModpack = directory.resolve("modpack.mrpack");
|
||||
if (Files.isRegularFile(mrpackModpack)) {
|
||||
return mrpackModpack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultCacheRepository;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Provides HMCL-specific game builders for an HMCL game repository.
|
||||
@NotNullByDefault
|
||||
public class HMCLDependencyManager extends DefaultDependencyManager {
|
||||
/// Creates a dependency manager for a repository and download context.
|
||||
///
|
||||
/// @param repository the associated game repository
|
||||
/// @param downloadProvider the remote download provider
|
||||
/// @param cacheRepository the artifact cache
|
||||
public HMCLDependencyManager(
|
||||
HMCLGameRepository repository,
|
||||
DownloadProvider downloadProvider,
|
||||
DefaultCacheRepository cacheRepository) {
|
||||
super(repository, downloadProvider, cacheRepository);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameRepository getGameRepository() {
|
||||
return (HMCLGameRepository) super.getGameRepository();
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) {
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId);
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null);
|
||||
return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
public HMCLGameBuilder newGameBuilder(GameInstance instance) {
|
||||
validateGameInstance(instance);
|
||||
HMCLGameInstance updateTarget = (HMCLGameInstance) instance;
|
||||
|
||||
GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId());
|
||||
DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget);
|
||||
return new HMCLGameBuilder(
|
||||
this, updateTarget.getId(), updateTarget, draft, initialManifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DefaultGameBuilder;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/// Builds game instances and applies HMCL-specific post-installation settings.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameBuilder extends DefaultGameBuilder {
|
||||
/// Creates a builder around an HMCL target already reserved by its dependency manager.
|
||||
///
|
||||
/// @param dependencyManager the dependency manager for the target repository
|
||||
/// @param instanceId the id of the reserved instance
|
||||
/// @param updateTarget the exact update target retained by the draft, or `null` for install
|
||||
/// @param draft the open draft containing `initialManifest`
|
||||
/// @param initialManifest the empty target manifest retained by `draft`
|
||||
HMCLGameBuilder(
|
||||
HMCLDependencyManager dependencyManager,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable HMCLGameInstance updateTarget,
|
||||
DefaultGameRepositoryDraft draft,
|
||||
GameInstanceManifest initialManifest) {
|
||||
super(dependencyManager, instanceId, updateTarget, draft, initialManifest);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
///
|
||||
/// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent
|
||||
/// launches use the instance root. A read-only instance retains its existing setting.
|
||||
@Override
|
||||
protected void onInstanceCommitted(DefaultGameInstance instance) {
|
||||
if (isolationEnabled) {
|
||||
((HMCLGameInstance) instance).enableIsolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectPropertyBase;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL-specific game instance that owns instance-local settings and run-directory policy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameInstance extends DefaultGameInstance {
|
||||
|
||||
/// Whether the instance-local game settings file has already been inspected.
|
||||
private boolean gameSettingsLoaded;
|
||||
|
||||
/// Whether the instance-local game settings file cannot be overwritten safely.
|
||||
private boolean gameSettingsReadOnly;
|
||||
|
||||
/// Cached instance-local game settings, or `null` when none exist after loading.
|
||||
private GameSettings.@Nullable Instance gameSettings;
|
||||
|
||||
/// Creates a registered instance bound to the given repository snapshot.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) {
|
||||
this(snapshot, id, manifest, (Path) null);
|
||||
}
|
||||
|
||||
/// Creates a registered instance with an optional non-conventional manifest path.
|
||||
///
|
||||
/// @param snapshot the repository snapshot that owns this instance
|
||||
/// @param id the instance id
|
||||
/// @param manifest the stored instance manifest
|
||||
/// @param manifestFile the actual manifest JSON path, or `null` for the layout default
|
||||
protected HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
@Nullable Path manifestFile) {
|
||||
super(snapshot, id, manifest, manifestFile);
|
||||
}
|
||||
|
||||
/// Creates an instance that shares mutable instance-local state with another instance.
|
||||
///
|
||||
/// Used when the repository clones a snapshot so that settings and the icon property remain
|
||||
/// available on the new wrapper.
|
||||
private HMCLGameInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
HMCLGameInstance shareState) {
|
||||
super(snapshot, id, manifest, shareState);
|
||||
this.gameSettingsLoaded = shareState.gameSettingsLoaded;
|
||||
this.gameSettingsReadOnly = shareState.gameSettingsReadOnly;
|
||||
this.gameSettings = shareState.gameSettings;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) {
|
||||
return new HMCLGameInstance(newSnapshot, id, manifest, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
/// Returns the HMCL modpack configuration file for this instance.
|
||||
///
|
||||
/// @return the `modpack.cfg` path in the instance root
|
||||
@Override
|
||||
public Path getModpackConfigurationFile() {
|
||||
return getLayout().getModpackConfigurationFile(getId());
|
||||
}
|
||||
|
||||
/// Returns whether this instance has an HMCL modpack configuration file.
|
||||
///
|
||||
/// @return whether [#getModpackConfigurationFile()] exists
|
||||
public boolean isModpack() {
|
||||
return Files.exists(getModpackConfigurationFile());
|
||||
}
|
||||
|
||||
/// Reads this instance's HMCL modpack configuration.
|
||||
///
|
||||
/// @return the parsed configuration, or `null` when the file does not exist
|
||||
/// @throws IOException if the configuration cannot be read
|
||||
public @Nullable ModpackConfiguration<?> readModpackConfiguration() throws IOException {
|
||||
Path file = getModpackConfigurationFile();
|
||||
if (Files.notExists(file)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonUtils.fromJsonFile(file, ModpackConfiguration.class);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed modpack configuration: " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory() {
|
||||
return getRepository().computeRunDirectory(getId(), isModpack(), getSettings());
|
||||
}
|
||||
|
||||
/// Returns the loaded instance-local game settings, loading them while this instance is current.
|
||||
///
|
||||
/// @return the settings, or `null` when no local settings exist after loading
|
||||
public @Nullable GameSettings.Instance getSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettings;
|
||||
}
|
||||
|
||||
/// Returns the instance-local game settings, creating an empty settings object when absent.
|
||||
///
|
||||
/// @return the settings, or `null` when the settings file is read-only and no settings are loaded
|
||||
public @Nullable GameSettings.Instance getSettingsOrCreate() {
|
||||
GameSettings.Instance setting = getSettings();
|
||||
if (setting == null) {
|
||||
setting = createSettings();
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Resolves this instance's effective settings against its selected parent preset.
|
||||
///
|
||||
/// @return the effective settings
|
||||
public GameSettings.Effective getEffectiveSettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting);
|
||||
}
|
||||
|
||||
/// Creates empty instance-local game settings when none are loaded.
|
||||
///
|
||||
/// @return the settings, or `null` when settings are read-only or already present in a non-creatable state
|
||||
public @Nullable GameSettings.Instance createSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (gameSettingsReadOnly) {
|
||||
return null;
|
||||
}
|
||||
if (gameSettings != null) {
|
||||
return gameSettings;
|
||||
}
|
||||
return initSettings(new GameSettings.Instance(), true);
|
||||
}
|
||||
|
||||
/// Returns whether the instance-local game settings file cannot be overwritten safely.
|
||||
///
|
||||
/// @return whether the settings are loaded in read-only mode
|
||||
public boolean isSettingsReadOnly() {
|
||||
ensureGameSettingsLoaded();
|
||||
return gameSettingsReadOnly;
|
||||
}
|
||||
|
||||
/// Enables instance-local running-directory selection for this instance.
|
||||
///
|
||||
/// A blank local running directory resolves to the instance root. This operation is idempotent
|
||||
/// and schedules a settings save only when it adds the override. It leaves the instance
|
||||
/// unchanged when its local settings cannot be written safely.
|
||||
public void enableIsolation() {
|
||||
if (isSettingsReadOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettings.Instance setting = getSettingsOrCreate();
|
||||
if (setting != null
|
||||
&& setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the settings file when this instance still owns its settings.
|
||||
public void forceOverwriteSettings() {
|
||||
ensureGameSettingsLoaded();
|
||||
if (!ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
if (setting == null) {
|
||||
setting = new GameSettings.Instance();
|
||||
gameSettings = setting;
|
||||
gameSettingsLoaded = true;
|
||||
}
|
||||
|
||||
boolean installAutoSave = !setting.isSavable();
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
setting.setSavable(true);
|
||||
setting.setBackupOnNextSave(false);
|
||||
gameSettingsReadOnly = false;
|
||||
saveSettings();
|
||||
if (installAutoSave) {
|
||||
setting.addListener(a -> saveSettings());
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the settings asynchronously when writable and still owned by the current instance.
|
||||
public void saveSettings() {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Saves the settings synchronously when writable and still owned by the current instance.
|
||||
///
|
||||
/// @throws IOException if saving the file fails
|
||||
public void saveSettingsSync() throws IOException {
|
||||
if (gameSettings == null || gameSettingsReadOnly || !ownsGameSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameSettings.Instance setting = gameSettings;
|
||||
Path file = getGameSettingsFile().toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
if (setting.isBackupOnNextSave()) {
|
||||
setting.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(file);
|
||||
}
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
/// Initializes this instance with the given settings object.
|
||||
///
|
||||
/// @param setting the settings to install
|
||||
/// @param allowSave whether the settings may be written back to disk
|
||||
/// @return the installed settings
|
||||
public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) {
|
||||
normalizeRunningDirectoryOverride(setting);
|
||||
setting.setSavable(allowSave);
|
||||
gameSettingsLoaded = true;
|
||||
gameSettings = setting;
|
||||
setting.iconProperty().addListener(observable -> invalidateIconImage());
|
||||
if (allowSave) {
|
||||
gameSettingsReadOnly = false;
|
||||
setting.addListener(a -> saveSettings());
|
||||
} else {
|
||||
gameSettingsReadOnly = true;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
/// Returns a deep copy of the currently loaded settings, or a new settings object that inherits
|
||||
/// the effective parent preset when no local settings exist.
|
||||
///
|
||||
/// @return a detached copy suitable for installing into another instance
|
||||
public GameSettings.Instance copySettings() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
if (setting != null) {
|
||||
return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class));
|
||||
}
|
||||
|
||||
GameSettings.Instance copied = new GameSettings.Instance();
|
||||
copied.parentProperty().setValue(
|
||||
getEffectiveSettings().getPreset().idProperty().getValue());
|
||||
return copied;
|
||||
}
|
||||
|
||||
/// Returns the first custom icon file found in this instance's root directory.
|
||||
///
|
||||
/// @return the icon file, or empty when no supported icon file exists
|
||||
public @Nullable Path getIconFile() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
if (Files.exists(file)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Replaces this instance's custom icon file.
|
||||
///
|
||||
/// Existing supported icon files are removed before `iconFile` is copied.
|
||||
///
|
||||
/// @param iconFile the source icon file
|
||||
/// @throws IOException if the icon cannot be copied
|
||||
/// @throws IllegalArgumentException if the file extension is unsupported
|
||||
public void setIconFile(Path iconFile) throws IOException {
|
||||
String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||
if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new IllegalArgumentException("Unsupported icon file: " + extension);
|
||||
}
|
||||
|
||||
clearIconFiles();
|
||||
FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension));
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
/// Deletes all supported custom icon files for this instance.
|
||||
///
|
||||
/// Individual deletion failures are logged and do not stop later files from being attempted.
|
||||
public void deleteIconFile() {
|
||||
clearIconFiles();
|
||||
invalidateIconImage();
|
||||
}
|
||||
|
||||
private void clearIconFiles() {
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = getInstanceRoot().resolve("icon." + extension);
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete instance icon file: " + file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft-cached icon image for this instance id.
|
||||
///
|
||||
/// Shared across COW snapshot wrappers. The computed [Image] is retained only via a
|
||||
/// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it.
|
||||
private @Nullable WeakCachedIconImageProperty iconImage;
|
||||
|
||||
/// Returns the observable icon image for this instance.
|
||||
///
|
||||
/// The image is stored in a [WeakReference] cache: when nothing else strongly references it
|
||||
/// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory
|
||||
/// pressure. The next [#getIconImage] reloads it.
|
||||
///
|
||||
/// @return the icon image property
|
||||
public ReadOnlyObjectProperty<Image> iconImageProperty() {
|
||||
if (iconImage == null) {
|
||||
iconImage = new WeakCachedIconImageProperty();
|
||||
}
|
||||
return iconImage;
|
||||
}
|
||||
|
||||
/// Returns the icon image selected for this instance.
|
||||
///
|
||||
/// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty].
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
public Image getIconImage() {
|
||||
return iconImageProperty().get();
|
||||
}
|
||||
|
||||
/// Drops the soft-cached icon image and notifies observers.
|
||||
public void invalidateIconImage() {
|
||||
((WeakCachedIconImageProperty) iconImageProperty()).invalidate();
|
||||
}
|
||||
|
||||
/// Soft-cached read-only icon property compatible with JavaFX versions before 19.
|
||||
private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase<Image> {
|
||||
private @Nullable WeakReference<Image> cache;
|
||||
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return HMCLGameInstance.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "iconImage";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Image get() {
|
||||
WeakReference<Image> current = cache;
|
||||
Image image = current != null ? current.get() : null;
|
||||
if (image != null) {
|
||||
return image;
|
||||
}
|
||||
|
||||
image = computeIconImage();
|
||||
cache = new WeakReference<>(image);
|
||||
return image;
|
||||
}
|
||||
|
||||
/// Computes the icon image from settings, custom files, and the launch manifest.
|
||||
///
|
||||
/// @return the selected or derived icon image
|
||||
private Image computeIconImage() {
|
||||
@Nullable GameSettings.Instance setting = getSettings();
|
||||
GameInstanceIconType iconType = setting != null
|
||||
? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT)
|
||||
: GameInstanceIconType.DEFAULT;
|
||||
if (iconType != GameInstanceIconType.DEFAULT) {
|
||||
return iconType.getIcon();
|
||||
}
|
||||
|
||||
@Nullable Path iconFile = getIconFile();
|
||||
if (iconFile != null) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile, 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load instance icon for " + getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
for (ModLoaderType modLoader : getModLoaders()) {
|
||||
return GameInstanceIconType.getIconType(modLoader).getIcon();
|
||||
}
|
||||
|
||||
if (hasComponent(GameComponentType.OPTIFINE))
|
||||
return GameInstanceIconType.OPTIFINE.getIcon();
|
||||
|
||||
GameVersionNumber version = getVersion();
|
||||
if (version.isAprilFools())
|
||||
return GameInstanceIconType.APRIL_FOOLS.getIcon();
|
||||
else if (version instanceof GameVersionNumber.LegacySnapshot || version instanceof GameVersionNumber.Release release && release.getEaType() != GameVersionNumber.Release.ReleaseType.GA)
|
||||
return GameInstanceIconType.COMMAND.getIcon();
|
||||
else if (version instanceof GameVersionNumber.Old)
|
||||
return GameInstanceIconType.CRAFT_TABLE.getIcon();
|
||||
else
|
||||
return GameInstanceIconType.GRASS.getIcon();
|
||||
}
|
||||
|
||||
/// Clears the weak cache and notifies listeners.
|
||||
void invalidate() {
|
||||
cache = null;
|
||||
fireValueChangedEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the marker indicating that the most recent launch ended abnormally.
|
||||
public void markLaunchedAbnormally() {
|
||||
try {
|
||||
Files.createFile(getInstanceRoot().resolve(".abnormal"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the abnormal-launch marker when present.
|
||||
///
|
||||
/// @return whether a regular marker file was present
|
||||
public boolean unmarkLaunchedAbnormally() {
|
||||
Path file = getInstanceRoot().resolve(".abnormal");
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete abnormal launch marker: " + file, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Loads settings once while this object is the repository's current instance.
|
||||
private void ensureGameSettingsLoaded() {
|
||||
if (!gameSettingsLoaded && getRepository().findInstance(id) == this) {
|
||||
loadGameSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this instance still owns the settings object used by the current snapshot.
|
||||
private boolean ownsGameSettings() {
|
||||
@Nullable HMCLGameInstance current = getRepository().findInstance(id);
|
||||
boolean owns = current == this
|
||||
|| current != null && gameSettings != null && current.gameSettings == gameSettings;
|
||||
if (!owns && gameSettings != null) {
|
||||
gameSettings.setSavable(false);
|
||||
}
|
||||
return owns;
|
||||
}
|
||||
|
||||
private void loadGameSettings() {
|
||||
gameSettingsLoaded = true;
|
||||
LoadResult result = loadGameSettingsFile(getGameSettingsFile());
|
||||
if (result.setting() != null) {
|
||||
initSettings(result.setting(), result.allowSave());
|
||||
return;
|
||||
}
|
||||
if (!result.allowSave()) {
|
||||
gameSettingsReadOnly = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(
|
||||
getRepository(), id, legacyParent);
|
||||
if (migrationResult != null) {
|
||||
initSettings(migrationResult.setting(), true);
|
||||
try {
|
||||
saveSettingsSync();
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path getGameSettingsFile() {
|
||||
return getLayout().getInstanceGameSettingsFile(id);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
GameSettings.Effective vs = getEffectiveSettings();
|
||||
boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty);
|
||||
boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty);
|
||||
GameVersionNumber gameVersionNumber = getVersion();
|
||||
|
||||
@Nullable Integer maxMemory;
|
||||
if (autoMemory) {
|
||||
maxMemory = noJVMOptions
|
||||
? null
|
||||
: Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(
|
||||
SystemInfo.getPhysicalMemoryStatus().available(),
|
||||
javaVersion.getPlatform()) / 1024L / 1024L);
|
||||
} else {
|
||||
maxMemory = vs.getMaxMemory();
|
||||
}
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setInstanceId(getId())
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(getId().id())
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty)))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty)))
|
||||
.setMaxMemory(maxMemory)
|
||||
.setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty))
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty)))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty))
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN)
|
||||
.setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty))
|
||||
.setProxyOption(getProxyOption())
|
||||
.setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty))
|
||||
.setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty))
|
||||
.setNoGeneratedJVMArgs(noJVMOptions)
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty))
|
||||
.setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty))
|
||||
.setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty))
|
||||
.setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty))
|
||||
.setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty))
|
||||
.setRenderer(vs.getRenderer(gameVersionNumber))
|
||||
.setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty))
|
||||
.setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty))
|
||||
.setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty))
|
||||
.setUseNativeGLFWorSDL(vs.getInheritable(GameSettings::useNativeGLFWorSDLProperty))
|
||||
.setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty))
|
||||
.setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty))
|
||||
.setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
QuickPlayOption quickPlayOption = vs.getQuickPlayOption();
|
||||
if (quickPlayOption != null) {
|
||||
builder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
Path json = getModpackConfigurationFile();
|
||||
if (Files.exists(json)) {
|
||||
try {
|
||||
String jsonText = Files.readString(json);
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static ProxyOption getProxyOption() {
|
||||
return switch (settings().proxyTypeProperty().get()) {
|
||||
case SYSTEM -> ProxyOption.Default.INSTANCE;
|
||||
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||
case HTTP, SOCKS -> {
|
||||
String proxyHost = settings().proxyHostProperty().get();
|
||||
int proxyPort = settings().proxyPortProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||
yield ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
String proxyUser = settings().proxyUserProperty().get();
|
||||
String proxyPass = settings().proxyPasswordProperty().get();
|
||||
|
||||
if (StringUtils.isBlank(proxyUser)) {
|
||||
proxyUser = null;
|
||||
proxyPass = null;
|
||||
} else if (proxyPass == null) {
|
||||
proxyPass = "";
|
||||
}
|
||||
|
||||
if (settings().proxyTypeProperty().get() == ProxyType.HTTP) {
|
||||
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
} else {
|
||||
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Loads a new-format instance game settings file.
|
||||
private static LoadResult loadGameSettingsFile(Path file) {
|
||||
if (!Files.exists(file)) {
|
||||
return new LoadResult(null, true);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning("Instance game settings are empty: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in instance game settings: " + file);
|
||||
case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue()));
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual()));
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual()));
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setSavable(false);
|
||||
return new LoadResult(fallback, false);
|
||||
}
|
||||
|
||||
GameSettings.@Nullable Instance setting =
|
||||
LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class);
|
||||
if (setting == null) {
|
||||
LOG.warning("Instance game settings deserialized to null: " + file);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
}
|
||||
if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) {
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
}
|
||||
return new LoadResult(setting, schemaResult.allowSave());
|
||||
} catch (JsonParseException ex) {
|
||||
LOG.warning("Failed to parse game setting " + file, ex);
|
||||
GameSettings.Instance fallback = new GameSettings.Instance();
|
||||
fallback.setBackupOnNextSave(true);
|
||||
return new LoadResult(fallback, true);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to load game setting " + file, ex);
|
||||
return new LoadResult(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps old local custom running directories effective under the new source-selection model.
|
||||
private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) {
|
||||
if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of loading an instance-specific game settings file.
|
||||
///
|
||||
/// @param setting the loaded instance settings, or `null` when unavailable
|
||||
/// @param allowSave whether the file may be overwritten
|
||||
private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) {
|
||||
}
|
||||
|
||||
/// Optional reference to an HMCL game instance bound to a repository.
|
||||
///
|
||||
/// Replaces the former `(repository, instanceId)` pair for UI and service context that may or
|
||||
/// may not have a selected instance. When present, [#instance()] is a snapshot member and may
|
||||
/// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve
|
||||
/// from the current snapshot while preserving repository context.
|
||||
@NotNullByDefault
|
||||
public static final class Optional {
|
||||
private final HMCLGameRepository repository;
|
||||
private final @Nullable HMCLGameInstance instance;
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
public Optional(HMCLGameRepository repository) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.instance = null;
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
public Optional(HMCLGameInstance instance) {
|
||||
this.repository = instance.getRepository();
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
/// Creates an optional by resolving `instanceId` from the repository's current snapshot.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @param instanceId the instance id, or `null` for an empty optional
|
||||
/// @return an optional that is empty when `instanceId` is null or not registered
|
||||
public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
if (instanceId == null) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
HMCLGameInstance instance = repository.findInstance(instanceId);
|
||||
return instance != null ? new Optional(instance) : new Optional(repository);
|
||||
}
|
||||
|
||||
/// Creates an optional that holds the given instance.
|
||||
///
|
||||
/// @param instance the instance
|
||||
/// @return the optional
|
||||
public static Optional of(HMCLGameInstance instance) {
|
||||
return new Optional(instance);
|
||||
}
|
||||
|
||||
/// Creates an empty optional bound only to a repository.
|
||||
///
|
||||
/// @param repository the repository
|
||||
/// @return the empty optional
|
||||
public static Optional empty(HMCLGameRepository repository) {
|
||||
return new Optional(repository);
|
||||
}
|
||||
|
||||
/// Returns the repository associated with this optional.
|
||||
///
|
||||
/// @return the repository
|
||||
public HMCLGameRepository repository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Returns the held instance, if any.
|
||||
///
|
||||
/// @return the instance, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable HMCLGameInstance instance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// Returns the held instance id, if any.
|
||||
///
|
||||
/// @return the instance id, or `null` when empty
|
||||
@Contract(pure = true)
|
||||
public @Nullable GameInstanceID instanceId() {
|
||||
return instance != null ? instance.getId() : null;
|
||||
}
|
||||
|
||||
/// Returns whether an instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is non-null
|
||||
public boolean isPresent() {
|
||||
return instance != null;
|
||||
}
|
||||
|
||||
/// Returns whether no instance is present.
|
||||
///
|
||||
/// @return whether [#instance()] is null
|
||||
public boolean isEmpty() {
|
||||
return instance == null;
|
||||
}
|
||||
|
||||
/// Re-resolves the held instance id from the repository's current snapshot.
|
||||
///
|
||||
/// @return this optional when empty; otherwise a fresh optional for the same id
|
||||
public Optional refreshed() {
|
||||
if (instance == null) {
|
||||
return this;
|
||||
}
|
||||
return of(repository, instance.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -43,16 +42,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) {
|
||||
this(repository, version, authInfo, options, null);
|
||||
/// Creates a launcher with daemon process monitors.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(instance, manifest, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(repository, version, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(repository, version, authInfo, options, listener, daemon);
|
||||
/// Creates a launcher for the given instance and launch plan.
|
||||
///
|
||||
/// @param instance the instance being launched
|
||||
/// @param manifest the effective launch-time manifest
|
||||
/// @param authInfo authentication information for the game process
|
||||
/// @param options launch options
|
||||
/// @param listener process listener, or `null` to inherit IO
|
||||
/// @param daemon whether monitors should be daemon threads
|
||||
public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(instance, manifest, authInfo, options, listener, daemon);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,10 +74,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
if (config().isDisableAutoGameOptions())
|
||||
if (options.isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(version.getId());
|
||||
Path runDir = instance.getRunDirectory();
|
||||
Path optionsFile = runDir.resolve("options.txt");
|
||||
Path configFolder = runDir.resolve("config");
|
||||
|
||||
@@ -92,8 +102,8 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
* 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected
|
||||
* 1.13+ : zh_cn works fine, zh_CN will automatically switch to English
|
||||
*/
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version));
|
||||
if (gameVersion.compareTo("1.1") < 0)
|
||||
GameVersionNumber gameVersion = instance.getVersion();
|
||||
if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0)
|
||||
return;
|
||||
|
||||
String lang = normalizedLanguageTag(locale, gameVersion);
|
||||
@@ -159,10 +169,10 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
protected void appendJvmArgs(CommandBuilder result) {
|
||||
super.appendJvmArgs(result);
|
||||
|
||||
if (config().getAllowAutoAgent()
|
||||
if (options.isAllowAutoAgent()
|
||||
&& !options.isNoGeneratedJVMArgs()
|
||||
&& !options.isNoGeneratedOptimizingJVMArgs()
|
||||
&& NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) {
|
||||
&& NativePatcher.needPatchMemoryUtil(manifest, options.getJava().getParsedVersion())) {
|
||||
LOG.info("Attempting to patch game with lwjgl-unsafe-agent");
|
||||
try {
|
||||
result.add("-javaagent:" + extractLwjglUnsafeAgent());
|
||||
@@ -179,9 +189,9 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||
String fileName = library.getArtifact().getFileName();
|
||||
String fileName = library.artifact().getFileName();
|
||||
|
||||
Path agentPath = repository.getLibraryFile(version, library).toAbsolutePath().normalize();
|
||||
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
|
||||
if (agentPath.toString().contains("=")) {
|
||||
throw new IOException("Invalid library path: " + agentPath);
|
||||
}
|
||||
|
||||
@@ -17,574 +17,517 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParseException;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.mod.ModAdviser;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.ObjectBinding;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModAdviser;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.setting.DefaultIsolationType;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator;
|
||||
import org.jackhuang.hmcl.setting.GameSettingsPresetID;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Bits;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// HMCL game repository implementation backed by a GameDirectory and per-instance game settings.
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
private final Profile profile;
|
||||
/// The persistent game directory for this repository.
|
||||
private final GameDirectory gameDirectory;
|
||||
|
||||
// local version settings
|
||||
private final Map<String, VersionSetting> localVersionSettings = new HashMap<>();
|
||||
private final Set<String> beingModpackVersions = new HashSet<>();
|
||||
/// The selected instance ID persisted for this repository's game directory.
|
||||
private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId;
|
||||
|
||||
public final EventManager<Event> onVersionIconChanged = new EventManager<>();
|
||||
/// The selected instance resolved from the current repository snapshot.
|
||||
private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance;
|
||||
|
||||
public HMCLGameRepository(Profile profile, Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
this.profile = profile;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
/// Creates a repository backed by the given game directory.
|
||||
///
|
||||
/// @param gameDirectory the persistent game directory represented by this repository
|
||||
public HMCLGameRepository(GameDirectory gameDirectory) {
|
||||
super(gameDirectory.getPath().toPath());
|
||||
this.gameDirectory = gameDirectory;
|
||||
this.selectedInstanceId = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId());
|
||||
this.selectedInstance = new ReadOnlyObjectWrapper<>(this, "selectedInstance");
|
||||
this.selectedInstance.bind(Bindings.createObjectBinding(
|
||||
this::resolveSelectedInstance,
|
||||
selectedInstanceId,
|
||||
snapshotProperty()));
|
||||
gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameDirectoryType getGameDirectoryType(String id) {
|
||||
if (beingModpackVersions.contains(id) || isModpack(id)) {
|
||||
return GameDirectoryType.VERSION_FOLDER;
|
||||
} else {
|
||||
return getVersionSetting(id).getGameDirType();
|
||||
protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) {
|
||||
return new HMCLGameRepositoryLayout(baseDirectory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) {
|
||||
return new HMCLGameRepositorySnapshot(this, (HMCLGameRepositoryLayout) layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameInstance createInstance(
|
||||
DefaultGameRepositorySnapshot snapshot,
|
||||
GameInstanceID id,
|
||||
GameInstanceManifest manifest,
|
||||
@Nullable Path manifestFile) {
|
||||
return new HMCLGameInstance(snapshot, id, manifest, manifestFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositorySnapshot getSnapshot() {
|
||||
return (HMCLGameRepositorySnapshot) super.getSnapshot();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public ReadOnlyObjectProperty<HMCLGameRepositorySnapshot> snapshotProperty() {
|
||||
return (ReadOnlyObjectProperty<HMCLGameRepositorySnapshot>) super.snapshotProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException {
|
||||
return (HMCLGameInstance) super.getInstance(id);
|
||||
}
|
||||
|
||||
/// Returns the indexed instance for the given id, or `null` when it is not loaded.
|
||||
///
|
||||
/// @param id the instance id
|
||||
/// @return the instance, or `null` when absent
|
||||
public @Nullable HMCLGameInstance findInstance(GameInstanceID id) {
|
||||
return (HMCLGameInstance) getSnapshot().findInstance(id);
|
||||
}
|
||||
|
||||
/// {@inheritDoc}
|
||||
@Override
|
||||
protected void flushPendingInstanceWrites() throws IOException {
|
||||
try {
|
||||
FileSaver.waitForAllSaves();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while waiting for pending instance writes", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getRunDirectory(String id) {
|
||||
switch (getGameDirectoryType(id)) {
|
||||
case VERSION_FOLDER:
|
||||
return getVersionRoot(id);
|
||||
case ROOT_FOLDER:
|
||||
return super.getRunDirectory(id);
|
||||
case CUSTOM:
|
||||
try {
|
||||
return Path.of(getVersionSetting(id).getGameDir());
|
||||
} catch (InvalidPathException ignored) {
|
||||
return getVersionRoot(id);
|
||||
}
|
||||
default:
|
||||
throw new AssertionError("Unreachable");
|
||||
/// Returns the persistent game directory for this repository.
|
||||
public GameDirectory getGameDirectory() {
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected instance resolved from the current repository snapshot.
|
||||
///
|
||||
/// The property is `null` when the persisted selection is absent or is not registered in the
|
||||
/// current snapshot. Publishing a new snapshot replaces the value with that snapshot's member,
|
||||
/// even when the selected ID is unchanged.
|
||||
///
|
||||
/// @return the read-only selected-instance property
|
||||
public ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
|
||||
return selectedInstance.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the selected instance from the current repository snapshot.
|
||||
///
|
||||
/// @return the selected instance, or `null` when no registered instance is selected
|
||||
public @Nullable HMCLGameInstance getSelectedInstance() {
|
||||
return selectedInstance.get();
|
||||
}
|
||||
|
||||
/// Persists an instance as this repository's current selection.
|
||||
///
|
||||
/// A stale snapshot member from this repository is accepted; the observable property resolves
|
||||
/// its ID against the current snapshot.
|
||||
///
|
||||
/// @param instance the instance to select, or `null` to clear the selection
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository
|
||||
public void setSelectedInstance(@Nullable HMCLGameInstance instance) {
|
||||
if (instance != null && instance.getRepository() != this) {
|
||||
throw new IllegalArgumentException("Selected instance belongs to another repository");
|
||||
}
|
||||
settings().setSelectedInstance(gameDirectory.getId(), instance != null ? instance.getId() : null);
|
||||
}
|
||||
|
||||
/// Restores a valid selected instance after repository instances are loaded.
|
||||
///
|
||||
/// If the persisted ID is not registered, the first indexed instance is selected. If the
|
||||
/// repository is empty, the persisted selection is cleared.
|
||||
public void refreshSelectedInstance() {
|
||||
@Nullable GameInstanceID persistedId = selectedInstanceId.get();
|
||||
@Nullable HMCLGameInstance refreshedInstance = persistedId != null ? findInstance(persistedId) : null;
|
||||
if (refreshedInstance == null) {
|
||||
refreshedInstance = getSnapshot().getInstances().stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Nullable GameInstanceID refreshedId = refreshedInstance != null ? refreshedInstance.getId() : null;
|
||||
if (!Objects.equals(persistedId, refreshedId)) {
|
||||
setSelectedInstance(refreshedInstance);
|
||||
}
|
||||
}
|
||||
|
||||
public Stream<Version> getDisplayVersions() {
|
||||
return getVersions().stream()
|
||||
.filter(v -> !v.isHidden())
|
||||
.sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH))
|
||||
.thenComparing(v -> VersionNumber.asVersion(v.getId())));
|
||||
/// Resolves the persisted selected ID from the current repository snapshot.
|
||||
///
|
||||
/// @return the current snapshot member, or `null` when the selected ID is absent or unregistered
|
||||
private @Nullable HMCLGameInstance resolveSelectedInstance() {
|
||||
@Nullable GameInstanceID instanceId = selectedInstanceId.get();
|
||||
return instanceId != null ? findInstance(instanceId) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void refreshVersionsImpl() {
|
||||
localVersionSettings.clear();
|
||||
super.refreshVersionsImpl();
|
||||
versions.keySet().forEach(this::loadLocalVersionSetting);
|
||||
versions.keySet().forEach(version -> {
|
||||
if (isModpack(version)) {
|
||||
specializeVersionSetting(version);
|
||||
}
|
||||
});
|
||||
/// Returns a dependency manager using the currently selected download provider.
|
||||
///
|
||||
/// @return a new dependency manager for this repository
|
||||
public HMCLDependencyManager getDependency() {
|
||||
return getDependency(DownloadProviders.getDownloadProvider());
|
||||
}
|
||||
|
||||
/// Returns a dependency manager using the given download provider.
|
||||
///
|
||||
/// @param downloadProvider the remote download provider
|
||||
/// @return a new dependency manager for this repository
|
||||
public HMCLDependencyManager getDependency(DownloadProvider downloadProvider) {
|
||||
return new HMCLDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY);
|
||||
}
|
||||
|
||||
/// Resolves the run directory from modpack state and local settings.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present)
|
||||
/// @param localSetting the instance-local settings, or `null` when absent
|
||||
/// @return the run directory
|
||||
Path computeRunDirectory(
|
||||
GameInstanceID instanceId,
|
||||
boolean modpack,
|
||||
GameSettings.@Nullable Instance localSetting) {
|
||||
Path instanceRoot = getLayout().getInstanceRoot(instanceId);
|
||||
if (modpack) {
|
||||
return instanceRoot;
|
||||
}
|
||||
|
||||
boolean useInstanceRunningDirectory =
|
||||
localSetting != null
|
||||
&& localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory);
|
||||
if (StringUtils.isBlank(runningDirectory)) {
|
||||
return useInstanceRunningDirectory ? instanceRoot : getBaseDirectory();
|
||||
}
|
||||
|
||||
try {
|
||||
Path file = getBaseDirectory().resolve("launcher_profiles.json");
|
||||
if (!Files.exists(file) && !versions.isEmpty()) {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, PROFILE);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex);
|
||||
return Path.of(runningDirectory);
|
||||
} catch (Exception ignored) {
|
||||
return instanceRoot;
|
||||
}
|
||||
}
|
||||
|
||||
private String selectedRunningDirectory(
|
||||
GameSettings.@Nullable Instance localSetting,
|
||||
boolean useInstanceRunningDirectory) {
|
||||
if (useInstanceRunningDirectory) {
|
||||
if (localSetting == null) {
|
||||
return "";
|
||||
}
|
||||
return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
GameSettings.Preset parent = getParentGameSettings(localSetting);
|
||||
return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), "");
|
||||
}
|
||||
|
||||
/// Reads instance-local settings from disk without requiring a registered snapshot member.
|
||||
///
|
||||
/// Used while loading or migrating settings before the instance is indexed. Does not publish a
|
||||
/// snapshot entry.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @return the loaded settings, or `null` when none can be loaded
|
||||
private GameSettings.@Nullable Instance peekInstanceGameSettings(GameInstanceID instanceId) {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return null;
|
||||
}
|
||||
try (var reader = Files.newBufferedReader(file)) {
|
||||
return LauncherSettings.SETTINGS_GSON.fromJson(reader, GameSettings.Instance.class);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to peek instance game settings: " + file, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes instance-local settings to disk for an id that may not yet be registered.
|
||||
///
|
||||
/// @param instanceId the instance id
|
||||
/// @param setting the settings to write
|
||||
/// @throws IOException if the file cannot be written
|
||||
private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting)
|
||||
throws IOException {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize();
|
||||
Files.createDirectories(file.getParent());
|
||||
setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA);
|
||||
FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting));
|
||||
}
|
||||
|
||||
public Stream<HMCLGameInstance> getDisplayInstances() {
|
||||
return getSnapshot().getInstances().stream()
|
||||
.filter(it -> !it.getManifest().isHidden())
|
||||
.sorted(Comparator.comparing(DefaultGameInstance::getVersion)
|
||||
.thenComparing(instance -> Lang.requireNonNullElse(instance.getResolvedManifest().releaseTime(), Instant.EPOCH))
|
||||
.thenComparing(instance -> VersionNumber.asVersion(instance.getId().id())));
|
||||
}
|
||||
|
||||
public void changeDirectory(Path newDirectory) {
|
||||
setBaseDirectory(newDirectory);
|
||||
refreshVersionsAsync().start();
|
||||
refreshAsync().start();
|
||||
}
|
||||
|
||||
private void clean(Path directory) throws IOException {
|
||||
FileUtils.deleteDirectory(directory.resolve("crash-reports"));
|
||||
FileUtils.deleteDirectory(directory.resolve("logs"));
|
||||
for (Path logFile : FileUtils.listFilesByExtension(directory, "log"))
|
||||
Files.deleteIfExists(logFile);
|
||||
}
|
||||
|
||||
public void clean(String id) throws IOException {
|
||||
public void clean(GameInstanceID instanceId) throws IOException {
|
||||
clean(getBaseDirectory());
|
||||
clean(getRunDirectory(id));
|
||||
clean(getInstance(instanceId).getRunDirectory());
|
||||
}
|
||||
|
||||
public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException {
|
||||
Path srcDir = getVersionRoot(srcId);
|
||||
Path dstDir = getVersionRoot(dstId);
|
||||
/// Duplicates an instance and publishes the copy through one exclusive repository draft.
|
||||
///
|
||||
/// The destination remains unpublished until all selected instance and run-directory files have
|
||||
/// been copied. Failure aborts the draft and removes the destination instance root.
|
||||
///
|
||||
/// @param srcId the source instance id
|
||||
/// @param dstId the destination instance id
|
||||
/// @param copySaves whether saved worlds should be copied
|
||||
/// @throws IOException if the destination exists or any file cannot be copied or committed
|
||||
public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException {
|
||||
Path srcDir = getLayout().getInstanceRoot(srcId);
|
||||
Path dstDir = getLayout().getInstanceRoot(dstId);
|
||||
|
||||
Version fromVersion = getVersion(srcId);
|
||||
GameInstanceManifest fromManifest = getInstanceManifest(srcId);
|
||||
GameInstanceManifest destinationManifest = fromManifest.withId(dstId).withJar(dstId);
|
||||
|
||||
List<String> blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST);
|
||||
blackList.add(srcId + ".jar");
|
||||
blackList.add(srcId + ".json");
|
||||
blackList.add(srcId.id() + ".jar");
|
||||
blackList.add(srcId.id() + ".json");
|
||||
if (!copySaves)
|
||||
blackList.add("saves");
|
||||
|
||||
if (Files.exists(dstDir)) throw new IOException("Version exists");
|
||||
try (DefaultGameRepositoryDraft draft = openDraft()) {
|
||||
draft.put(destinationManifest);
|
||||
|
||||
Files.createDirectories(dstDir);
|
||||
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
Files.createDirectories(dstDir);
|
||||
FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
|
||||
Path fromJson = srcDir.resolve(srcId + ".json");
|
||||
Path fromJar = srcDir.resolve(srcId + ".jar");
|
||||
Path toJson = dstDir.resolve(dstId + ".json");
|
||||
Path toJar = dstDir.resolve(dstId + ".jar");
|
||||
|
||||
if (Files.exists(fromJar)) {
|
||||
Files.copy(fromJar, toJar);
|
||||
}
|
||||
Files.copy(fromJson, toJson);
|
||||
|
||||
JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId));
|
||||
|
||||
VersionSetting oldVersionSetting = getVersionSetting(srcId).clone();
|
||||
GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType();
|
||||
oldVersionSetting.setUsesGlobal(false);
|
||||
oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting);
|
||||
saveVersionSetting(dstId);
|
||||
|
||||
Path srcGameDir = getRunDirectory(srcId);
|
||||
Path dstGameDir = getRunDirectory(dstId);
|
||||
|
||||
if (originalGameDirType != GameDirectoryType.VERSION_FOLDER)
|
||||
FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null));
|
||||
}
|
||||
|
||||
private Path getLocalVersionSettingFile(String id) {
|
||||
return getVersionRoot(id).resolve("hmclversion.cfg");
|
||||
}
|
||||
|
||||
private void loadLocalVersionSetting(String id) {
|
||||
Path file = getLocalVersionSettingFile(id);
|
||||
if (Files.exists(file))
|
||||
try {
|
||||
VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class);
|
||||
initLocalVersionSetting(id, versionSetting);
|
||||
} catch (Exception ex) {
|
||||
// If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated.
|
||||
initLocalVersionSetting(id, new VersionSetting());
|
||||
Path fromJar = srcDir.resolve(srcId.id() + ".jar");
|
||||
Path toJar = dstDir.resolve(dstId.id() + ".jar");
|
||||
if (Files.exists(fromJar)) {
|
||||
Files.copy(fromJar, toJar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new version setting if version id has no version setting.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return new version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting createLocalVersionSetting(String id) {
|
||||
if (!hasVersion(id))
|
||||
return null;
|
||||
if (localVersionSettings.containsKey(id))
|
||||
return getLocalVersionSetting(id);
|
||||
else
|
||||
return initLocalVersionSetting(id, new VersionSetting());
|
||||
}
|
||||
|
||||
private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) {
|
||||
localVersionSettings.put(id, vs);
|
||||
vs.addListener(a -> saveVersionSetting(id));
|
||||
return vs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version setting for version id.
|
||||
*
|
||||
* @param id version id
|
||||
* @return corresponding version setting, null if the version has no its own version setting.
|
||||
*/
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
loadLocalVersionSetting(id);
|
||||
VersionSetting setting = localVersionSettings.get(id);
|
||||
if (setting != null && isModpack(id))
|
||||
setting.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
return setting;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public VersionSetting getLocalVersionSettingOrCreate(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null) {
|
||||
vs = createLocalVersionSetting(id);
|
||||
}
|
||||
return vs;
|
||||
}
|
||||
|
||||
public VersionSetting getVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null || vs.isUsesGlobal()) {
|
||||
profile.getGlobal().setUsesGlobal(true);
|
||||
return profile.getGlobal();
|
||||
} else
|
||||
return vs;
|
||||
}
|
||||
|
||||
public Optional<Path> getVersionIconFile(String id) {
|
||||
Path root = getVersionRoot(id);
|
||||
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = root.resolve("icon." + extension);
|
||||
if (Files.exists(file)) {
|
||||
return Optional.of(file);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public void setVersionIconFile(String id, Path iconFile) throws IOException {
|
||||
String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT);
|
||||
if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) {
|
||||
throw new IllegalArgumentException("Unsupported icon file: " + ext);
|
||||
}
|
||||
|
||||
deleteIconFile(id);
|
||||
|
||||
FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext));
|
||||
}
|
||||
|
||||
public void deleteIconFile(String id) {
|
||||
Path root = getVersionRoot(id);
|
||||
for (String extension : FXUtils.IMAGE_EXTENSIONS) {
|
||||
Path file = root.resolve("icon." + extension);
|
||||
Path srcGameDir = getInstance(srcId).getRunDirectory();
|
||||
boolean copyOriginalGameDir;
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
copyOriginalGameDir = !Files.isSameFile(srcGameDir, srcDir);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete icon file: " + file, e);
|
||||
copyOriginalGameDir = true;
|
||||
}
|
||||
|
||||
GameSettings.Instance newGameSettings = getInstance(srcId).copySettings();
|
||||
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
newGameSettings.runningDirectoryProperty().setValue("");
|
||||
writeInstanceGameSettings(dstId, newGameSettings);
|
||||
|
||||
Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings);
|
||||
if (copyOriginalGameDir) {
|
||||
FileUtils.copyDirectory(
|
||||
srcGameDir,
|
||||
dstGameDir,
|
||||
path -> Modpack.acceptFile(path, blackList, null));
|
||||
}
|
||||
|
||||
draft.commit();
|
||||
}
|
||||
}
|
||||
|
||||
public Image getVersionIconImage(String id) {
|
||||
if (id == null || !isLoaded())
|
||||
return VersionIconType.DEFAULT.getIcon();
|
||||
|
||||
VersionSetting vs = getLocalVersionSettingOrCreate(id);
|
||||
VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT;
|
||||
|
||||
if (iconType == VersionIconType.DEFAULT) {
|
||||
Version version = getVersion(id).resolve(this);
|
||||
Optional<Path> iconFile = getVersionIconFile(id);
|
||||
if (iconFile.isPresent()) {
|
||||
try {
|
||||
return FXUtils.loadImage(iconFile.get(), 64, 64, true, true);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load version icon of " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (LibraryAnalyzer.isModded(this, version)) {
|
||||
LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null);
|
||||
if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC))
|
||||
return VersionIconType.FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT))
|
||||
return VersionIconType.QUILT.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC))
|
||||
return VersionIconType.LEGACY_FABRIC.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE))
|
||||
return VersionIconType.NEO_FORGE.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE))
|
||||
return VersionIconType.FORGE.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM))
|
||||
return VersionIconType.CLEANROOM.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER))
|
||||
return VersionIconType.CHICKEN.getIcon();
|
||||
else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE))
|
||||
return VersionIconType.OPTIFINE.getIcon();
|
||||
}
|
||||
|
||||
String gameVersion = getGameVersion(version).orElse(null);
|
||||
if (gameVersion != null) {
|
||||
GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion);
|
||||
if (versionNumber.isAprilFools()) {
|
||||
return VersionIconType.APRIL_FOOLS.getIcon();
|
||||
} else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) {
|
||||
return VersionIconType.COMMAND.getIcon();
|
||||
} else if (versionNumber instanceof GameVersionNumber.Old) {
|
||||
return VersionIconType.CRAFT_TABLE.getIcon();
|
||||
}
|
||||
}
|
||||
return VersionIconType.GRASS.getIcon();
|
||||
} else {
|
||||
return iconType.getIcon();
|
||||
}
|
||||
}
|
||||
|
||||
public void saveVersionSetting(String id) {
|
||||
if (!localVersionSettings.containsKey(id))
|
||||
return;
|
||||
Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to create directory: " + file.getParent(), e);
|
||||
}
|
||||
|
||||
FileSaver.save(file, GSON.toJson(localVersionSettings.get(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Make version use self version settings instead of the global one.
|
||||
*
|
||||
* @param id the version id.
|
||||
* @return specialized version setting, null if given version does not exist.
|
||||
*/
|
||||
public VersionSetting specializeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
vs = createLocalVersionSetting(id);
|
||||
if (vs == null)
|
||||
/// Returns instance-local settings for a registered instance ID, creating empty settings when
|
||||
/// the settings file is absent and writable.
|
||||
///
|
||||
/// Code that already has an [HMCLGameInstance] should use
|
||||
/// [HMCLGameInstance#getSettingsOrCreate()] instead.
|
||||
///
|
||||
/// @param instanceId the registered instance ID
|
||||
/// @return the settings, or `null` when the instance is not registered or settings are unavailable
|
||||
public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) {
|
||||
HMCLGameInstance instance = findInstance(instanceId);
|
||||
if (instance == null) {
|
||||
return null;
|
||||
if (vs.isUsesGlobal()) {
|
||||
vs.setUsesGlobal(false);
|
||||
}
|
||||
return vs;
|
||||
return instance.getSettingsOrCreate();
|
||||
}
|
||||
|
||||
public void globalizeVersionSetting(String id) {
|
||||
VersionSetting vs = getLocalVersionSetting(id);
|
||||
if (vs != null)
|
||||
vs.setUsesGlobal(true);
|
||||
}
|
||||
|
||||
public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List<String> javaAgents, List<String> javaArguments, boolean makeLaunchScript) {
|
||||
VersionSetting vs = getVersionSetting(version);
|
||||
|
||||
LaunchOptions.Builder builder = new LaunchOptions.Builder()
|
||||
.setGameDir(gameDir)
|
||||
.setJava(javaVersion)
|
||||
.setVersionType(Metadata.TITLE)
|
||||
.setVersionName(version)
|
||||
.setProfileName(Metadata.TITLE)
|
||||
.setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs()))
|
||||
.setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs()))
|
||||
.setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory(
|
||||
vs.getMaxMemory() * 1024L * 1024L,
|
||||
SystemInfo.getPhysicalMemoryStatus().getAvailable(),
|
||||
vs.isAutoMemory()
|
||||
) / 1024 / 1024))
|
||||
.setMinMemory(vs.getMinMemory())
|
||||
.setMetaspace(Lang.toIntOrNull(vs.getPermSize()))
|
||||
.setEnvironmentVariables(
|
||||
Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables())
|
||||
.stream()
|
||||
.map(it -> {
|
||||
int idx = it.indexOf('=');
|
||||
return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, "");
|
||||
})
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
)
|
||||
.setWidth(vs.getWidth())
|
||||
.setHeight(vs.getHeight())
|
||||
.setFullscreen(vs.isFullscreen())
|
||||
.setWrapper(vs.getWrapper())
|
||||
.setProxyOption(getProxyOption())
|
||||
.setPreLaunchCommand(vs.getPreLaunchCommand())
|
||||
.setPostExitCommand(vs.getPostExitCommand())
|
||||
.setNoGeneratedJVMArgs(vs.isNoJVMArgs())
|
||||
.setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs())
|
||||
.setNativesDirType(vs.getNativesDirType())
|
||||
.setNativesDir(vs.getNativesDir())
|
||||
.setProcessPriority(vs.getProcessPriority())
|
||||
.setGraphicsBackend(vs.getGraphicsBackend())
|
||||
.setRenderer(vs.getRenderer())
|
||||
.setEnableDebugLogOutput(vs.isEnableDebugLogOutput())
|
||||
.setUseNativeGLFW(vs.isUseNativeGLFW())
|
||||
.setUseNativeOpenAL(vs.isUseNativeOpenAL())
|
||||
.setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon())
|
||||
.setJavaAgents(javaAgents)
|
||||
.setJavaArguments(javaArguments);
|
||||
|
||||
if (StringUtils.isNotBlank(vs.getServerIp())) {
|
||||
builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp()));
|
||||
/// Returns instance-local settings for a registered instance ID.
|
||||
///
|
||||
/// When the instance is not yet indexed, settings are loaded from disk (including lazy legacy
|
||||
/// migration) without publishing a snapshot entry. Callers that already have an
|
||||
/// [HMCLGameInstance] should use [HMCLGameInstance#getSettings()] instead.
|
||||
///
|
||||
/// @param instanceId the instance ID
|
||||
/// @return the settings, or `null` when no local settings exist
|
||||
public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) {
|
||||
HMCLGameInstance instance = findInstance(instanceId);
|
||||
if (instance != null) {
|
||||
return instance.getSettings();
|
||||
}
|
||||
|
||||
Path json = getModpackConfiguration(version);
|
||||
if (Files.exists(json)) {
|
||||
try {
|
||||
String jsonText = Files.readString(json);
|
||||
ModpackConfiguration<?> modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class);
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType());
|
||||
if (provider != null) provider.injectLaunchOptions(jsonText, builder);
|
||||
} catch (IOException | JsonParseException e) {
|
||||
LOG.warning("Failed to parse modpack configuration file " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx")))
|
||||
builder.setMaxMemory(null);
|
||||
|
||||
return builder;
|
||||
return loadOrMigrateInstanceGameSettings(instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getModpackConfiguration(String version) {
|
||||
return getVersionRoot(version).resolve("modpack.cfg");
|
||||
/// Returns the explicit parent preset of the instance, falling back to the default preset.
|
||||
public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) {
|
||||
@Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null;
|
||||
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
|
||||
return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate();
|
||||
}
|
||||
|
||||
public void markVersionAsModpack(String id) {
|
||||
beingModpackVersions.add(id);
|
||||
/// Resolves effective settings for a registered instance ID.
|
||||
///
|
||||
/// Instance-oriented callers should use [HMCLGameInstance#getEffectiveSettings()] instead.
|
||||
///
|
||||
/// @param instanceId the registered instance ID
|
||||
/// @return the effective settings
|
||||
/// @throws NoSuchGameInstanceException if the instance is not registered
|
||||
public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) {
|
||||
return getInstance(instanceId).getEffectiveSettings();
|
||||
}
|
||||
|
||||
public void undoMark(String id) {
|
||||
beingModpackVersions.remove(id);
|
||||
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
|
||||
///
|
||||
/// @param modded whether the new instance contains a mod loader
|
||||
/// @return whether installation-time run-directory content should be placed under the instance root
|
||||
public boolean shouldIsolateNewInstance(boolean modded) {
|
||||
GameSettings.Preset preset = getParentGameSettings(null);
|
||||
DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED);
|
||||
return switch (type) {
|
||||
case NEVER -> false;
|
||||
case ALWAYS -> true;
|
||||
case MODDED -> modded;
|
||||
};
|
||||
}
|
||||
|
||||
public void markVersionLaunchedAbnormally(String id) {
|
||||
try {
|
||||
Files.createFile(getVersionRoot(id).resolve(".abnormal"));
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unmarkVersionLaunchedAbnormally(String id) {
|
||||
Path file = getVersionRoot(id).resolve(".abnormal");
|
||||
/// Loads settings from disk for an unregistered id, running legacy migration when needed.
|
||||
private GameSettings.@Nullable Instance loadOrMigrateInstanceGameSettings(GameInstanceID instanceId) {
|
||||
Path file = getLayout().getInstanceGameSettingsFile(instanceId);
|
||||
if (Files.isRegularFile(file)) {
|
||||
try {
|
||||
Files.delete(file);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete abnormal mark file: " + file, e);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
return peekInstanceGameSettings(instanceId);
|
||||
}
|
||||
|
||||
@Nullable GameSettingsPresetID legacyParent = getGameDirectory().getLegacyGameSettings();
|
||||
if (SettingsManager.getGameSettings(legacyParent) == null) {
|
||||
legacyParent = null;
|
||||
}
|
||||
|
||||
LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult =
|
||||
LegacyGameSettingsMigrator.migrateInstanceGameSettings(this, instanceId, legacyParent);
|
||||
if (migrationResult == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeInstanceGameSettings(instanceId, migrationResult.setting());
|
||||
migrationResult.saveReceipt();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to save migrated instance game settings for " + instanceId, e);
|
||||
}
|
||||
return migrationResult.setting();
|
||||
}
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create();
|
||||
// These instance ids are forbidden because they may conflict with modpack configuration filenames
|
||||
private static final Set<String> FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest");
|
||||
|
||||
private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}";
|
||||
public static boolean isValidInstanceId(String id) {
|
||||
if (!GameInstanceID.isValid(id))
|
||||
return false;
|
||||
|
||||
|
||||
// These version ids are forbidden because they may conflict with modpack configuration filenames
|
||||
private static final Set<String> FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList(
|
||||
"modpack", "minecraftinstance", "manifest"));
|
||||
|
||||
public static boolean isValidVersionId(String id) {
|
||||
if (FORBIDDEN_VERSION_IDS.contains(id))
|
||||
if (FORBIDDEN_INSTANCE_IDS.contains(id))
|
||||
return false;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS &&
|
||||
FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
FORBIDDEN_INSTANCE_IDS.contains(id.toLowerCase(Locale.ROOT)))
|
||||
return false;
|
||||
|
||||
return FileUtils.isNameValid(id);
|
||||
return FileUtils.isNameValidForJar(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given version id conflicts with an existing version.
|
||||
* Returns true if the given instance id conflicts with an existing instance.
|
||||
*/
|
||||
public boolean versionIdConflicts(String id) {
|
||||
public boolean instanceIdConflicts(String instanceId) {
|
||||
try {
|
||||
return instanceIdConflicts(new GameInstanceID(instanceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean instanceIdConflicts(GameInstanceID id) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// on Windows, filenames are case-insensitive
|
||||
for (String existingId : versions.keySet()) {
|
||||
if (existingId.equalsIgnoreCase(id)) {
|
||||
for (HMCLGameInstance instance : getSnapshot().getInstances()) {
|
||||
if (instance.getId().toString().equalsIgnoreCase(id.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return versions.containsKey(id);
|
||||
return hasInstance(id);
|
||||
}
|
||||
}
|
||||
|
||||
public static long getAllocatedMemory(long minimum, long available, boolean auto) {
|
||||
if (auto) {
|
||||
available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (available <= 0) {
|
||||
return minimum;
|
||||
}
|
||||
public static long getAutoAllocatedMemory(long available, Platform platform) {
|
||||
long usable = available - 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself
|
||||
if (usable <= 0) {
|
||||
return available;
|
||||
}
|
||||
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested = Math.min(available <= threshold
|
||||
? (long) (available * 0.8)
|
||||
: (long) (threshold * 0.8 + (available - threshold) * 0.2),
|
||||
final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB
|
||||
final long suggested;
|
||||
if (usable <= threshold)
|
||||
suggested = (long) (usable * 0.8);
|
||||
else
|
||||
suggested = Math.min(
|
||||
(long) (threshold * 0.8 + (usable - threshold) * 0.2),
|
||||
16L * 1024 * 1024 * 1024);
|
||||
return Math.max(minimum, suggested);
|
||||
} else {
|
||||
return minimum;
|
||||
}
|
||||
return platform.getBits() == Bits.BIT_32
|
||||
? Math.min(suggested, 768 * 1024 * 1024) // https://github.com/HMCL-dev/HMCL/issues/6638
|
||||
: suggested;
|
||||
}
|
||||
|
||||
public static ProxyOption getProxyOption() {
|
||||
if (!config().hasProxy() || config().getProxyType() == null) {
|
||||
return ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
return switch (config().getProxyType()) {
|
||||
case DIRECT -> ProxyOption.Direct.INSTANCE;
|
||||
case HTTP, SOCKS -> {
|
||||
String proxyHost = config().getProxyHost();
|
||||
int proxyPort = config().getProxyPort();
|
||||
|
||||
if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) {
|
||||
yield ProxyOption.Default.INSTANCE;
|
||||
}
|
||||
|
||||
String proxyUser = config().getProxyUser();
|
||||
String proxyPass = config().getProxyPass();
|
||||
|
||||
if (StringUtils.isBlank(proxyUser)) {
|
||||
proxyUser = null;
|
||||
proxyPass = null;
|
||||
} else if (proxyPass == null) {
|
||||
proxyPass = "";
|
||||
}
|
||||
|
||||
if (config().getProxyType() == Proxy.Type.HTTP) {
|
||||
yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
} else {
|
||||
yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass);
|
||||
}
|
||||
}
|
||||
default -> ProxyOption.Default.INSTANCE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@NotNullByDefault
|
||||
public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout {
|
||||
/// Directory under the instance root that stores HMCL-managed instance metadata.
|
||||
private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||
private static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance state files.
|
||||
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||
|
||||
/// Current file name for instance-specific game settings.
|
||||
private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||
|
||||
public HMCLGameRepositoryLayout(Path baseDirectory) {
|
||||
super(baseDirectory);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed metadata directory under the instance root.
|
||||
///
|
||||
/// This directory stores instance-scoped files owned by HMCL.
|
||||
public Path getInstanceMetadataDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed configuration directory under the instance metadata directory.
|
||||
public Path getInstanceConfigDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the HMCL-managed state directory under the instance metadata directory.
|
||||
public Path getInstanceStateDirectory(GameInstanceID instanceId) {
|
||||
return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY);
|
||||
}
|
||||
|
||||
/// Returns the current local game settings path under the instance configuration directory.
|
||||
public Path getInstanceGameSettingsFile(GameInstanceID instanceId) {
|
||||
return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy.
|
||||
@NotNullByDefault
|
||||
public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot {
|
||||
/// Creates an empty unsealed HMCL snapshot.
|
||||
///
|
||||
/// @param repository the owning repository
|
||||
/// @param layout the HMCL layout for this snapshot
|
||||
public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) {
|
||||
super(repository, layout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepository getRepository() {
|
||||
return (HMCLGameRepository) super.getRepository();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLGameRepositoryLayout getLayout() {
|
||||
return (HMCLGameRepositoryLayout) super.getLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HMCLGameRepositorySnapshot newEmpty() {
|
||||
return new HMCLGameRepositorySnapshot(getRepository(), getLayout());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public Collection<HMCLGameInstance> getInstances() {
|
||||
return (Collection<HMCLGameInstance>) super.getInstances();
|
||||
}
|
||||
}
|
||||
@@ -19,62 +19,118 @@ package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.mod.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.download.GameBuilder;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/// Installs or updates an HMCL modpack by rebuilding its recognized game components.
|
||||
@NotNullByDefault
|
||||
public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
private final Path zipFile;
|
||||
private final String name;
|
||||
private final GameInstanceID instanceId;
|
||||
|
||||
/// Existing instance selecting update mode, or `null` for a new installation.
|
||||
private final @Nullable DefaultGameInstance updateTarget;
|
||||
|
||||
private final HMCLGameRepository repository;
|
||||
private final DefaultDependencyManager dependency;
|
||||
private final Modpack modpack;
|
||||
private final List<Task<?>> dependencies = new ArrayList<>(1);
|
||||
private final List<Task<?>> dependents = new ArrayList<>(4);
|
||||
|
||||
public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) {
|
||||
dependency = profile.getDependency();
|
||||
repository = profile.getRepository();
|
||||
/// Creates a task that installs a new HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the id of the new instance
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId) {
|
||||
this(repository, zipFile, modpack, instanceId, null);
|
||||
}
|
||||
|
||||
/// Creates a task that updates an existing HMCL modpack instance.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instance the existing instance to update
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository, has no
|
||||
/// modpack configuration, or records another provider type
|
||||
public HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
DefaultGameInstance instance) {
|
||||
this(repository, zipFile, modpack, instance.getId(), instance);
|
||||
}
|
||||
|
||||
/// Creates an HMCL modpack task in the mode selected by `updateTarget`.
|
||||
///
|
||||
/// @param repository the target HMCL game repository
|
||||
/// @param zipFile the HMCL modpack archive
|
||||
/// @param modpack the parsed modpack metadata
|
||||
/// @param instanceId the target instance id
|
||||
/// @param updateTarget the existing instance selecting update mode, or `null` for install
|
||||
/// @throws IllegalArgumentException if an update target belongs to another repository or has
|
||||
/// no compatible configuration
|
||||
private HMCLModpackInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable DefaultGameInstance updateTarget) {
|
||||
this.repository = repository;
|
||||
this.dependency = repository.getDependency();
|
||||
this.zipFile = zipFile;
|
||||
this.name = name;
|
||||
this.instanceId = instanceId;
|
||||
this.updateTarget = updateTarget;
|
||||
this.modpack = modpack;
|
||||
if (this.updateTarget != null) {
|
||||
dependency.validateGameInstance(this.updateTarget);
|
||||
}
|
||||
|
||||
Path run = repository.getRunDirectory(name);
|
||||
Path json = repository.getModpackConfiguration(name);
|
||||
if (repository.hasVersion(name) && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Version " + name + " already exists");
|
||||
Path run = repository.getLayout().getInstanceRoot(this.instanceId);
|
||||
Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId);
|
||||
if (this.updateTarget != null && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
|
||||
dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync());
|
||||
|
||||
onDone().register(event -> {
|
||||
if (event.isFailed()) repository.removeVersionFromDisk(name);
|
||||
});
|
||||
|
||||
ModpackConfiguration<Modpack> config = null;
|
||||
@Nullable ModpackConfiguration<Modpack> config = null;
|
||||
try {
|
||||
if (Files.exists(json)) {
|
||||
if (this.updateTarget != null && Files.exists(json)) {
|
||||
config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class));
|
||||
|
||||
if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version.");
|
||||
if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType()))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance.");
|
||||
}
|
||||
} catch (JsonParseException | IOException ignore) {
|
||||
}
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack"));
|
||||
|
||||
onDone().register(event -> {
|
||||
if (this.updateTarget == null && event.isFailed()) {
|
||||
repository.removeInstanceFromDisk(this.instanceId);
|
||||
}
|
||||
});
|
||||
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,20 +143,43 @@ public final class HMCLModpackInstallTask extends Task<Void> {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/// Reads the legacy pack manifest and schedules one builder for all recognized components.
|
||||
///
|
||||
/// The pack manifest is used only for component detection. The builder creates a new
|
||||
/// patch-structured manifest instead of attempting to mutate the legacy flattened manifest.
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json");
|
||||
Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null);
|
||||
Task<Version> libraryTask = Task.supplyAsync(() -> originalVersion);
|
||||
// reinstall libraries
|
||||
// libraries of Forge and OptiFine should be obtained by installation.
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId()))
|
||||
continue;
|
||||
libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion()));
|
||||
}
|
||||
GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class)
|
||||
.withId(instanceId)
|
||||
.withJar(null);
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null);
|
||||
|
||||
dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync));
|
||||
try (GameBuilder builder = updateTarget == null
|
||||
? dependency.newGameBuilder(instanceId)
|
||||
: dependency.newGameBuilder(updateTarget)) {
|
||||
builder.enableIsolation();
|
||||
|
||||
String gameVersion = modpack.getGameVersion();
|
||||
builder.component(GameComponentType.GAME, gameVersion);
|
||||
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||
if (mark.componentType() == GameComponentType.GAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable String componentVersion = mark.version();
|
||||
if (componentVersion != null) {
|
||||
if (mark.componentType() == GameComponentType.OPTIFINE) {
|
||||
Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion);
|
||||
if (matcher.matches()) {
|
||||
componentVersion = matcher.group("optifine");
|
||||
}
|
||||
}
|
||||
builder.component(mark.componentType(), componentVersion);
|
||||
}
|
||||
}
|
||||
dependencies.add(builder.buildAsync());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.mod.ModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
|
||||
public final class HMCLModpackManifest implements ModpackManifest {
|
||||
public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest();
|
||||
|
||||
@@ -20,19 +20,20 @@ package org.jackhuang.hmcl.game;
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.mod.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.mod.Modpack;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
public final class HMCLModpackProvider implements ModpackProvider {
|
||||
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
|
||||
@@ -43,12 +44,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, String version) {
|
||||
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException {
|
||||
public Task<?> createUpdateTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
DefaultGameInstance instance,
|
||||
Path zipFile,
|
||||
Modpack modpack,
|
||||
@Nullable Set<String> excludedFiles) throws MismatchedModpackTypeException {
|
||||
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||
|
||||
@@ -56,9 +62,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||
}
|
||||
|
||||
Profile profile = repository.getProfile();
|
||||
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name));
|
||||
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,21 +70,26 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||
Version game = JsonUtils.fromNonNullJson(gameJson, Version.class);
|
||||
if (game.getJar() == null)
|
||||
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||
if (game.jar() == null)
|
||||
if (StringUtils.isBlank(manifest.getVersion()))
|
||||
throw new JsonParseException("Cannot recognize the game version of modpack " + file + ".");
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE);
|
||||
else
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar());
|
||||
manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.jar().id());
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) {
|
||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
||||
public Task<?> getInstallTask(
|
||||
DefaultDependencyManager dependencyManager,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,17 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.MaintainTask;
|
||||
import org.jackhuang.hmcl.download.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.launch.*;
|
||||
import org.jackhuang.hmcl.mod.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.mod.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.mod.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.ModpackProvider;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.LauncherVisibility;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
@@ -47,8 +48,10 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
@@ -57,6 +60,7 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -65,37 +69,43 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static javafx.application.Platform.runLater;
|
||||
import static javafx.application.Platform.setImplicitExit;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.TIME_FORMATTER;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM;
|
||||
import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java;
|
||||
|
||||
public final class LauncherHelper {
|
||||
|
||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||
|
||||
private final Profile profile;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private Account account;
|
||||
private final String selectedVersion;
|
||||
private Path scriptFile;
|
||||
private final VersionSetting setting;
|
||||
private final GameSettings.Effective setting;
|
||||
private LauncherVisibility launcherVisibility;
|
||||
private boolean showLogs;
|
||||
private QuickPlayOption quickPlayOption;
|
||||
private boolean disableOfflineSkin = false;
|
||||
|
||||
public LauncherHelper(Profile profile, Account account, String selectedVersion) {
|
||||
this.profile = Objects.requireNonNull(profile);
|
||||
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
|
||||
this.gameInstance = Objects.requireNonNull(gameInstance);
|
||||
this.account = Objects.requireNonNull(account);
|
||||
this.selectedVersion = Objects.requireNonNull(selectedVersion);
|
||||
this.setting = profile.getVersionSetting(selectedVersion);
|
||||
this.launcherVisibility = setting.getLauncherVisibility();
|
||||
this.showLogs = setting.isShowLogs();
|
||||
this.launchingStepsPane.setTitle(i18n("version.launch"));
|
||||
this.setting = gameInstance.getEffectiveSettings();
|
||||
this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty);
|
||||
this.showLogs = setting.getInheritable(GameSettings::showLogsProperty);
|
||||
this.launchingStepsPane.setTitle(i18n("instance.launch"));
|
||||
}
|
||||
|
||||
public HMCLGameInstance getGameInstance() {
|
||||
return gameInstance;
|
||||
}
|
||||
|
||||
private HMCLGameRepository repository() {
|
||||
return gameInstance.getRepository();
|
||||
}
|
||||
|
||||
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
|
||||
@@ -128,7 +138,7 @@ public final class LauncherHelper {
|
||||
public void launch() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
LOG.info("Launching game version: " + selectedVersion);
|
||||
LOG.info("Launching game instance: " + gameInstance.getId());
|
||||
|
||||
Controllers.dialog(launchingStepsPane);
|
||||
launch0();
|
||||
@@ -139,49 +149,55 @@ public final class LauncherHelper {
|
||||
launch();
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
DefaultDependencyManager dependencyManager = profile.getDependency();
|
||||
AtomicReference<Version> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion)));
|
||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
||||
boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion);
|
||||
HMCLGameRepository repository = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
// Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch.
|
||||
var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest()));
|
||||
boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally();
|
||||
CountDownLatch launchingLatch = new CountDownLatch(1);
|
||||
List<String> javaAgents = new ArrayList<>(0);
|
||||
List<String> javaArguments = new ArrayList<>(0);
|
||||
|
||||
AtomicReference<JavaRuntime> javaVersionRef = new AtomicReference<>();
|
||||
|
||||
TaskExecutor executor = checkGameState(profile, setting, version.get())
|
||||
TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get())
|
||||
.thenComposeAsync(java -> {
|
||||
javaVersionRef.set(Objects.requireNonNull(java));
|
||||
version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments));
|
||||
if (setting.isNotCheckGame())
|
||||
launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments));
|
||||
if (setting.getInheritable(GameSettings::notCheckGameProperty))
|
||||
return null;
|
||||
return Task.allOf(
|
||||
dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck),
|
||||
dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck),
|
||||
Task.composeAsync(() -> {
|
||||
try {
|
||||
ModpackConfiguration<?> configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion));
|
||||
ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType());
|
||||
@Nullable ModpackConfiguration<?> configuration =
|
||||
gameInstance.readModpackConfiguration();
|
||||
if (configuration == null) return null;
|
||||
@Nullable ModpackProvider provider =
|
||||
ModpackHelper.getProviderByType(configuration.getType());
|
||||
if (provider == null) return null;
|
||||
else return provider.createCompletionTask(dependencyManager, selectedVersion);
|
||||
else return provider.createCompletionTask(
|
||||
dependencyManager,
|
||||
gameInstance);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer() instanceof Renderer.Driver renderer)
|
||||
|| !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer)
|
||||
|| renderer.mesaDriverName() == null)
|
||||
return null;
|
||||
|
||||
Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION);
|
||||
if (lib == null)
|
||||
return null;
|
||||
Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib);
|
||||
Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib);
|
||||
if (file.toAbsolutePath().toString().indexOf('=') >= 0) {
|
||||
LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader");
|
||||
return null;
|
||||
@@ -189,7 +205,7 @@ public final class LauncherHelper {
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
@@ -199,23 +215,23 @@ public final class LauncherHelper {
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null))
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
|
||||
.thenComposeAsync(() -> {
|
||||
if (config().getAllowAutoAgent()
|
||||
|| setting.isNoJVMArgs()
|
||||
|| setting.isNoOptimizingJVMArgs()
|
||||
|| Boolean.TRUE.equals(config().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> {
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
config().setAllowAutoAgent(true);
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
enableAutoAgentForCurrentSetting();
|
||||
future.complete(null);
|
||||
}, () -> {
|
||||
config().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
state().getShownTips().put(LWJGL_3_4_1_TIP, true);
|
||||
future.complete(null);
|
||||
});
|
||||
});
|
||||
@@ -224,8 +240,8 @@ public final class LauncherHelper {
|
||||
})
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -233,18 +249,48 @@ public final class LauncherHelper {
|
||||
launchOptionsBuilder.setQuickPlayOption(quickPlayOption);
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && setting.getInheritable(GameSettings::highPerformanceProperty)) {
|
||||
@Nullable String javaPathGpuReg = FileUtils.getAbsolutePath(javaVersionRef.get().getBinary());
|
||||
try {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
if (reg != null) {
|
||||
Object current = reg.queryValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg
|
||||
);
|
||||
if (current == null) {
|
||||
boolean result = reg.setValue(
|
||||
WinReg.HKEY.HKEY_CURRENT_USER,
|
||||
"Software\\Microsoft\\DirectX\\UserGpuPreferences",
|
||||
javaPathGpuReg,
|
||||
"GpuPreference=2;"
|
||||
);
|
||||
if (result)
|
||||
LOG.info("Successfully applied high performance GPU preference for java: " + javaPathGpuReg);
|
||||
else
|
||||
LOG.warning("Failed to apply high performance GPU preference for java: " + javaPathGpuReg);
|
||||
} else {
|
||||
LOG.info("GPU preference for %s already exists: %s".formatted(javaPathGpuReg, current));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to apply high performance GPU preference", e);
|
||||
}
|
||||
}
|
||||
|
||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10));
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
repository,
|
||||
version.get(),
|
||||
gameInstance,
|
||||
launchManifest.get(),
|
||||
authInfo,
|
||||
launchOptions,
|
||||
launcherVisibility == LauncherVisibility.CLOSE
|
||||
? null // Unnecessary to start listening to game process output when close launcher immediately after game launched.
|
||||
: new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent())
|
||||
: new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0)
|
||||
);
|
||||
}).thenComposeAsync(launcher -> { // launcher is prev task's result
|
||||
if (scriptFile == null) {
|
||||
@@ -268,7 +314,7 @@ public final class LauncherHelper {
|
||||
} else {
|
||||
runLater(() -> {
|
||||
launchingStepsPane.fireEvent(new DialogCloseEvent());
|
||||
Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
Controllers.dialog(i18n("instance.launch_script.success", FileUtils.getAbsolutePath(scriptFile)));
|
||||
});
|
||||
}
|
||||
}).withFakeProgress(
|
||||
@@ -307,9 +353,8 @@ public final class LauncherHelper {
|
||||
} else if (ex instanceof NotDecompressingNativesException) {
|
||||
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
||||
} else if (ex instanceof LibraryDownloadException) {
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
@@ -323,8 +368,7 @@ public final class LauncherHelper {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
@@ -339,8 +383,7 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.injector_download_failure");
|
||||
} else if (ex instanceof CharacterDeletedException) {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
@@ -370,7 +413,7 @@ public final class LauncherHelper {
|
||||
message = StringUtils.getStackTrace(ex);
|
||||
}
|
||||
Controllers.dialog(message,
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"),
|
||||
scriptFile == null ? i18n("launch.failed") : i18n("instance.launch_script.failed"),
|
||||
MessageType.ERROR);
|
||||
}
|
||||
}
|
||||
@@ -383,21 +426,22 @@ public final class LauncherHelper {
|
||||
executor.start();
|
||||
}
|
||||
|
||||
private static Task<JavaRuntime> checkGameState(Profile profile, VersionSetting setting, Version version) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null));
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT));
|
||||
private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion());
|
||||
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||
|
||||
Task<JavaRuntime> getJavaTask = Task.supplyAsync(() -> {
|
||||
try {
|
||||
return setting.getJava(gameVersion, version);
|
||||
return setting.getJava(gameVersion, manifest);
|
||||
} catch (InterruptedException e) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
});
|
||||
Task<JavaRuntime> task;
|
||||
if (setting.isNotCheckJVM()) {
|
||||
JavaVersionType javaVersionType = setting.getInheritable(GameSettings::javaTypeProperty);
|
||||
if (setting.getInheritable(GameSettings::notCheckJVMProperty)) {
|
||||
task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault()));
|
||||
} else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
|
||||
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
|
||||
if (java != null) {
|
||||
return Task.completed(java);
|
||||
@@ -410,14 +454,14 @@ public final class LauncherHelper {
|
||||
List<GameJavaVersion> supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM);
|
||||
|
||||
GameJavaVersion targetJavaVersion = null;
|
||||
if (setting.getJavaVersionType() == JavaVersionType.VERSION) {
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
try {
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion());
|
||||
int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty));
|
||||
GameJavaVersion minimumJavaVersion = null;
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,18 +483,18 @@ public final class LauncherHelper {
|
||||
}
|
||||
} else {
|
||||
if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
Optional<String> cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM);
|
||||
if (cleanroomVersion.isPresent()) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get());
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null) {
|
||||
targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetJavaVersion == null)
|
||||
targetJavaVersion = version.getJavaVersion();
|
||||
targetJavaVersion = manifest.javaVersion();
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||
downloadJava(targetJavaVersion, profile)
|
||||
downloadJava(targetJavaVersion, gameInstance.getRepository())
|
||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(downloadedJava);
|
||||
@@ -476,8 +520,8 @@ public final class LauncherHelper {
|
||||
|
||||
if (java != null) {
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violatedMandatoryConstraints.add(constraint);
|
||||
} else {
|
||||
@@ -493,7 +537,7 @@ public final class LauncherHelper {
|
||||
Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user"));
|
||||
|
||||
if (java == null || !violatedMandatoryConstraints.isEmpty()) {
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version);
|
||||
JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, manifest);
|
||||
if (suggestedJava != null) {
|
||||
FXUtils.runInFX(() -> {
|
||||
Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> {
|
||||
@@ -513,21 +557,20 @@ public final class LauncherHelper {
|
||||
} else {
|
||||
GameJavaVersion gameJavaVersion;
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM)
|
||||
.orElse("");
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
|
||||
gameJavaVersion = !cleanroomVersion.isEmpty()
|
||||
gameJavaVersion = cleanroomVersion != null
|
||||
? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion)
|
||||
: GameJavaVersion.JAVA_21;
|
||||
} else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON))
|
||||
gameJavaVersion = version.getJavaVersion();
|
||||
gameJavaVersion = manifest.javaVersion();
|
||||
else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
|
||||
gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion);
|
||||
else
|
||||
gameJavaVersion = null;
|
||||
|
||||
if (gameJavaVersion != null) {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
if (throwable == null) {
|
||||
setting.setJavaAutoSelected();
|
||||
future.complete(downloadedJava);
|
||||
@@ -540,7 +583,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) {
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)) {
|
||||
FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction));
|
||||
return result;
|
||||
} else {
|
||||
@@ -577,7 +620,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
// 32-bit JVM cannot make use of too much memory.
|
||||
if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
if (java.getBits() == Bits.BIT_32 && !setting.getInheritable(GameSettings::autoMemoryProperty) && setting.getMaxMemory() > 1.5 * 1024) {
|
||||
// 1.5 * 1024 is an inaccurate number.
|
||||
// Actual memory limit depends on operating system and memory.
|
||||
suggestions.add(i18n("launch.advice.too_large_memory_for_32bit"));
|
||||
@@ -597,7 +640,7 @@ public final class LauncherHelper {
|
||||
break;
|
||||
case MODDED_JAVA_16:
|
||||
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
|
||||
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
|
||||
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
|
||||
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
|
||||
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
||||
else
|
||||
@@ -610,8 +653,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||
break;
|
||||
case CLEANROOM: {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
|
||||
if (!cleanroomVersion.isEmpty())
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null)
|
||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||
else
|
||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||
@@ -624,8 +667,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modlauncher8"));
|
||||
break;
|
||||
case VANILLA_X86:
|
||||
if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER
|
||||
&& isCompatibleWithX86Java()) {
|
||||
if (!setting.getInheritable(GameSettings::useCustomNativesProperty)
|
||||
&& Platform.isSupportedTranslationX86_64()) {
|
||||
suggestions.add(i18n("launch.advice.vanilla_x86.translation"));
|
||||
}
|
||||
break;
|
||||
@@ -636,24 +679,24 @@ public final class LauncherHelper {
|
||||
|
||||
// Cannot allocate too much memory exceeding free space.
|
||||
long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
|
||||
if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
if (totalMemorySizeMB > 0 && !setting.getInheritable(GameSettings::autoMemoryProperty) && totalMemorySizeMB < setting.getMaxMemory()) {
|
||||
suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB));
|
||||
}
|
||||
|
||||
VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE)
|
||||
VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE))
|
||||
.map(VersionNumber::asVersion)
|
||||
.orElse(null);
|
||||
|
||||
// Forge 2760~2773 will crash game with LiteLoader.
|
||||
boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0);
|
||||
boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
boolean hasLiteLoader = manifest.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader"));
|
||||
if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge2760_liteloader"));
|
||||
}
|
||||
|
||||
// OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions.
|
||||
boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0);
|
||||
boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
boolean hasOptiFine = manifest.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine"));
|
||||
if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) {
|
||||
suggestions.add(i18n("launch.advice.forge28_2_2_optifine"));
|
||||
}
|
||||
@@ -685,14 +728,14 @@ public final class LauncherHelper {
|
||||
return task.withStage("launch.state.java");
|
||||
}
|
||||
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, Profile profile) {
|
||||
private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
|
||||
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()),
|
||||
i18n("message.warning"),
|
||||
MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider();
|
||||
DownloadProvider downloadProvider = repository.getDependency().getDownloadProvider();
|
||||
Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion)
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
@@ -756,7 +799,10 @@ public final class LauncherHelper {
|
||||
case HIDE_AND_REOPEN:
|
||||
runLater(() -> {
|
||||
Optional.ofNullable(Controllers.getStage())
|
||||
.ifPresent(Stage::show);
|
||||
.ifPresent(stage -> {
|
||||
stage.setIconified(false);
|
||||
stage.show();
|
||||
});
|
||||
});
|
||||
break;
|
||||
case KEEP:
|
||||
@@ -775,16 +821,22 @@ public final class LauncherHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed process listener.
|
||||
* Guarantee that one [JavaProcess], one [HMCLProcessListener].
|
||||
* Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
*/
|
||||
private void enableAutoAgentForCurrentSetting() {
|
||||
GameSettings.Instance instance = setting.getInstance();
|
||||
if (instance != null
|
||||
&& instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) {
|
||||
instance.allowAutoAgentProperty().setValue(true);
|
||||
} else {
|
||||
setting.getPreset().allowAutoAgentProperty().setValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The managed process listener.
|
||||
/// Guarantee that one Java [Process], one [HMCLProcessListener].
|
||||
/// Because every time we launched a game, we generates a new [HMCLProcessListener]
|
||||
private final class HMCLProcessListener implements ProcessListener {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final HMCLGameRepository repository;
|
||||
private final Version version;
|
||||
private final LaunchOptions launchOptions;
|
||||
private ManagedProcess process;
|
||||
private volatile boolean lwjgl;
|
||||
@@ -796,9 +848,7 @@ public final class LauncherHelper {
|
||||
private Thread submitLogThread;
|
||||
private LinkedBlockingQueue<Log> logBuffer;
|
||||
|
||||
public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.repository = repository;
|
||||
this.version = version;
|
||||
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.launchOptions = launchOptions;
|
||||
this.launchingLatch = launchingLatch;
|
||||
this.detectWindow = detectWindow;
|
||||
@@ -881,8 +931,10 @@ public final class LauncherHelper {
|
||||
runLater(() -> {
|
||||
// If application was stopped and execution services did not finish termination,
|
||||
// these codes will be executed.
|
||||
if (Controllers.getStage() != null) {
|
||||
Controllers.getStage().hide();
|
||||
@Nullable Stage stage = Controllers.getStage();
|
||||
if (stage != null) {
|
||||
stage.setIconified(false);
|
||||
stage.hide();
|
||||
launchingLatch.countDown();
|
||||
}
|
||||
});
|
||||
@@ -923,7 +975,7 @@ public final class LauncherHelper {
|
||||
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
||||
if (showLogs) {
|
||||
if (level == null)
|
||||
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
logBuffer.add(new Log(log, level));
|
||||
} else {
|
||||
lock.lock();
|
||||
@@ -955,7 +1007,7 @@ public final class LauncherHelper {
|
||||
@Override
|
||||
public void onExit(int exitCode, ExitType exitType) {
|
||||
if (showLogs) {
|
||||
logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
logBuffer.add(new Log(String.format("[%s] [HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", TIME_FORMATTER.format(Instant.now()), exitCode, exitCode, exitType), Log4jLevel.INFO));
|
||||
submitLogThread.interrupt();
|
||||
try {
|
||||
submitLogThread.join();
|
||||
@@ -981,8 +1033,8 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (exitType != ExitType.NORMAL) {
|
||||
repository.markVersionLaunchedAbnormally(version.getId());
|
||||
runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show());
|
||||
gameInstance.markLaunchedAbnormally();
|
||||
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||
}
|
||||
|
||||
checkExit();
|
||||
|
||||
+63
-31
@@ -18,12 +18,12 @@
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.RemoteMod;
|
||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
import org.jackhuang.hmcl.ui.versions.ModTranslations;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.ui.instances.ModTranslations;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -32,14 +32,22 @@ import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public abstract class LocalizedRemoteModRepository implements RemoteModRepository {
|
||||
public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepository {
|
||||
private static final int CONTAIN_CHINESE_WEIGHT = 10;
|
||||
|
||||
private static final int INITIAL_CAPACITY = 16;
|
||||
|
||||
protected abstract RemoteModRepository getBackedRemoteModRepository();
|
||||
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
|
||||
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
@Override
|
||||
public String getApiBaseUrl() {
|
||||
return getBackedRemoteModRepository().getApiBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseUrl() {
|
||||
return getBackedRemoteModRepository().getBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
@@ -47,46 +55,55 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
||||
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
|
||||
if (englishSearchFiltersSet.contains(englishWord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
englishSearchFiltersSet.add(englishWord);
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(getType()).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count >= 3) break;
|
||||
}
|
||||
|
||||
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
|
||||
if (englishSearchFiltersSet.isEmpty()) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
|
||||
{
|
||||
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
|
||||
SearchResult searchResult = null;
|
||||
List<RemoteAddon> remoteAddons = List.of();
|
||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
|
||||
remoteAddons = searchResult.results().toList();
|
||||
if (!remoteAddons.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (RemoteAddon remoteAddon : remoteAddons) {
|
||||
if (chineseIndex > englishIndex) {
|
||||
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
|
||||
continue;
|
||||
}
|
||||
|
||||
RemoteMod remoteMod = iterator.next();
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
searchResultArray[chineseIndex++] = remoteMod;
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
searchResultArray[englishIndex--] = remoteMod;
|
||||
searchResultArray[englishIndex--] = remoteAddon;
|
||||
}
|
||||
}
|
||||
totalPages = searchResult.getTotalPages();
|
||||
totalPages = searchResult.totalPages();
|
||||
}
|
||||
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> {
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
@@ -110,22 +127,37 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getModById(downloadProvider, id);
|
||||
public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getModFile(modId, fileId);
|
||||
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteMod.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RemoteAddon.Version> getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException {
|
||||
return getBackedRemoteModRepository().getVersionPageUrl(version);
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,18 @@ package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
|
||||
public final class Log {
|
||||
public static final int DEFAULT_LOG_LINES = 2000;
|
||||
|
||||
public static int getLogLines() {
|
||||
Integer lines = config().getLogLines();
|
||||
Integer lines = settings().logLinesProperty().get();
|
||||
return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES;
|
||||
}
|
||||
|
||||
private final String log;
|
||||
private Log4jLevel level;
|
||||
private boolean selected = false;
|
||||
|
||||
public Log(String log) {
|
||||
this.log = log;
|
||||
@@ -57,14 +56,6 @@ public final class Log {
|
||||
return level;
|
||||
}
|
||||
|
||||
public boolean isSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return log;
|
||||
|
||||
@@ -17,15 +17,18 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -42,22 +45,24 @@ public final class LogExporter {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> exportLogs(
|
||||
Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript,
|
||||
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
|
||||
PathMatcher logMatcher) {
|
||||
Path runDirectory = gameRepository.getRunDirectory(versionId);
|
||||
Path baseDirectory = gameRepository.getBaseDirectory();
|
||||
List<String> versions = new ArrayList<>();
|
||||
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
|
||||
Path runDirectory = options.getGameDir();
|
||||
List<GameInstanceID> instances = new ArrayList<>();
|
||||
|
||||
String currentVersionId = versionId;
|
||||
HashSet<String> resolvedSoFar = new HashSet<>();
|
||||
GameInstanceID currentInstanceId = instance.id;
|
||||
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
|
||||
while (true) {
|
||||
if (resolvedSoFar.contains(currentVersionId)) break;
|
||||
resolvedSoFar.add(currentVersionId);
|
||||
Version currentVersion = gameRepository.getVersion(currentVersionId);
|
||||
versions.add(currentVersionId);
|
||||
if (resolvedSoFar.contains(currentInstanceId)) break;
|
||||
resolvedSoFar.add(currentInstanceId);
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
|
||||
if (currentInstance == null)
|
||||
break;
|
||||
instances.add(currentInstanceId);
|
||||
|
||||
if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
|
||||
currentVersionId = currentVersion.getInheritsFrom();
|
||||
if (currentInstance.getManifest().inheritsFrom() != null) {
|
||||
currentInstanceId = currentInstance.getManifest().inheritsFrom();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -74,10 +79,12 @@ public final class LogExporter {
|
||||
zipper.putTextFile(logs, "minecraft.log");
|
||||
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
||||
|
||||
for (String id : versions) {
|
||||
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
|
||||
if (Files.exists(versionJson)) {
|
||||
zipper.putFile(versionJson, id + ".json");
|
||||
for (GameInstanceID id : instances) {
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id);
|
||||
if (currentInstance != null) {
|
||||
try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) {
|
||||
JsonUtils.GSON.toJson(currentInstance.getManifest(), writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -17,43 +17,39 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Profile profile;
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) {
|
||||
this.profile = profile;
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
Path subdirectory;
|
||||
try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) {
|
||||
subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs);
|
||||
}
|
||||
String subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), zipFile);
|
||||
|
||||
Path dest = Paths.get("externalgames").resolve(name);
|
||||
|
||||
setResult(dest);
|
||||
|
||||
new Unzipper(zipFile, dest)
|
||||
.setSubDirectory(subdirectory.toString())
|
||||
.setSubDirectory(subdirectory)
|
||||
.setTerminateIfSubDirectoryNotExists()
|
||||
.setEncoding(charset)
|
||||
.unzip();
|
||||
|
||||
@@ -20,34 +20,35 @@ package org.jackhuang.hmcl.game;
|
||||
import com.google.gson.JsonParseException;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.mod.*;
|
||||
import org.jackhuang.hmcl.mod.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.VersionSetting;
|
||||
import org.jackhuang.hmcl.modpack.*;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCComponents;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -55,12 +56,13 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.toIterable;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/// Utilities for reading, installing, and applying modpack-specific game settings.
|
||||
@NotNullByDefault
|
||||
public final class ModpackHelper {
|
||||
private ModpackHelper() {
|
||||
}
|
||||
@@ -107,8 +109,8 @@ public final class ModpackHelper {
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
||||
try {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||
throw new ManuallyCreatedModpackException(file);
|
||||
} catch (IOException e) {
|
||||
// ignore it
|
||||
@@ -117,28 +119,35 @@ public final class ModpackHelper {
|
||||
throw new UnsupportedModpackException(file.toString());
|
||||
}
|
||||
|
||||
public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException {
|
||||
Path root = fs.getPath("/");
|
||||
if (isMinecraftDirectory(root)) return root;
|
||||
try (Stream<Path> firstLayer = Files.list(root)) {
|
||||
for (Path dir : toIterable(firstLayer)) {
|
||||
if (isMinecraftDirectory(dir)) return dir;
|
||||
public static String findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, Path zipPath)
|
||||
throws IOException, UnsupportedModpackException {
|
||||
|
||||
try (Stream<Path> secondLayer = Files.list(dir)) {
|
||||
for (Path subdir : toIterable(secondLayer)) {
|
||||
if (isMinecraftDirectory(subdir)) return subdir;
|
||||
try (ArchiveFileTree<?, ?> tree = ArchiveFileTree.open(zipPath)) {
|
||||
ArchiveFileTree.Dir<?> rootDir = tree.getRoot();
|
||||
|
||||
if (isMinecraftDirectory(rootDir)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
for (ArchiveFileTree.Dir<?> firstLayer : rootDir.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(firstLayer)) {
|
||||
return firstLayer.getName();
|
||||
}
|
||||
|
||||
for (ArchiveFileTree.Dir<?> secondLayer : firstLayer.getSubDirs().values()) {
|
||||
if (isMinecraftDirectory(secondLayer)) {
|
||||
return firstLayer.getName() + "/" + secondLayer.getName();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
throw new UnsupportedModpackException(modpackName);
|
||||
}
|
||||
|
||||
private static boolean isMinecraftDirectory(Path path) {
|
||||
return Files.isDirectory(path.resolve("versions")) &&
|
||||
(path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path)));
|
||||
private static boolean isMinecraftDirectory(ArchiveFileTree.Dir<?> dir) {
|
||||
return dir.getSubDirs().containsKey("versions") && (dir.isRoot() || ".minecraft".equals(dir.getName()));
|
||||
}
|
||||
|
||||
public static ModpackConfiguration<?> readModpackConfiguration(Path file) throws IOException {
|
||||
@@ -149,16 +158,10 @@ public final class ModpackHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -168,7 +171,7 @@ public final class ModpackHelper {
|
||||
}
|
||||
};
|
||||
|
||||
return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)
|
||||
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
@@ -177,30 +180,46 @@ public final class ModpackHelper {
|
||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||
}
|
||||
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) {
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
|
||||
if (isExternalGameNameConflicts(name)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
Profile newProfile = new Profile(name, location);
|
||||
newProfile.setUseRelativePath(true);
|
||||
Profiles.getProfiles().add(newProfile);
|
||||
Profiles.setSelectedProfile(newProfile);
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
LocalizedText.plain(name),
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) {
|
||||
profile.getRepository().markVersionAsModpack(name);
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) {
|
||||
return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null);
|
||||
}
|
||||
|
||||
/// Creates an install task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the new instance id
|
||||
/// @param modpack the parsed modpack
|
||||
/// @param iconUrl the optional icon URL, or `null`
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the install task
|
||||
public static Task<?> getInstallTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
Modpack modpack,
|
||||
@Nullable String iconUrl,
|
||||
@Nullable Set<String> excludedFiles) {
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
repository.refreshVersions();
|
||||
VersionSetting vs = repository.specializeVersionSetting(name);
|
||||
repository.undoMark(name);
|
||||
if (vs != null)
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
repository.refresh();
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -211,114 +230,174 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name))
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl)
|
||||
.whenComplete(Schedulers.javafx(), success, failure)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync())
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||
return new ModpackUpdateTask(
|
||||
instance,
|
||||
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param charset the archive encoding
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the update task
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
Charset charset,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name))
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
else
|
||||
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
|
||||
.thenComposeAsync(profile.getRepository().refreshVersionsAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
}
|
||||
|
||||
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
vs.setUsesGlobal(false);
|
||||
vs.setGameDirType(GameDirectoryType.VERSION_FOLDER);
|
||||
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
|
||||
if (c.isOverrideJavaLocation()) {
|
||||
vs.setJavaDir(Lang.nonNull(c.getJavaPath(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE);
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_CUSTOM_JAVA_PATH);
|
||||
setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM);
|
||||
setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideMemory()) {
|
||||
vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY
|
||||
));
|
||||
setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse(""));
|
||||
if (c.getMaxMemory() != null)
|
||||
vs.setMaxMemory(c.getMaxMemory());
|
||||
vs.setMinMemory(c.getMinMemory());
|
||||
setting.maxMemoryProperty().setValue(c.getMaxMemory());
|
||||
setting.minMemoryProperty().setValue(c.getMinMemory());
|
||||
}
|
||||
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideConsole()) {
|
||||
vs.setShowLogs(c.isShowConsole());
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS);
|
||||
setting.showLogsProperty().setValue(c.isShowConsole());
|
||||
}
|
||||
|
||||
if (c.isOverrideWindow()) {
|
||||
vs.setFullscreen(c.isFullscreen());
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_WINDOW_TYPE,
|
||||
GameSettings.PROPERTY_WIDTH,
|
||||
GameSettings.PROPERTY_HEIGHT
|
||||
));
|
||||
setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
|
||||
if (c.getWidth() != null)
|
||||
vs.setWidth(c.getWidth());
|
||||
setting.widthProperty().setValue(c.getWidth().doubleValue());
|
||||
if (c.getHeight() != null)
|
||||
vs.setHeight(c.getHeight());
|
||||
setting.heightProperty().setValue(c.getHeight().doubleValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
|
||||
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
if (c.isOverrideCommands()) {
|
||||
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND
|
||||
));
|
||||
setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), ""));
|
||||
setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), ""));
|
||||
}
|
||||
|
||||
if (c.isOverrideJavaArgs()) {
|
||||
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS);
|
||||
setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostUpdateTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.applyCommandAndJvmSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
|
||||
private static Task<Void> createMultiMCPostInstallTask(HMCLGameRepository repository, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
ModpackHelper.toVersionSetting(manifest, vs);
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
ModpackHelper.toGameSettings(manifest, setting);
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Void> createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) {
|
||||
private static Task<Void> createMcbbsPostInstallTask(HMCLGameRepository repository, McbbsModpackManifest manifest, GameInstanceID instanceId) {
|
||||
return Task.runAsync(Schedulers.javafx(), () -> {
|
||||
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
|
||||
if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory())
|
||||
vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory());
|
||||
GameSettings.Effective effective = repository.getEffectiveGameSettings(instanceId);
|
||||
if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) {
|
||||
GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettingsOrCreate(instanceId));
|
||||
setting.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE
|
||||
));
|
||||
setting.autoMemoryProperty().setValue(effective.getInheritable(GameSettings::autoMemoryProperty));
|
||||
setting.minMemoryProperty().setValue(effective.getInheritable(GameSettings::minMemoryProperty));
|
||||
setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory());
|
||||
setting.permSizeProperty().setValue(effective.getInheritable(GameSettings::permSizeProperty));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,10 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
@@ -50,7 +53,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
private String idToken;
|
||||
|
||||
private OAuthServer(int port) {
|
||||
super(port);
|
||||
super("localhost", port);
|
||||
|
||||
this.port = port;
|
||||
|
||||
@@ -172,6 +175,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
|
||||
public static class Factory implements OAuth.Callback {
|
||||
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
|
||||
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
|
||||
|
||||
@@ -199,6 +203,11 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loginCompletedDeviceCode() {
|
||||
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
@@ -235,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ public final class HMCLJavaRepository implements JavaRepository {
|
||||
update.put("component", gameJavaVersion.component());
|
||||
|
||||
Map<String, JavaLocalFiles.Local> files = new LinkedHashMap<>();
|
||||
result.remoteFiles().getFiles().forEach((path, file) -> {
|
||||
result.remoteFiles().files().forEach((path, file) -> {
|
||||
if (file instanceof MojangJavaRemoteFiles.RemoteFile) {
|
||||
DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw");
|
||||
if (downloadInfo != null) {
|
||||
@@ -190,12 +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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,26 +17,32 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.java;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.setting.ConfigHolder;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.platform.UnsupportedPlatformException;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -74,8 +80,8 @@ public final class JavaManager {
|
||||
"Semeru"
|
||||
};
|
||||
|
||||
public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java"));
|
||||
public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java"));
|
||||
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) {
|
||||
@@ -122,16 +128,27 @@ public final class JavaManager {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
// Windows x86-64 platform is compatible with x86 programs
|
||||
return architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86;
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
|
||||
// Since Windows 10 Build 21277, Windows Arm64 has been compatible with x86-64 programs via translation
|
||||
if (architecture == Architecture.X86_64 && Platform.isSupportedTranslationX86_64())
|
||||
return true;
|
||||
|
||||
// Windows Arm64 is compatible with x86 programs via translation
|
||||
if (architecture == Architecture.X86)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
return architecture == Architecture.X86;
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
// macOS Arm64 compatible with x86-64 programs via Rosetta 2.
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
return architecture == Architecture.X86_64;
|
||||
break;
|
||||
}
|
||||
@@ -202,9 +219,11 @@ public final class JavaManager {
|
||||
|
||||
String pathString = javaRuntime.getBinary().toString();
|
||||
|
||||
ConfigHolder.globalConfig().getDisabledJava().remove(pathString);
|
||||
if (ConfigHolder.globalConfig().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
if (!SettingsManager.isUserSettingsReadOnly()) {
|
||||
SettingsManager.userSettings().getDisabledJava().remove(pathString);
|
||||
if (SettingsManager.userSettings().getUserJava().add(pathString)) {
|
||||
addJava(javaRuntime);
|
||||
}
|
||||
}
|
||||
return javaRuntime;
|
||||
});
|
||||
@@ -256,7 +275,7 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void addJava(JavaRuntime java) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (!oldMap.containsKey(java.getBinary())) {
|
||||
@@ -267,12 +286,12 @@ public final class JavaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(JavaRuntime java) throws InterruptedException {
|
||||
removeJava(java.getBinary());
|
||||
}
|
||||
|
||||
// FXThread
|
||||
@FXThread
|
||||
public static void removeJava(Path realPath) throws InterruptedException {
|
||||
Map<Path, JavaRuntime> oldMap = getAllJavaMap();
|
||||
if (oldMap.containsKey(realPath)) {
|
||||
@@ -295,13 +314,13 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, version);
|
||||
public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameInstanceManifest manifest) throws InterruptedException {
|
||||
return findSuitableJava(getAllJava(), gameVersion, manifest);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, Version version) {
|
||||
LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null;
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
|
||||
GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null;
|
||||
|
||||
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
@@ -322,8 +341,8 @@ public final class JavaManager {
|
||||
boolean violationSuggested = false;
|
||||
|
||||
for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) {
|
||||
if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, version, java, analyzer)) {
|
||||
if (constraint.appliesToVersion(gameVersion, manifest, java, analyzer)) {
|
||||
if (!constraint.checkJava(gameVersion, manifest, java, analyzer)) {
|
||||
if (constraint.isMandatory()) {
|
||||
violationMandatory = true;
|
||||
} else {
|
||||
@@ -354,7 +373,7 @@ public final class JavaManager {
|
||||
// search java
|
||||
|
||||
private static Map<Path, JavaRuntime> searchPotentialJavaExecutables(boolean useCache) {
|
||||
Searcher searcher = new Searcher(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("javaCache.json"));
|
||||
Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json"));
|
||||
if (useCache)
|
||||
searcher.loadCache();
|
||||
|
||||
@@ -364,13 +383,13 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64)
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
if (Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86_64);
|
||||
searcher.searchAllJavaInRepository(Platform.WINDOWS_X86);
|
||||
}
|
||||
break;
|
||||
case MACOS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64)
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64())
|
||||
searcher.searchAllJavaInRepository(Platform.MACOS_X86_64);
|
||||
break;
|
||||
}
|
||||
@@ -418,7 +437,7 @@ public final class JavaManager {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
@@ -457,7 +476,7 @@ public final class JavaManager {
|
||||
|
||||
searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks"));
|
||||
|
||||
for (String javaPath : ConfigHolder.globalConfig().getUserJava()) {
|
||||
for (String javaPath : SettingsManager.userSettings().getUserJava()) {
|
||||
try {
|
||||
searcher.tryAddJavaExecutable(Path.of(javaPath));
|
||||
} catch (InvalidPathException e) {
|
||||
@@ -468,7 +487,7 @@ public final class JavaManager {
|
||||
JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA;
|
||||
if (currentJava != null
|
||||
&& !searcher.javaRuntimes.containsKey(currentJava.getBinary())
|
||||
&& !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
&& !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) {
|
||||
searcher.addResult(currentJava.getBinary(), currentJava);
|
||||
}
|
||||
|
||||
@@ -479,7 +498,7 @@ public final class JavaManager {
|
||||
it.isJDK() ? "JDK" : "JRE",
|
||||
it.getVersion(),
|
||||
it.getPlatform().getArchitecture().getDisplayName(),
|
||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
it.getBinary()))
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||
return searcher.javaRuntimes;
|
||||
@@ -674,7 +693,7 @@ public final class JavaManager {
|
||||
|
||||
if (javaRuntimes.containsKey(executable)
|
||||
|| failed.contains(executable)
|
||||
|| ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) {
|
||||
|| SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -784,13 +803,15 @@ public final class JavaManager {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) {
|
||||
if (Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify);
|
||||
}
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify);
|
||||
}
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.ARM64 && Platform.isSupportedTranslationX86_64()) {
|
||||
searchAllOfficialJava(directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,7 +841,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(programFiles);
|
||||
|
||||
@@ -34,33 +34,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
* @author Glavo
|
||||
*/
|
||||
@JsonAdapter(JavaManifest.Serializer.class)
|
||||
public final class JavaManifest {
|
||||
|
||||
private final JavaInfo info;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, Object> update;
|
||||
|
||||
@Nullable
|
||||
private final Map<String, JavaLocalFiles.Local> files;
|
||||
|
||||
public JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
this.info = info;
|
||||
this.update = update;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
public JavaInfo getInfo() {
|
||||
return info;
|
||||
}
|
||||
|
||||
public Map<String, Object> getUpdate() {
|
||||
return update;
|
||||
}
|
||||
|
||||
public Map<String, JavaLocalFiles.Local> getFiles() {
|
||||
return files;
|
||||
}
|
||||
public record JavaManifest(JavaInfo info, @Nullable Map<String, Object> update, @Nullable Map<String, JavaLocalFiles.Local> files) {
|
||||
|
||||
public static final class Serializer implements JsonSerializer<JavaManifest>, JsonDeserializer<JavaManifest> {
|
||||
|
||||
@@ -69,16 +43,16 @@ public final class JavaManifest {
|
||||
@Override
|
||||
public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonObject res = new JsonObject();
|
||||
res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.getInfo().getVersion());
|
||||
res.addProperty("java.vendor", src.getInfo().getVendor());
|
||||
res.addProperty("os.name", src.info().getPlatform().getOperatingSystem().getCheckedName());
|
||||
res.addProperty("os.arch", src.info().getPlatform().getArchitecture().getCheckedName());
|
||||
res.addProperty("java.version", src.info().getVersion());
|
||||
res.addProperty("java.vendor", src.info().getVendor());
|
||||
|
||||
if (src.getUpdate() != null)
|
||||
res.add("update", context.serialize(src.getUpdate()));
|
||||
if (src.update() != null)
|
||||
res.add("update", context.serialize(src.update()));
|
||||
|
||||
if (src.getFiles() != null)
|
||||
res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE));
|
||||
if (src.files() != null)
|
||||
res.add("files", context.serialize(src.files(), LOCAL_FILES_TYPE));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores account metadata records in a detached JSON file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory and stores account metadata in the `accounts` list.
|
||||
/// Private account data is persisted separately in [AccountPrivateData].
|
||||
@JsonAdapter(AccountMetadataStore.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountMetadataStore extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account metadata store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("accounts", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty account metadata store.
|
||||
AccountMetadataStore() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(accounts);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates an account metadata store from already serialized account records.
|
||||
///
|
||||
/// @param records the serialized account records
|
||||
/// @return an account metadata store containing the given records
|
||||
static AccountMetadataStore fromRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.getAccounts().setAll(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Creates a copy of this account metadata store with replacement account records.
|
||||
///
|
||||
/// @param records the replacement account records
|
||||
/// @return a copy preserving this store's schema and unknown top-level members
|
||||
AccountMetadataStore copyWithRecords(List<JsonObject> records) {
|
||||
AccountMetadataStore result = new AccountMetadataStore();
|
||||
result.setSchema(getSchema());
|
||||
result.getAccounts().setAll(records);
|
||||
result.unknownFields.putAll(unknownFields);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The schema used by this account metadata store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account metadata store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account metadata store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this account metadata store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account metadata store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Serialized account records.
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<JsonObject> accounts = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns serialized account records.
|
||||
public ObservableList<JsonObject> getAccounts() {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountMetadataStore].
|
||||
static final class Adapter extends ObservableSetting.Adapter<AccountMetadataStore> {
|
||||
/// Creates an empty account metadata store for deserialization.
|
||||
@Override
|
||||
protected AccountMetadataStore createInstance() {
|
||||
return new AccountMetadataStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonSerializationContext;
|
||||
import com.google.gson.JsonSerializer;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores private account data in a protected payload.
|
||||
@JsonAdapter(AccountPrivateData.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AccountPrivateData extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this account private data store.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("account-private-data", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// JVM system property selecting the account private data protection mode.
|
||||
static final String PROTECTION_PROPERTY = "hmcl.account.privateData.protection";
|
||||
|
||||
/// Creates an empty account private data store.
|
||||
AccountPrivateData() {
|
||||
tracker.markDirty(schema);
|
||||
tracker.markDirty(privateData);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this account private data store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Private account data keyed by account ID.
|
||||
private final ObservableMap<AccountID, JsonObject> privateData =
|
||||
FXCollections.observableMap(new LinkedHashMap<>());
|
||||
|
||||
/// Whether this account private data store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this account private data store file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this account private data store file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Returns private account data keyed by account ID.
|
||||
ObservableMap<AccountID, JsonObject> getPrivateData() {
|
||||
return privateData;
|
||||
}
|
||||
|
||||
/// Returns the protection mode selected for writing account private data.
|
||||
private static ProtectedPayload.ProtectionMode protectionMode() {
|
||||
return ProtectedPayload.ProtectionMode.fromConfiguredId(System.getProperty(PROTECTION_PROPERTY));
|
||||
}
|
||||
|
||||
/// Returns whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this account private data store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Finds the first private data entry matching an account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param privateDataStores private data stores searched in order
|
||||
/// @return the first matching private data object, or `null` if no store contains one
|
||||
static @Nullable JsonObject findPrivateData(
|
||||
AccountID accountID,
|
||||
List<AccountPrivateData> privateDataStores) {
|
||||
for (AccountPrivateData privateDataStore : privateDataStores) {
|
||||
@Nullable JsonObject accountPrivateData = privateDataStore.privateData.get(accountID);
|
||||
if (accountPrivateData != null) {
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns whether this store contains private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether this store contains private data for the account ID
|
||||
boolean containsPrivateData(AccountID accountID) {
|
||||
return privateData.containsKey(accountID);
|
||||
}
|
||||
|
||||
/// Removes private data for the account ID.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @return whether private data was removed
|
||||
boolean removePrivateData(AccountID accountID) {
|
||||
return privateData.remove(accountID) != null;
|
||||
}
|
||||
|
||||
/// Stores private data for the account ID, or removes existing private data when the object is empty.
|
||||
///
|
||||
/// @param accountID the stable account ID
|
||||
/// @param accountPrivateData the private account data
|
||||
void putPrivateData(AccountID accountID, JsonObject accountPrivateData) {
|
||||
if (accountPrivateData.isEmpty()) {
|
||||
privateData.remove(accountID);
|
||||
} else {
|
||||
privateData.put(accountID, accountPrivateData);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces this private data store with another store.
|
||||
///
|
||||
/// @param other the private data store to copy from
|
||||
void replaceWith(AccountPrivateData other) {
|
||||
privateData.clear();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : other.privateData.entrySet()) {
|
||||
putPrivateData(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AccountPrivateData].
|
||||
static final class Adapter implements JsonSerializer<AccountPrivateData>,
|
||||
com.google.gson.JsonDeserializer<AccountPrivateData> {
|
||||
/// Creates the JSON payload protected inside an account private data file.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to serialize
|
||||
/// @param context the JSON serialization context
|
||||
/// @return the plain payload before protection
|
||||
private static JsonArray createPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonSerializationContext context) {
|
||||
JsonArray payload = new JsonArray();
|
||||
for (Map.Entry<AccountID, JsonObject> entry : accountPrivateData.privateData.entrySet()) {
|
||||
JsonObject item = new JsonObject();
|
||||
item.add("accountID", context.serialize(entry.getKey(), AccountID.class));
|
||||
item.add("privateData", entry.getValue());
|
||||
payload.add(item);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Reads an account ID from a private data entry.
|
||||
private static @Nullable AccountID readAccountID(
|
||||
@Nullable JsonElement accountIDElement,
|
||||
JsonDeserializationContext context) {
|
||||
if (accountIDElement == null || accountIDElement.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return context.deserialize(accountIDElement, AccountID.class);
|
||||
} catch (JsonParseException | IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the protected payload into the given private data store.
|
||||
///
|
||||
/// @param accountPrivateData the private data store to update
|
||||
/// @param payload the revealed payload
|
||||
/// @param context the JSON deserialization context
|
||||
private static void readPayload(
|
||||
AccountPrivateData accountPrivateData,
|
||||
JsonArray payload,
|
||||
JsonDeserializationContext context) {
|
||||
for (JsonElement itemElement : payload) {
|
||||
if (!(itemElement instanceof JsonObject item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable AccountID accountID = readAccountID(item.get("accountID"), context);
|
||||
if (accountID == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement privateDataElement = item.get("privateData");
|
||||
if (privateDataElement == null || !privateDataElement.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
accountPrivateData.putPrivateData(accountID, privateDataElement.getAsJsonObject());
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the private data store as a protected payload envelope.
|
||||
@Override
|
||||
public JsonElement serialize(
|
||||
AccountPrivateData accountPrivateData,
|
||||
Type typeOfSrc,
|
||||
JsonSerializationContext context) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty(JsonSchema.PROPERTY_SCHEMA, accountPrivateData.getSchema().url());
|
||||
protectionMode().writePayload(result, createPayload(accountPrivateData, context));
|
||||
accountPrivateData.unknownFields.forEach(result::add);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Deserializes the private data store from a protected payload envelope.
|
||||
@Override
|
||||
public @Nullable AccountPrivateData deserialize(
|
||||
@Nullable JsonElement json,
|
||||
Type typeOfT,
|
||||
JsonDeserializationContext context) throws JsonParseException {
|
||||
if (json == null || json.isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
if (!(json instanceof JsonObject object)) {
|
||||
throw new JsonParseException("Account private data is not an object");
|
||||
}
|
||||
|
||||
AccountPrivateData accountPrivateData = new AccountPrivateData();
|
||||
Map<String, JsonElement> values = new LinkedHashMap<>(object.asMap());
|
||||
JsonElement schema = values.remove(JsonSchema.PROPERTY_SCHEMA);
|
||||
if (schema != null && schema.isJsonPrimitive() && schema.getAsJsonPrimitive().isString()) {
|
||||
accountPrivateData.setSchema(new JsonSchema(schema.getAsString()));
|
||||
}
|
||||
values.remove(ProtectedPayload.PROPERTY_PROTECTION);
|
||||
values.remove(ProtectedPayload.PROPERTY_NONCE);
|
||||
values.remove(ProtectedPayload.PROPERTY_PAYLOAD);
|
||||
accountPrivateData.unknownFields.putAll(values);
|
||||
|
||||
readPayload(accountPrivateData, ProtectedPayload.read(object, JsonArray.class), context);
|
||||
return accountPrivateData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
@@ -35,28 +35,22 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import org.jackhuang.hmcl.game.OAuthServer;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -74,7 +68,7 @@ public final class Accounts {
|
||||
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
|
||||
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
|
||||
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
|
||||
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||
public static final List<AccountFactory<?>> FACTORIES = List.of(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||
|
||||
// ==== login type / account factory mapping ====
|
||||
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
||||
@@ -120,9 +114,6 @@ public final class Accounts {
|
||||
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
||||
}
|
||||
|
||||
private static final String GLOBAL_PREFIX = "$GLOBAL:";
|
||||
private static final ObservableList<Map<Object, Object>> globalAccountStorages = FXCollections.observableArrayList();
|
||||
|
||||
private static final ObservableList<Account> accounts = observableArrayList(account -> new Observable[]{account});
|
||||
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
|
||||
|
||||
@@ -131,120 +122,219 @@ public final class Accounts {
|
||||
*/
|
||||
private static boolean initialized = false;
|
||||
|
||||
private static Map<Object, Object> getAccountStorage(Account account) {
|
||||
Map<Object, Object> storage = account.toStorage();
|
||||
storage.put("type", getLoginType(getAccountFactory(account)));
|
||||
return storage;
|
||||
private static SerializedAccount serializeAccount(Account account) {
|
||||
JsonObject metadata = new JsonObject();
|
||||
metadata.addProperty("type", getLoginType(getAccountFactory(account)));
|
||||
account.writeMetadata(metadata);
|
||||
JsonObject privateData = new JsonObject();
|
||||
account.writePrivateData(privateData);
|
||||
return new SerializedAccount(metadata, privateData);
|
||||
}
|
||||
|
||||
private static void updateAccountStorages() {
|
||||
// don't update the underlying storage before data loading is completed
|
||||
/// Ensures account IDs are unique across local and shared account metadata records before accounts are instantiated.
|
||||
private static AccountIDNormalization ensureUniqueAccountIDs() {
|
||||
Set<String> usedAccountIDs = new HashSet<>();
|
||||
boolean localChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.gameAccounts(),
|
||||
usedAccountIDs,
|
||||
false);
|
||||
boolean sharedChanged = LegacyConfigMigrator.assignAccountIDs(
|
||||
SettingsManager.userGameAccounts(),
|
||||
usedAccountIDs,
|
||||
true);
|
||||
return new AccountIDNormalization(localChanged, sharedChanged);
|
||||
}
|
||||
|
||||
/// Result of normalizing account IDs across loaded metadata stores.
|
||||
///
|
||||
/// @param localChanged whether the per-workspace account metadata changed
|
||||
/// @param sharedChanged whether the shared account metadata changed
|
||||
private record AccountIDNormalization(boolean localChanged, boolean sharedChanged) {
|
||||
}
|
||||
|
||||
/// Returns account IDs from metadata records.
|
||||
///
|
||||
/// @param first the first metadata record list
|
||||
/// @param second the second metadata record list
|
||||
/// @return account IDs found in the metadata records
|
||||
private static List<AccountID> getAccountIDs(List<JsonObject> first, List<JsonObject> second) {
|
||||
ArrayList<AccountID> accountIDs = new ArrayList<>(first.size() + second.size());
|
||||
addAccountIDs(accountIDs, first);
|
||||
addAccountIDs(accountIDs, second);
|
||||
return accountIDs;
|
||||
}
|
||||
|
||||
/// Adds account IDs from metadata records to a target list.
|
||||
///
|
||||
/// @param accountIDs the target account ID list
|
||||
/// @param metadataRecords the metadata records to read
|
||||
private static void addAccountIDs(List<AccountID> accountIDs, List<JsonObject> metadataRecords) {
|
||||
for (JsonObject metadata : metadataRecords) {
|
||||
@Nullable AccountID accountID = Account.getAccountID(metadata);
|
||||
if (accountID != null && !accountIDs.contains(accountID)) {
|
||||
accountIDs.add(accountID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateAccountMetadataRecords() {
|
||||
// don't update the underlying account records before data loading is completed
|
||||
// otherwise it might cause data loss
|
||||
if (!initialized)
|
||||
return;
|
||||
// update storage
|
||||
|
||||
ArrayList<Map<Object, Object>> global = new ArrayList<>();
|
||||
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
|
||||
ArrayList<JsonObject> globalMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> globalPrivateData = new LinkedHashMap<>();
|
||||
ArrayList<JsonObject> portableMetadata = new ArrayList<>();
|
||||
LinkedHashMap<AccountID, JsonObject> portablePrivateData = new LinkedHashMap<>();
|
||||
|
||||
for (Account account : accounts) {
|
||||
Map<Object, Object> storage = getAccountStorage(account);
|
||||
if (account.isPortable())
|
||||
portable.add(storage);
|
||||
else
|
||||
global.add(storage);
|
||||
}
|
||||
|
||||
if (!global.equals(globalAccountStorages))
|
||||
globalAccountStorages.setAll(global);
|
||||
if (!portable.equals(config().getAccountStorages()))
|
||||
config().getAccountStorages().setAll(portable);
|
||||
}
|
||||
|
||||
private static void loadGlobalAccountStorages() {
|
||||
Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json");
|
||||
if (Files.exists(globalAccountsFile)) {
|
||||
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
|
||||
globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load global accounts", e);
|
||||
SerializedAccount serialized = serializeAccount(account);
|
||||
if (account.isPortable()) {
|
||||
portableMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
portablePrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
} else {
|
||||
globalMetadata.add(serialized.metadata());
|
||||
if (!serialized.privateData().isEmpty()) {
|
||||
globalPrivateData.put(account.getAccountID(), serialized.privateData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalAccountStorages.addListener(onInvalidating(() ->
|
||||
FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages))));
|
||||
List<AccountID> retainedAccountIDs = getAccountIDs(globalMetadata, portableMetadata);
|
||||
if (!SettingsManager.isUserGameAccountsReadOnly())
|
||||
SettingsManager.updateUserGameAccounts(globalMetadata, globalPrivateData, retainedAccountIDs);
|
||||
if (!SettingsManager.isGameAccountsReadOnly())
|
||||
SettingsManager.updateGameAccounts(portableMetadata, portablePrivateData, retainedAccountIDs);
|
||||
}
|
||||
|
||||
private static Account parseAccount(Map<Object, Object> storage) {
|
||||
AccountFactory<?> factory = type2factory.get(storage.get("type"));
|
||||
/// Returns whether the account metadata and credential files selected by the portability flag are read-only.
|
||||
///
|
||||
/// @param portable whether the account is stored in the local account file
|
||||
public static boolean isAccountFilesReadOnly(boolean portable) {
|
||||
return portable ? SettingsManager.isGameAccountsReadOnly() : SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Returns whether the files containing the given account are read-only.
|
||||
public static boolean isAccountFilesReadOnly(Account account) {
|
||||
return isAccountFilesReadOnly(account.isPortable());
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be removed from its current account files.
|
||||
public static boolean canRemoveAccount(Account account) {
|
||||
return !isAccountFilesReadOnly(account);
|
||||
}
|
||||
|
||||
/// Returns whether the given account may be moved between local and user account files.
|
||||
public static boolean canMoveAccount(Account account) {
|
||||
return !SettingsManager.isGameAccountsReadOnly() && !SettingsManager.isUserGameAccountsReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account metadata and credential files selected by the portability flag.
|
||||
///
|
||||
/// @param portable whether the target account files are local
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(boolean portable) throws IOException {
|
||||
if (portable) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the account files containing the given account.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles(Account account) throws IOException {
|
||||
forceOverwriteAccountFiles(account.isPortable());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites both local and user account metadata and credential files.
|
||||
///
|
||||
/// @throws IOException if saving either file fails
|
||||
public static void forceOverwriteAccountFiles() throws IOException {
|
||||
if (SettingsManager.isGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteGameAccounts();
|
||||
}
|
||||
if (SettingsManager.isUserGameAccountsReadOnly()) {
|
||||
SettingsManager.forceOverwriteUserGameAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
private static Account parseAccount(JsonObject record, boolean portable) {
|
||||
AccountFactory<?> factory = type2factory.get(JsonUtils.getString(record, "type"));
|
||||
if (factory == null) {
|
||||
LOG.warning("Unrecognized account type: " + storage);
|
||||
LOG.warning("Unrecognized account type: " + describeAccountRecord(record));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return factory.fromStorage(storage);
|
||||
AccountID accountID = Account.readAccountID(record);
|
||||
return factory.fromStorage(record, SettingsManager.getAccountPrivateData(accountID, portable));
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load account: " + storage, e);
|
||||
LOG.warning("Failed to load account: " + describeAccountRecord(record), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when it's ready to load accounts from {@link ConfigHolder#config()}.
|
||||
*/
|
||||
static void init() {
|
||||
/// Serialized account metadata and private data.
|
||||
///
|
||||
/// @param metadata public metadata stored in `accounts.json`
|
||||
/// @param privateData private account data stored in account private data
|
||||
private record SerializedAccount(JsonObject metadata, JsonObject privateData) {
|
||||
}
|
||||
|
||||
/// Returns a safe account record description for diagnostics.
|
||||
private static String describeAccountRecord(JsonObject record) {
|
||||
AccountID accountID = Account.getAccountID(record);
|
||||
if (accountID != null) {
|
||||
return accountID.toString();
|
||||
}
|
||||
|
||||
String type = JsonUtils.getString(record, "type");
|
||||
return type != null ? "{type=" + type + "}" : "<unknown>";
|
||||
}
|
||||
|
||||
/// Called when it's ready to load accounts from [SettingsManager#settings()].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
if (!config().isAddedLittleSkin()) {
|
||||
AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/");
|
||||
|
||||
if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) {
|
||||
config().getAuthlibInjectorServers().add(0, littleSkin);
|
||||
}
|
||||
|
||||
config().setAddedLittleSkin(true);
|
||||
AccountIDNormalization accountIDNormalization = ensureUniqueAccountIDs();
|
||||
if (accountIDNormalization.localChanged()) {
|
||||
SettingsManager.saveGameAccountMetadataRecords();
|
||||
}
|
||||
if (accountIDNormalization.sharedChanged()) {
|
||||
SettingsManager.saveUserGameAccountMetadataRecords();
|
||||
}
|
||||
|
||||
loadGlobalAccountStorages();
|
||||
|
||||
// load accounts
|
||||
Account selected = null;
|
||||
for (Map<Object, Object> storage : config().getAccountStorages()) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, true);
|
||||
if (account != null) {
|
||||
account.setPortable(true);
|
||||
accounts.add(account);
|
||||
if (Boolean.TRUE.equals(storage.get("selected"))) {
|
||||
if (JsonUtils.getBoolean(record, "selected", false)) {
|
||||
selected = account;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (Map<Object, Object> storage : globalAccountStorages) {
|
||||
Account account = parseAccount(storage);
|
||||
for (JsonObject record : getUserAccountMetadataRecords()) {
|
||||
Account account = parseAccount(record, false);
|
||||
if (account != null) {
|
||||
accounts.add(account);
|
||||
}
|
||||
}
|
||||
|
||||
String selectedAccountIdentifier = config().getSelectedAccount();
|
||||
if (selected == null && selectedAccountIdentifier != null) {
|
||||
boolean portable = true;
|
||||
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
|
||||
portable = false;
|
||||
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
|
||||
}
|
||||
|
||||
AccountID selectedAccountID = settings().selectedAccountProperty().get();
|
||||
if (selected == null && selectedAccountID != null) {
|
||||
for (Account account : accounts) {
|
||||
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
|
||||
if (portable == account.isPortable()) {
|
||||
selected = account;
|
||||
break;
|
||||
} else if (selected == null) {
|
||||
selected = account;
|
||||
}
|
||||
if (account.getAccountID().equals(selectedAccountID)) {
|
||||
selected = account;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,15 +343,18 @@ public final class Accounts {
|
||||
selected = accounts.get(0);
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
for (Account account : accounts) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalConfig().isEnableOfflineAccount())
|
||||
if (!SettingsManager.isUserSettingsReadOnly()
|
||||
&& !SettingsManager.userSettings().enableOfflineAccountProperty().get())
|
||||
accounts.addListener(new ListChangeListener<Account>() {
|
||||
@Override
|
||||
public void onChanged(Change<? extends Account> change) {
|
||||
@@ -269,7 +362,8 @@ public final class Accounts {
|
||||
for (Account account : change.getAddedSubList()) {
|
||||
if (account instanceof MicrosoftAccount) {
|
||||
accounts.removeListener(this);
|
||||
globalConfig().setEnableOfflineAccount(true);
|
||||
UserSettings userSettings = userSettings();
|
||||
userSettings.enableOfflineAccountProperty().set(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,7 +375,7 @@ public final class Accounts {
|
||||
|
||||
InvalidationListener listener = o -> {
|
||||
// this method first checks whether the current selection is valid
|
||||
// if it's valid, the underlying storage will be updated
|
||||
// if it's valid, the underlying account records will be updated
|
||||
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
|
||||
Account account = selectedAccount.get();
|
||||
if (accounts.isEmpty()) {
|
||||
@@ -304,16 +398,16 @@ public final class Accounts {
|
||||
selectedAccount.addListener(onInvalidating(() -> {
|
||||
Account account = selectedAccount.get();
|
||||
if (account != null)
|
||||
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
|
||||
settings().selectedAccountProperty().set(account.getAccountID());
|
||||
else
|
||||
config().setSelectedAccount(null);
|
||||
settings().selectedAccountProperty().set(null);
|
||||
}));
|
||||
accounts.addListener(listener);
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
|
||||
accounts.addListener(onInvalidating(Accounts::updateAccountMetadataRecords));
|
||||
|
||||
initialized = true;
|
||||
|
||||
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
|
||||
|
||||
if (selected != null) {
|
||||
Account finalSelected = selected;
|
||||
@@ -326,7 +420,7 @@ public final class Accounts {
|
||||
});
|
||||
}
|
||||
|
||||
for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) {
|
||||
for (AuthlibInjectorServer server : getAuthlibInjectorServers()) {
|
||||
if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server)
|
||||
continue;
|
||||
Schedulers.io().execute(() -> {
|
||||
@@ -373,12 +467,12 @@ public final class Accounts {
|
||||
}
|
||||
|
||||
private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) {
|
||||
return config().getAuthlibInjectorServers().stream()
|
||||
return getAuthlibInjectorServers().stream()
|
||||
.filter(server -> url.equals(server.getUrl()))
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
return server;
|
||||
});
|
||||
}
|
||||
@@ -391,7 +485,8 @@ public final class Accounts {
|
||||
accounts.stream()
|
||||
.filter(AuthlibInjectorAccount.class::isInstance)
|
||||
.map(AuthlibInjectorAccount.class::cast)
|
||||
.filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(it -> !getAuthlibInjectorServers().contains(it.getServer()))
|
||||
.filter(Accounts::canRemoveAccount)
|
||||
.collect(toList())
|
||||
.forEach(accounts::remove);
|
||||
}
|
||||
@@ -468,7 +563,9 @@ public final class Accounts {
|
||||
}
|
||||
} else if (exception instanceof MicrosoftService.XBox400Exception) {
|
||||
return i18n("account.methods.microsoft.error.wrong_verify_method");
|
||||
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_license");
|
||||
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
|
||||
return i18n("account.methods.microsoft.error.no_character");
|
||||
} else if (exception instanceof MicrosoftService.NoXuiException) {
|
||||
return i18n("account.methods.microsoft.error.add_family");
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace authlib-injector authentication servers.
|
||||
///
|
||||
/// The JSON representation is saved as `config/authlib-injector-servers.json`
|
||||
/// under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(AuthlibInjectorServerList.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists.
|
||||
public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/";
|
||||
|
||||
/// The JSON schema supported by this authlib-injector server list.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty authlib-injector server list.
|
||||
public AuthlibInjectorServerList() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Creates the default authlib-injector server list for a newly created file.
|
||||
public static AuthlibInjectorServerList createDefault() {
|
||||
AuthlibInjectorServerList result = new AuthlibInjectorServerList();
|
||||
result.addLittleSkinIfAbsent();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Adds the bundled LittleSkin server when it is not already present.
|
||||
public void addLittleSkinIfAbsent() {
|
||||
if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) {
|
||||
getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL));
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema used by this authlib-injector server list file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this authlib-injector server list file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this server list may be saved back to `config/authlib-injector-servers.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/authlib-injector-servers.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector authentication servers available for account login.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<AuthlibInjectorServer> servers =
|
||||
FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector authentication servers available for account login.
|
||||
public ObservableList<AuthlibInjectorServer> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerList].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerList> {
|
||||
/// Creates an empty authlib-injector server list for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerList createInstance() {
|
||||
return new AuthlibInjectorServerList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Stores derived authlib-injector server metadata separately from the user-managed server list.
|
||||
///
|
||||
/// The JSON representation is saved as `cache/authlib-injector-server-metadata.json`
|
||||
/// under the current HMCL directory.
|
||||
@JsonAdapter(AuthlibInjectorServerMetadataCache.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
final class AuthlibInjectorServerMetadataCache extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this metadata cache.
|
||||
static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("authlib-injector-server-metadata", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty metadata cache.
|
||||
AuthlibInjectorServerMetadataCache() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this metadata cache file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this metadata cache file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this metadata cache file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current metadata cache file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this metadata cache may be saved back to `cache/authlib-injector-server-metadata.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current metadata cache file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Authlib-injector metadata entries.
|
||||
@SerializedName("servers")
|
||||
private final ObservableList<Entry> servers = FXCollections.observableArrayList();
|
||||
|
||||
/// Returns authlib-injector metadata entries.
|
||||
ObservableList<Entry> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// Initializes metadata for a server that is being connected to this cache.
|
||||
///
|
||||
/// @param server the server to initialize
|
||||
/// @param storeExistingMetadata whether metadata already present on the server should replace cached metadata
|
||||
void initialize(AuthlibInjectorServer server, boolean storeExistingMetadata) {
|
||||
if (storeExistingMetadata && server.getMetadataResponse().isPresent()) {
|
||||
store(server);
|
||||
} else {
|
||||
restore(server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores cached metadata into the given server when a valid cache entry exists.
|
||||
void restore(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entry entry = servers.get(index);
|
||||
String metadataResponse = entry.getMetadataResponse();
|
||||
if (metadataResponse.isEmpty()) {
|
||||
servers.remove(index);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
server.restoreMetadataCache(metadataResponse, entry.getMetadataTimestamp());
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Ignoring malformed authlib-injector server metadata cache: " + server.getUrl(), e);
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the current metadata response from the given server when it has one.
|
||||
void store(AuthlibInjectorServer server) {
|
||||
server.getMetadataResponse().ifPresent(metadataResponse -> {
|
||||
Entry entry = new Entry(server.getUrl(), metadataResponse, server.getMetadataTimestamp());
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.set(index, entry);
|
||||
} else {
|
||||
servers.add(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Removes cached metadata for the given server.
|
||||
void remove(AuthlibInjectorServer server) {
|
||||
int index = indexOf(server.getUrl());
|
||||
if (index >= 0) {
|
||||
servers.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the cache entry for the given normalized server URL.
|
||||
private int indexOf(String url) {
|
||||
for (int i = 0; i < servers.size(); i++) {
|
||||
if (url.equals(servers.get(i).getUrl())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Metadata cached for one authlib-injector server.
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
static final class Entry {
|
||||
/// Normalized authlib-injector server URL.
|
||||
@SerializedName("url")
|
||||
private @Nullable String url = "";
|
||||
|
||||
/// Raw metadata response returned by the authlib-injector endpoint.
|
||||
@SerializedName("metadataResponse")
|
||||
private @Nullable String metadataResponse = "";
|
||||
|
||||
/// Wall-clock time when the metadata response was fetched.
|
||||
@SerializedName("metadataTimestamp")
|
||||
private long metadataTimestamp;
|
||||
|
||||
/// Creates an empty metadata entry for deserialization.
|
||||
private Entry() {
|
||||
}
|
||||
|
||||
/// Creates a metadata entry.
|
||||
///
|
||||
/// @param url normalized authlib-injector server URL
|
||||
/// @param metadataResponse raw metadata response returned by the authlib-injector endpoint
|
||||
/// @param metadataTimestamp wall-clock time when the metadata response was fetched
|
||||
Entry(String url, String metadataResponse, long metadataTimestamp) {
|
||||
this.url = Objects.requireNonNull(url);
|
||||
this.metadataResponse = Objects.requireNonNull(metadataResponse);
|
||||
this.metadataTimestamp = metadataTimestamp;
|
||||
}
|
||||
|
||||
/// Returns the normalized authlib-injector server URL.
|
||||
String getUrl() {
|
||||
return Objects.requireNonNullElse(url, "");
|
||||
}
|
||||
|
||||
/// Returns the raw metadata response, or an empty string when the cache entry is incomplete.
|
||||
String getMetadataResponse() {
|
||||
return Objects.requireNonNullElse(metadataResponse, "");
|
||||
}
|
||||
|
||||
/// Returns the wall-clock time when the metadata response was fetched.
|
||||
long getMetadataTimestamp() {
|
||||
return metadataTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON adapter for [AuthlibInjectorServerMetadataCache].
|
||||
@NotNullByDefault
|
||||
static final class Adapter extends ObservableSetting.Adapter<AuthlibInjectorServerMetadataCache> {
|
||||
/// Creates an empty metadata cache for deserialization.
|
||||
@Override
|
||||
protected AuthlibInjectorServerMetadataCache createInstance() {
|
||||
return new AuthlibInjectorServerMetadataCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonSerializable
|
||||
@@ -71,7 +72,7 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
configLocation = Paths.get(CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) {
|
||||
AuthlibInjectorServers configInstance;
|
||||
try {
|
||||
configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class);
|
||||
@@ -81,11 +82,11 @@ public final class AuthlibInjectorServers implements Validation {
|
||||
}
|
||||
|
||||
if (!configInstance.urls.isEmpty()) {
|
||||
config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||
for (String url : configInstance.urls) {
|
||||
Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url))
|
||||
.thenAcceptAsync(Schedulers.javafx(), server -> {
|
||||
config().getAuthlibInjectorServers().add(server);
|
||||
getAuthlibInjectorServers().add(server);
|
||||
servers.add(server);
|
||||
})
|
||||
.start();
|
||||
|
||||
+18
-27
@@ -15,37 +15,28 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.gson;
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/**
|
||||
* To resolve JsonParseException: duplicate key: null
|
||||
* By skipping inserting data with key null
|
||||
* @param <K>
|
||||
* @param <V>
|
||||
*/
|
||||
public final class JsonMap<K, V> extends HashMap<K, V> {
|
||||
public JsonMap(int initialCapacity, float loadFactor) {
|
||||
super(initialCapacity, loadFactor);
|
||||
}
|
||||
/// Selects the source used to render the launcher background.
|
||||
@NotNullByDefault
|
||||
public enum BackgroundType {
|
||||
/// Uses the selected theme background when available, otherwise a built-in fallback wallpaper.
|
||||
DEFAULT,
|
||||
|
||||
public JsonMap(int initialCapacity) {
|
||||
super(initialCapacity);
|
||||
}
|
||||
/// Uses one built-in wallpaper selected by launcher settings.
|
||||
BUILTIN,
|
||||
|
||||
public JsonMap() {
|
||||
super();
|
||||
}
|
||||
/// Uses a user-selected local image file or a random image from a selected directory.
|
||||
CUSTOM,
|
||||
|
||||
public JsonMap(Map<? extends K, ? extends V> m) {
|
||||
super(m);
|
||||
}
|
||||
/// Uses an image loaded from a user-provided URL.
|
||||
NETWORK,
|
||||
|
||||
@Override
|
||||
public V put(K key, V value) {
|
||||
if (key == null) return null;
|
||||
return super.put(key, value);
|
||||
}
|
||||
/// Uses the configured paint value as a flat background.
|
||||
PAINT,
|
||||
|
||||
/// Uses the current theme color scheme surface container as a flat background.
|
||||
THEME_COLOR;
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(value = Config.Adapter.class)
|
||||
public final class Config extends ObservableSetting {
|
||||
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
public static final int CURRENT_UI_VERSION = 0;
|
||||
|
||||
public static final Gson CONFIG_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
|
||||
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
@Nullable
|
||||
public static Config fromJson(String json) throws JsonParseException {
|
||||
return CONFIG_GSON.fromJson(json, Config.class);
|
||||
}
|
||||
|
||||
public Config() {
|
||||
tracker.markDirty(configVersion);
|
||||
tracker.markDirty(uiVersion);
|
||||
register();
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
@SerializedName("_version")
|
||||
private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION);
|
||||
|
||||
public IntegerProperty configVersionProperty() {
|
||||
return configVersion;
|
||||
}
|
||||
|
||||
public int getConfigVersion() {
|
||||
return configVersion.get();
|
||||
}
|
||||
|
||||
public void setConfigVersion(int configVersion) {
|
||||
this.configVersion.set(configVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of UI that the user have last used.
|
||||
* If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased.
|
||||
* When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown,
|
||||
* then this property is set to the same value as {@link #CURRENT_UI_VERSION}.
|
||||
* In particular, the property is default to 0, so that whoever open the application for the first time will see the guide.
|
||||
*/
|
||||
@SerializedName("uiVersion")
|
||||
private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION);
|
||||
|
||||
public IntegerProperty uiVersionProperty() {
|
||||
return uiVersion;
|
||||
}
|
||||
|
||||
public int getUiVersion() {
|
||||
return uiVersion.get();
|
||||
}
|
||||
|
||||
public void setUiVersion(int uiVersion) {
|
||||
this.uiVersion.set(uiVersion);
|
||||
}
|
||||
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
@SerializedName("localization")
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
public ObjectProperty<SupportedLocale> localizationProperty() {
|
||||
return localization;
|
||||
}
|
||||
|
||||
public SupportedLocale getLocalization() {
|
||||
return localization.get();
|
||||
}
|
||||
|
||||
public void setLocalization(SupportedLocale localization) {
|
||||
this.localization.set(localization);
|
||||
}
|
||||
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
public String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
public void setPromptedVersion(String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
public boolean isAcceptPreviewUpdate() {
|
||||
return acceptPreviewUpdate.get();
|
||||
}
|
||||
|
||||
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
|
||||
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
|
||||
}
|
||||
|
||||
@SerializedName("disableAutoShowUpdateDialog")
|
||||
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty disableAutoShowUpdateDialogProperty() {
|
||||
return disableAutoShowUpdateDialog;
|
||||
}
|
||||
|
||||
public boolean isDisableAutoShowUpdateDialog() {
|
||||
return disableAutoShowUpdateDialog.get();
|
||||
}
|
||||
|
||||
public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) {
|
||||
this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog);
|
||||
}
|
||||
|
||||
@SerializedName("disableAprilFools")
|
||||
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty disableAprilFoolsProperty() {
|
||||
return disableAprilFools;
|
||||
}
|
||||
|
||||
public boolean isDisableAprilFools() {
|
||||
return disableAprilFools.get();
|
||||
}
|
||||
|
||||
public void setDisableAprilFools(boolean disableAprilFools) {
|
||||
this.disableAprilFools.set(disableAprilFools);
|
||||
}
|
||||
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
@SerializedName("commonDirType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirTypeProperty() {
|
||||
return commonDirType;
|
||||
}
|
||||
|
||||
public EnumCommonDirectory getCommonDirType() {
|
||||
return commonDirType.get();
|
||||
}
|
||||
|
||||
public void setCommonDirType(EnumCommonDirectory commonDirType) {
|
||||
this.commonDirType.set(commonDirType);
|
||||
}
|
||||
|
||||
@SerializedName("commonpath")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString());
|
||||
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
public String getCommonDirectory() {
|
||||
return commonDirectory.get();
|
||||
}
|
||||
|
||||
public void setCommonDirectory(String commonDirectory) {
|
||||
this.commonDirectory.set(commonDirectory);
|
||||
}
|
||||
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
public ObjectProperty<Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
public Integer getLogLines() {
|
||||
return logLines.get();
|
||||
}
|
||||
|
||||
public void setLogLines(Integer logLines) {
|
||||
this.logLines.set(logLines);
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
@SerializedName("themeBrightness")
|
||||
private final StringProperty themeBrightness = new SimpleStringProperty("light");
|
||||
|
||||
public StringProperty themeBrightnessProperty() {
|
||||
return themeBrightness;
|
||||
}
|
||||
|
||||
public String getThemeBrightness() {
|
||||
return themeBrightness.get();
|
||||
}
|
||||
|
||||
public void setThemeBrightness(String themeBrightness) {
|
||||
this.themeBrightness.set(themeBrightness);
|
||||
}
|
||||
|
||||
@SerializedName("theme")
|
||||
private final ObjectProperty<ThemeColor> themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
public ObjectProperty<ThemeColor> themeColorProperty() {
|
||||
return themeColor;
|
||||
}
|
||||
|
||||
public ThemeColor getThemeColor() {
|
||||
return themeColor.get();
|
||||
}
|
||||
|
||||
public void setThemeColor(ThemeColor themeColor) {
|
||||
this.themeColor.set(themeColor);
|
||||
}
|
||||
|
||||
@SerializedName("fontFamily")
|
||||
private final StringProperty fontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty fontFamilyProperty() {
|
||||
return fontFamily;
|
||||
}
|
||||
|
||||
public String getFontFamily() {
|
||||
return fontFamily.get();
|
||||
}
|
||||
|
||||
public void setFontFamily(String fontFamily) {
|
||||
this.fontFamily.set(fontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("fontSize")
|
||||
private final DoubleProperty fontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
public DoubleProperty fontSizeProperty() {
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
public double getFontSize() {
|
||||
return fontSize.get();
|
||||
}
|
||||
|
||||
public void setFontSize(double fontSize) {
|
||||
this.fontSize.set(fontSize);
|
||||
}
|
||||
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
public String getLauncherFontFamily() {
|
||||
return launcherFontFamily.get();
|
||||
}
|
||||
|
||||
public void setLauncherFontFamily(String launcherFontFamily) {
|
||||
this.launcherFontFamily.set(launcherFontFamily);
|
||||
}
|
||||
|
||||
@SerializedName("animationDisabled")
|
||||
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
return animationDisabled.get();
|
||||
}
|
||||
|
||||
public void setAnimationDisabled(boolean animationDisabled) {
|
||||
this.animationDisabled.set(animationDisabled);
|
||||
}
|
||||
|
||||
@SerializedName("titleTransparent")
|
||||
private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty titleTransparentProperty() {
|
||||
return titleTransparent;
|
||||
}
|
||||
|
||||
public boolean isTitleTransparent() {
|
||||
return titleTransparent.get();
|
||||
}
|
||||
|
||||
public void setTitleTransparent(boolean titleTransparent) {
|
||||
this.titleTransparent.set(titleTransparent);
|
||||
}
|
||||
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<EnumBackgroundImage> backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT);
|
||||
|
||||
public ObjectProperty<EnumBackgroundImage> backgroundImageTypeProperty() {
|
||||
return backgroundImageType;
|
||||
}
|
||||
|
||||
public EnumBackgroundImage getBackgroundImageType() {
|
||||
return backgroundImageType.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) {
|
||||
this.backgroundImageType.set(backgroundImageType);
|
||||
}
|
||||
|
||||
@SerializedName("bgpath")
|
||||
private final StringProperty backgroundImage = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageProperty() {
|
||||
return backgroundImage;
|
||||
}
|
||||
|
||||
public String getBackgroundImage() {
|
||||
return backgroundImage.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImage(String backgroundImage) {
|
||||
this.backgroundImage.set(backgroundImage);
|
||||
}
|
||||
|
||||
@SerializedName("bgurl")
|
||||
private final StringProperty backgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
public StringProperty backgroundImageUrlProperty() {
|
||||
return backgroundImageUrl;
|
||||
}
|
||||
|
||||
public String getBackgroundImageUrl() {
|
||||
return backgroundImageUrl.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageUrl(String backgroundImageUrl) {
|
||||
this.backgroundImageUrl.set(backgroundImageUrl);
|
||||
}
|
||||
|
||||
@SerializedName("bgpaint")
|
||||
private final ObjectProperty<Paint> backgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
public Paint getBackgroundPaint() {
|
||||
return backgroundPaint.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Paint> backgroundPaintProperty() {
|
||||
return backgroundPaint;
|
||||
}
|
||||
|
||||
public void setBackgroundPaint(Paint backgroundPaint) {
|
||||
this.backgroundPaint.set(backgroundPaint);
|
||||
}
|
||||
|
||||
@SerializedName("bgImageOpacity")
|
||||
private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100);
|
||||
|
||||
public IntegerProperty backgroundImageOpacityProperty() {
|
||||
return backgroundImageOpacity;
|
||||
}
|
||||
|
||||
public int getBackgroundImageOpacity() {
|
||||
return backgroundImageOpacity.get();
|
||||
}
|
||||
|
||||
public void setBackgroundImageOpacity(int backgroundImageOpacity) {
|
||||
this.backgroundImageOpacity.set(backgroundImageOpacity);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
public boolean getAutoDownloadThreads() {
|
||||
return autoDownloadThreads.get();
|
||||
}
|
||||
|
||||
public void setAutoDownloadThreads(boolean autoDownloadThreads) {
|
||||
this.autoDownloadThreads.set(autoDownloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
public int getDownloadThreads() {
|
||||
return downloadThreads.get();
|
||||
}
|
||||
|
||||
public void setDownloadThreads(int downloadThreads) {
|
||||
this.downloadThreads.set(downloadThreads);
|
||||
}
|
||||
|
||||
@SerializedName("downloadType")
|
||||
private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID);
|
||||
|
||||
public StringProperty downloadTypeProperty() {
|
||||
return downloadType;
|
||||
}
|
||||
|
||||
public String getDownloadType() {
|
||||
return downloadType.get();
|
||||
}
|
||||
|
||||
public void setDownloadType(String downloadType) {
|
||||
this.downloadType.set(downloadType);
|
||||
}
|
||||
|
||||
@SerializedName("autoChooseDownloadType")
|
||||
private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true);
|
||||
|
||||
public BooleanProperty autoChooseDownloadTypeProperty() {
|
||||
return autoChooseDownloadType;
|
||||
}
|
||||
|
||||
public boolean isAutoChooseDownloadType() {
|
||||
return autoChooseDownloadType.get();
|
||||
}
|
||||
|
||||
public void setAutoChooseDownloadType(boolean autoChooseDownloadType) {
|
||||
this.autoChooseDownloadType.set(autoChooseDownloadType);
|
||||
}
|
||||
|
||||
@SerializedName("versionListSource")
|
||||
private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID);
|
||||
|
||||
public StringProperty versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
public String getVersionListSource() {
|
||||
return versionListSource.get();
|
||||
}
|
||||
|
||||
public void setVersionListSource(String versionListSource) {
|
||||
this.versionListSource.set(versionListSource);
|
||||
}
|
||||
|
||||
@SerializedName("defaultAddonSource")
|
||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
||||
|
||||
public StringProperty defaultAddonSourceProperty() {
|
||||
return defaultAddonSource;
|
||||
}
|
||||
|
||||
public String getDefaultAddonSource() {
|
||||
return defaultAddonSource.get();
|
||||
}
|
||||
|
||||
public void setDefaultAddonSource(String defaultAddonSource) {
|
||||
this.defaultAddonSource.set(defaultAddonSource);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxy")
|
||||
private final BooleanProperty hasProxy = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyProperty() {
|
||||
return hasProxy;
|
||||
}
|
||||
|
||||
public boolean hasProxy() {
|
||||
return hasProxy.get();
|
||||
}
|
||||
|
||||
public void setHasProxy(boolean hasProxy) {
|
||||
this.hasProxy.set(hasProxy);
|
||||
}
|
||||
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
public boolean hasProxyAuth() {
|
||||
return hasProxyAuth.get();
|
||||
}
|
||||
|
||||
public void setHasProxyAuth(boolean hasProxyAuth) {
|
||||
this.hasProxyAuth.set(hasProxyAuth);
|
||||
}
|
||||
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<Proxy.Type> proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP);
|
||||
|
||||
public ObjectProperty<Proxy.Type> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
public Proxy.Type getProxyType() {
|
||||
return proxyType.get();
|
||||
}
|
||||
|
||||
public void setProxyType(Proxy.Type proxyType) {
|
||||
this.proxyType.set(proxyType);
|
||||
}
|
||||
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
public String getProxyHost() {
|
||||
return proxyHost.get();
|
||||
}
|
||||
|
||||
public void setProxyHost(String proxyHost) {
|
||||
this.proxyHost.set(proxyHost);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
public int getProxyPort() {
|
||||
return proxyPort.get();
|
||||
}
|
||||
|
||||
public void setProxyPort(int proxyPort) {
|
||||
this.proxyPort.set(proxyPort);
|
||||
}
|
||||
|
||||
@SerializedName("proxyUserName")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
public String getProxyUser() {
|
||||
return proxyUser.get();
|
||||
}
|
||||
|
||||
public void setProxyUser(String proxyUser) {
|
||||
this.proxyUser.set(proxyUser);
|
||||
}
|
||||
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPass = new SimpleStringProperty();
|
||||
|
||||
public StringProperty proxyPassProperty() {
|
||||
return proxyPass;
|
||||
}
|
||||
|
||||
public String getProxyPass() {
|
||||
return proxyPass.get();
|
||||
}
|
||||
|
||||
public void setProxyPass(String proxyPass) {
|
||||
this.proxyPass.set(proxyPass);
|
||||
}
|
||||
|
||||
// Game
|
||||
|
||||
@SerializedName("disableAutoGameOptions")
|
||||
private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty disableAutoGameOptionsProperty() {
|
||||
return disableAutoGameOptions;
|
||||
}
|
||||
|
||||
public boolean isDisableAutoGameOptions() {
|
||||
return disableAutoGameOptions.get();
|
||||
}
|
||||
|
||||
public void setDisableAutoGameOptions(boolean disableAutoGameOptions) {
|
||||
this.disableAutoGameOptions.set(disableAutoGameOptions);
|
||||
}
|
||||
|
||||
@SerializedName("allowAutoAgent")
|
||||
private final BooleanProperty allowAutoAgent = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty allowAutoAgentProperty() {
|
||||
return allowAutoAgent;
|
||||
}
|
||||
|
||||
public boolean getAllowAutoAgent() {
|
||||
return allowAutoAgent.get();
|
||||
}
|
||||
|
||||
public void setAllowAutoAgent(boolean allowAutoAgent) {
|
||||
this.allowAutoAgent.set(allowAutoAgent);
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
@SerializedName("authlibInjectorServers")
|
||||
private final ObservableList<AuthlibInjectorServer> authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server});
|
||||
|
||||
public ObservableList<AuthlibInjectorServer> getAuthlibInjectorServers() {
|
||||
return authlibInjectorServers;
|
||||
}
|
||||
|
||||
@SerializedName("addedLittleSkin")
|
||||
private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty addedLittleSkinProperty() {
|
||||
return addedLittleSkin;
|
||||
}
|
||||
|
||||
public boolean isAddedLittleSkin() {
|
||||
return addedLittleSkin.get();
|
||||
}
|
||||
|
||||
public void setAddedLittleSkin(boolean addedLittleSkin) {
|
||||
this.addedLittleSkin.set(addedLittleSkin);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred login type to use when the user wants to add an account.
|
||||
*/
|
||||
@SerializedName("preferredLoginType")
|
||||
private final StringProperty preferredLoginType = new SimpleStringProperty();
|
||||
|
||||
public StringProperty preferredLoginTypeProperty() {
|
||||
return preferredLoginType;
|
||||
}
|
||||
|
||||
public String getPreferredLoginType() {
|
||||
return preferredLoginType.get();
|
||||
}
|
||||
|
||||
public void setPreferredLoginType(String preferredLoginType) {
|
||||
this.preferredLoginType.set(preferredLoginType);
|
||||
}
|
||||
|
||||
@SerializedName("selectedAccount")
|
||||
private final StringProperty selectedAccount = new SimpleStringProperty();
|
||||
|
||||
public StringProperty selectedAccountProperty() {
|
||||
return selectedAccount;
|
||||
}
|
||||
|
||||
public String getSelectedAccount() {
|
||||
return selectedAccount.get();
|
||||
}
|
||||
|
||||
public void setSelectedAccount(String selectedAccount) {
|
||||
this.selectedAccount.set(selectedAccount);
|
||||
}
|
||||
|
||||
@SerializedName("accounts")
|
||||
private final ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
|
||||
|
||||
public ObservableList<Map<Object, Object>> getAccountStorages() {
|
||||
return accountStorages;
|
||||
}
|
||||
|
||||
// Configurations
|
||||
|
||||
@SerializedName("last")
|
||||
private final StringProperty selectedProfile = new SimpleStringProperty("");
|
||||
|
||||
public StringProperty selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
public String getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public void setSelectedProfile(String selectedProfile) {
|
||||
this.selectedProfile.set(selectedProfile);
|
||||
}
|
||||
|
||||
@SerializedName("configurations")
|
||||
private final SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
|
||||
|
||||
public MapProperty<String, Profile> getConfigurations() {
|
||||
return configurations;
|
||||
}
|
||||
|
||||
public static final class Adapter extends ObservableSetting.Adapter<Config> {
|
||||
@Override
|
||||
protected Config createInstance() {
|
||||
return new Config();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ConfigHolder {
|
||||
|
||||
private ConfigHolder() {
|
||||
}
|
||||
|
||||
public static final String CONFIG_FILENAME = "hmcl.json";
|
||||
public static final String CONFIG_FILENAME_LINUX = ".hmcl.json";
|
||||
public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json");
|
||||
|
||||
private static Path configLocation;
|
||||
private static Config configInstance;
|
||||
private static GlobalConfig globalConfigInstance;
|
||||
private static boolean newlyCreated;
|
||||
private static boolean ownerChanged = false;
|
||||
private static boolean unsupportedVersion = false;
|
||||
|
||||
public static Config config() {
|
||||
if (configInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return configInstance;
|
||||
}
|
||||
|
||||
public static GlobalConfig globalConfig() {
|
||||
if (globalConfigInstance == null) {
|
||||
throw new IllegalStateException("Configuration hasn't been loaded");
|
||||
}
|
||||
return globalConfigInstance;
|
||||
}
|
||||
|
||||
public static Path configLocation() {
|
||||
return configLocation;
|
||||
}
|
||||
|
||||
public static boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
public static boolean isOwnerChanged() {
|
||||
return ownerChanged;
|
||||
}
|
||||
|
||||
public static boolean isUnsupportedVersion() {
|
||||
return unsupportedVersion;
|
||||
}
|
||||
|
||||
public static void init() throws IOException {
|
||||
if (configInstance != null) {
|
||||
throw new IllegalStateException("Configuration is already loaded");
|
||||
}
|
||||
|
||||
configLocation = locateConfig();
|
||||
|
||||
LOG.info("Config location: " + configLocation);
|
||||
|
||||
configInstance = loadConfig();
|
||||
if (!unsupportedVersion)
|
||||
configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson()));
|
||||
|
||||
globalConfigInstance = loadGlobalConfig();
|
||||
globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson()));
|
||||
|
||||
Locale.setDefault(config().getLocalization().getLocale());
|
||||
I18n.setLocale(configInstance.getLocalization());
|
||||
LOG.setLogRetention(globalConfig().getLogRetention());
|
||||
Settings.init();
|
||||
|
||||
if (newlyCreated) {
|
||||
LOG.info("Creating config file " + configLocation);
|
||||
FileUtils.saveSafely(configLocation, configInstance.toJson());
|
||||
}
|
||||
|
||||
if (!Files.isWritable(configLocation)) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
|
||||
&& configLocation.getFileSystem() == FileSystems.getDefault()
|
||||
&& configLocation.toFile().canWrite()) {
|
||||
LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug");
|
||||
// There are some serious problems with the implementation of Samba or OpenJDK
|
||||
throw new SambaException();
|
||||
} else {
|
||||
// the config cannot be saved
|
||||
// throw up the error now to prevent further data loss
|
||||
throw new IOException("Config at " + configLocation + " is not writable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Path locateConfig() {
|
||||
Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(defaultConfigFile))
|
||||
return defaultConfigFile;
|
||||
|
||||
try {
|
||||
Path jarPath = JarUtils.thisJarPath();
|
||||
if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) {
|
||||
jarPath = jarPath.getParent();
|
||||
|
||||
Path config = jarPath.resolve(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
}
|
||||
|
||||
} catch (Throwable ignore) {
|
||||
}
|
||||
|
||||
Path config = Paths.get(CONFIG_FILENAME);
|
||||
if (Files.isRegularFile(config))
|
||||
return config;
|
||||
|
||||
Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX);
|
||||
if (Files.isRegularFile(dotConfig))
|
||||
return dotConfig;
|
||||
|
||||
// create new
|
||||
return defaultConfigFile;
|
||||
}
|
||||
|
||||
private static Config loadConfig() throws IOException {
|
||||
if (Files.exists(configLocation)) {
|
||||
try {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
&& "root".equals(System.getProperty("user.name"))
|
||||
&& !"root".equals(Files.getOwner(configLocation).getName())) {
|
||||
ownerChanged = true;
|
||||
}
|
||||
} catch (IOException e1) {
|
||||
LOG.warning("Failed to get owner");
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(configLocation);
|
||||
Config deserialized = Config.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
if (configVersion < Config.CURRENT_VERSION) {
|
||||
ConfigUpgrader.upgradeConfig(deserialized, content);
|
||||
} else if (configVersion > Config.CURRENT_VERSION) {
|
||||
unsupportedVersion = true;
|
||||
LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion));
|
||||
}
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
newlyCreated = true;
|
||||
return new Config();
|
||||
}
|
||||
|
||||
// Global Config
|
||||
|
||||
private static GlobalConfig loadGlobalConfig() throws IOException {
|
||||
if (Files.exists(GLOBAL_CONFIG_PATH)) {
|
||||
try {
|
||||
String content = Files.readString(GLOBAL_CONFIG_PATH);
|
||||
GlobalConfig deserialized = GlobalConfig.fromJson(content);
|
||||
if (deserialized == null) {
|
||||
LOG.info("Config is empty");
|
||||
} else {
|
||||
return deserialized;
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed config.", e);
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("Creating an empty global config");
|
||||
return new GlobalConfig();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
final class ConfigUpgrader {
|
||||
private ConfigUpgrader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is for the compatibility with old HMCL versions.
|
||||
*
|
||||
* @param deserialized deserialized config settings
|
||||
* @param rawContent raw json content of the config settings without modification
|
||||
*/
|
||||
static void upgradeConfig(Config deserialized, String rawContent) {
|
||||
int configVersion = deserialized.getConfigVersion();
|
||||
|
||||
if (configVersion >= Config.CURRENT_VERSION)
|
||||
return;
|
||||
|
||||
LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION));
|
||||
Map<?, ?> rawJson = Collections.unmodifiableMap(new Gson().<Map<?, ?>>fromJson(rawContent, Map.class));
|
||||
|
||||
if (configVersion < 1) {
|
||||
// Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important.
|
||||
tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> {
|
||||
tryCast(auth.get("offline"), Map.class).ifPresent(offline -> {
|
||||
String selected = rawJson.containsKey("selectedAccount") ? null
|
||||
: tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null);
|
||||
|
||||
tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> {
|
||||
((Map<?, ?>) uuidMap).forEach((key, value) -> {
|
||||
Map<Object, Object> storage = new HashMap<>();
|
||||
storage.put("type", "offline");
|
||||
storage.put("username", key);
|
||||
storage.put("uuid", value);
|
||||
if (key.equals(selected)) {
|
||||
storage.put("selected", true);
|
||||
}
|
||||
deserialized.getAccountStorages().add(storage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Upgrade configuration of HMCL earlier than 3.1.70
|
||||
if (!rawJson.containsKey("commonDirType"))
|
||||
deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM);
|
||||
if (!rawJson.containsKey("backgroundType"))
|
||||
deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT);
|
||||
if (!rawJson.containsKey("hasProxy"))
|
||||
deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost()));
|
||||
if (!rawJson.containsKey("hasProxyAuth"))
|
||||
deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser()));
|
||||
|
||||
if (!rawJson.containsKey("downloadType")) {
|
||||
tryCast(rawJson.get("downloadtype"), Number.class)
|
||||
.map(Number::intValue)
|
||||
.ifPresent(id -> {
|
||||
if (id == 0) {
|
||||
deserialized.setDownloadType("mojang");
|
||||
} else if (id == 1) {
|
||||
deserialized.setDownloadType("bmclapi");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deserialized.setConfigVersion(Config.CURRENT_VERSION);
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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,9 +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.game;
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
public enum VersionJsonType {
|
||||
OFFICIAL,
|
||||
TLAUNCHER
|
||||
/// @author Glavo
|
||||
public enum DefaultIsolationType {
|
||||
/// Default to isolate all instances.
|
||||
ALWAYS,
|
||||
|
||||
/// Default to isolate instances with any modloader.
|
||||
MODDED,
|
||||
|
||||
/// No instances are isolated by default.
|
||||
NEVER,
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import javafx.beans.InvalidationListener;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
@@ -33,82 +32,70 @@ import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced";
|
||||
public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang";
|
||||
|
||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
||||
|
||||
private static final DownloadProvider MOJANG_PROVIDER;
|
||||
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
|
||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
||||
public static final Map<String, DownloadProvider> DIRECT_PROVIDERS;
|
||||
public static final Map<String, DownloadProvider> AUTO_PROVIDERS;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
|
||||
DownloadProvider mojang = new MojangDownloadProvider();
|
||||
DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang);
|
||||
|
||||
DEFAULT_PROVIDER = mojang;
|
||||
DIRECT_PROVIDERS = Lang.mapOf(
|
||||
pair("mojang", mojang),
|
||||
pair("bmclapi", bmclapi)
|
||||
);
|
||||
|
||||
AUTO_PROVIDERS = Lang.mapOf(
|
||||
pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang),
|
||||
pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider(
|
||||
List.of(mojang, bmclapiRaw),
|
||||
List.of(bmclapiRaw, mojang)
|
||||
) : mojang),
|
||||
pair("mirror", bmclapi)
|
||||
);
|
||||
|
||||
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
MOJANG_PROVIDER = new MojangDownloadProvider();
|
||||
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Initializes download provider settings and synchronizes download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads()
|
||||
FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get()
|
||||
? DEFAULT_CONCURRENCY
|
||||
: config().getDownloadThreads());
|
||||
: settings().downloadThreadsProperty().get());
|
||||
};
|
||||
config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
config().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
if (config().isAutoChooseDownloadType()) {
|
||||
String versionListSource = config().getVersionListSource();
|
||||
DownloadProvider downloadProvider = versionListSource != null
|
||||
? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER;
|
||||
PROVIDER_WRAPPER.setProvider(downloadProvider);
|
||||
} else {
|
||||
String downloadType = config().getDownloadType();
|
||||
PROVIDER_WRAPPER.setProvider(downloadType != null
|
||||
? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER)
|
||||
: DEFAULT_PROVIDER);
|
||||
}
|
||||
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
|
||||
settings().versionListSourceProperty().get(),
|
||||
settings().fileDownloadSourceProperty().get()));
|
||||
};
|
||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
config().downloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
settings().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource);
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
}
|
||||
|
||||
/// Creates a download provider with independent version-list and file download preferences.
|
||||
private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) {
|
||||
return new AutoDownloadProvider(
|
||||
getCandidates(versionListSource),
|
||||
getCandidates(fileDownloadSource));
|
||||
}
|
||||
|
||||
/// Returns provider candidates ordered by the given source preference.
|
||||
private static List<DownloadProvider> getCandidates(DownloadSource source) {
|
||||
DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT;
|
||||
return switch (normalized) {
|
||||
case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND
|
||||
? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER)
|
||||
: List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER);
|
||||
case OFFICIAL -> List.of(MOJANG_PROVIDER);
|
||||
case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
@@ -56,17 +56,17 @@ public final class FontManager {
|
||||
|
||||
// Recommended
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font"));
|
||||
font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font"));
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
// Legacy
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -74,7 +74,7 @@ public final class FontManager {
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY);
|
||||
font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME);
|
||||
if (font != null)
|
||||
return font;
|
||||
|
||||
@@ -103,7 +103,7 @@ public final class FontManager {
|
||||
}
|
||||
|
||||
private static void updateFont() {
|
||||
String fontFamily = config().getLauncherFontFamily();
|
||||
String fontFamily = settings().launcherFontFamilyProperty().get();
|
||||
if (fontFamily == null)
|
||||
fontFamily = System.getProperty("hmcl.font.override");
|
||||
if (fontFamily == null)
|
||||
@@ -236,7 +236,7 @@ public final class FontManager {
|
||||
}
|
||||
|
||||
public static void setFontFamily(String fontFamily) {
|
||||
config().setLauncherFontFamily(fontFamily);
|
||||
settings().launcherFontFamilyProperty().set(fontFamily);
|
||||
updateFont();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores game directories independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved under the `config` directory in either the current
|
||||
/// HMCL directory or the user HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameDirectories.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameDirectories extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game directory store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-directories", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game directory store.
|
||||
public GameDirectories() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// The schema used by this game directory store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game directory store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game directory store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this game directory store may be saved back to its JSON file.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current on-disk file first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this game directory store may be saved back to its JSON file.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Game directories stored in this file.
|
||||
@SerializedName("directories")
|
||||
private final ObservableList<GameDirectory> gameDirectories =
|
||||
FXCollections.observableArrayList(profile -> new Observable[] { profile });
|
||||
|
||||
/// Whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
private transient boolean userFile;
|
||||
|
||||
/// Returns the game directories stored in this file.
|
||||
public ObservableList<GameDirectory> getGameDirectories() {
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Sets whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
void setUserFile(boolean userFile) {
|
||||
this.userFile = userFile;
|
||||
}
|
||||
|
||||
/// Returns whether this store represents `HMCL_USER_HOME/config/user-game-directories.json`.
|
||||
boolean isUserFile() {
|
||||
return userFile;
|
||||
}
|
||||
|
||||
/// Whether this store was created because its JSON file did not exist.
|
||||
private transient boolean newlyCreated;
|
||||
|
||||
/// Returns whether this store was created because its JSON file did not exist.
|
||||
boolean isNewlyCreated() {
|
||||
return newlyCreated;
|
||||
}
|
||||
|
||||
/// Sets whether this store was created because its JSON file did not exist.
|
||||
void setNewlyCreated(boolean newlyCreated) {
|
||||
this.newlyCreated = newlyCreated;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameDirectories].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameDirectories> {
|
||||
/// Creates an empty game directory store for deserialization.
|
||||
@Override
|
||||
protected GameDirectories createInstance() {
|
||||
return new GameDirectories();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Persistent configuration for a game directory.
|
||||
@JsonAdapter(GameDirectory.Serializer.class)
|
||||
@NotNullByDefault
|
||||
public final class GameDirectory implements Observable {
|
||||
/// The stable game directory ID.
|
||||
private final GameDirectoryID id;
|
||||
|
||||
/// Returns the stable game directory ID.
|
||||
public GameDirectoryID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// The game directory path.
|
||||
private final ObjectProperty<PortablePath> path;
|
||||
|
||||
/// Returns the game directory path property.
|
||||
public ObjectProperty<PortablePath> pathProperty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Returns the game directory path.
|
||||
public PortablePath getPath() {
|
||||
return path.get();
|
||||
}
|
||||
|
||||
/// Sets the game directory path.
|
||||
public void setPath(PortablePath path) {
|
||||
this.path.set(Objects.requireNonNull(path));
|
||||
}
|
||||
|
||||
/// The custom localized game directory name, or `null` when no name is stored.
|
||||
private final ObjectProperty<@Nullable LocalizedText> name;
|
||||
|
||||
/// Returns the custom localized game directory name property.
|
||||
public ObjectProperty<@Nullable LocalizedText> nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Returns the custom localized game directory name, or `null` when no name is stored.
|
||||
public @Nullable LocalizedText getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
/// Sets the custom localized game directory name.
|
||||
public void setName(@Nullable LocalizedText name) {
|
||||
this.name.set(name);
|
||||
}
|
||||
|
||||
/// The migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
|
||||
private final ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettings;
|
||||
|
||||
/// Returns the migrated legacy game settings preset ID property.
|
||||
public ObjectProperty<@Nullable GameSettingsPresetID> legacyGameSettingsProperty() {
|
||||
return legacyGameSettings;
|
||||
}
|
||||
|
||||
/// Returns the migrated legacy game settings preset ID, or `null` when this game directory uses the default preset.
|
||||
public @Nullable GameSettingsPresetID getLegacyGameSettings() {
|
||||
return legacyGameSettings.get();
|
||||
}
|
||||
|
||||
/// Sets the migrated legacy game settings preset ID.
|
||||
public void setLegacyGameSettings(@Nullable GameSettingsPresetID legacyGameSettings) {
|
||||
this.legacyGameSettings.set(legacyGameSettings);
|
||||
}
|
||||
|
||||
/// Creates a game directory.
|
||||
public GameDirectory(GameDirectoryID id, @Nullable LocalizedText name, PortablePath path) {
|
||||
this(id, name, path, null);
|
||||
}
|
||||
|
||||
/// Creates a game directory.
|
||||
public GameDirectory(
|
||||
GameDirectoryID id,
|
||||
@Nullable LocalizedText name,
|
||||
PortablePath path,
|
||||
@Nullable GameSettingsPresetID legacyGameSettings) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.name = new SimpleObjectProperty<>(this, "name", name);
|
||||
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
|
||||
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
|
||||
|
||||
addPropertyChangedListener(arg -> invalidate());
|
||||
}
|
||||
|
||||
/// Returns a debug string containing the game directory path and display metadata.
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("path", getPath())
|
||||
.append("name", getName())
|
||||
.toString();
|
||||
}
|
||||
|
||||
/// Registers a listener that invalidates this game directory when any stored property changes.
|
||||
private void addPropertyChangedListener(InvalidationListener listener) {
|
||||
name.addListener(listener);
|
||||
path.addListener(listener);
|
||||
legacyGameSettings.addListener(listener);
|
||||
}
|
||||
|
||||
/// Helper that stores and dispatches invalidation listeners for this game directory.
|
||||
private final ObservableHelper observableHelper = new ObservableHelper(this);
|
||||
|
||||
/// Adds an invalidation listener.
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
observableHelper.addListener(listener);
|
||||
}
|
||||
|
||||
/// Removes an invalidation listener.
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
observableHelper.removeListener(listener);
|
||||
}
|
||||
|
||||
/// Notifies game directory observers on the JavaFX thread when the toolkit is available.
|
||||
private void invalidate() {
|
||||
try {
|
||||
Platform.runLater(observableHelper::invalidate);
|
||||
} catch (IllegalStateException e) {
|
||||
observableHelper.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes and deserializes game directories.
|
||||
@NotNullByDefault
|
||||
public static final class Serializer implements JsonSerializer<GameDirectory>, JsonDeserializer<GameDirectory> {
|
||||
/// Serializes a game directory to JSON.
|
||||
@Override
|
||||
public JsonElement serialize(@Nullable GameDirectory src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null)
|
||||
return JsonNull.INSTANCE;
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("id", context.serialize(src.getId(), GameDirectoryID.class));
|
||||
if (src.getName() != null) {
|
||||
JsonElement name = context.serialize(src.getName(), LocalizedText.class);
|
||||
if (name != null && !name.isJsonNull()) {
|
||||
jsonObject.add("name", name);
|
||||
}
|
||||
}
|
||||
jsonObject.add("path", context.serialize(src.getPath(), PortablePath.class));
|
||||
if (src.getLegacyGameSettings() != null) {
|
||||
jsonObject.add("legacyGameSettings", context.serialize(src.getLegacyGameSettings(), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
/// Deserializes a game directory from JSON.
|
||||
@Override
|
||||
public @Nullable GameDirectory deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject obj)) return null;
|
||||
GameDirectoryID id = context.deserialize(obj.get("id"), GameDirectoryID.class);
|
||||
if (id == null) {
|
||||
throw new JsonParseException("Game directory ID cannot be null");
|
||||
} else if (GameDirectoryID.NIL.equals(id)) {
|
||||
throw new JsonParseException("Game directory ID cannot be nil");
|
||||
}
|
||||
PortablePath path = context.deserialize(obj.get("path"), PortablePath.class);
|
||||
if (path == null) {
|
||||
throw new JsonParseException("Game directory path cannot be null");
|
||||
}
|
||||
@Nullable LocalizedText name = context.deserialize(obj.get("name"), LocalizedText.class);
|
||||
|
||||
return new GameDirectory(id,
|
||||
name,
|
||||
path,
|
||||
context.deserialize(obj.get("legacyGameSettings"), GameSettingsPresetID.class));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game directory entry.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameDirectoryID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameDirectoryID(UUID uuid) implements TypedID {
|
||||
/// The serialized game directory ID prefix.
|
||||
public static final String PREFIX = "game-directory";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameDirectoryID NIL = new GameDirectoryID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game directory ID.
|
||||
public GameDirectoryID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game directory ID from a prefixed UUID string.
|
||||
public static GameDirectoryID parse(String value) {
|
||||
return new GameDirectoryID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game directory ID.
|
||||
public static GameDirectoryID generate() {
|
||||
return new GameDirectoryID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game directory ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameDirectoryID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameDirectoryID> {
|
||||
/// Creates a game directory ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameDirectoryID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Manages the merged runtime view of local and user game directories.
|
||||
///
|
||||
/// Local game directories take precedence over user game directories with the same ID. This class is
|
||||
/// the owner of the currently selected game directory and selected instance bookkeeping;
|
||||
/// callers should use this API instead of mutating the game-directory selection fields in
|
||||
/// [LauncherSettings] directly.
|
||||
@NotNullByDefault
|
||||
public final class GameDirectoryManager {
|
||||
|
||||
/// The stable ID shared by newly created and migrated current-workspace game directories.
|
||||
static final GameDirectoryID DEFAULT_GAME_DIRECTORY_ID =
|
||||
new GameDirectoryID(new UUID(0x7105bc1f490e5e8cL, 0x878cf5844c3d4bc3L));
|
||||
|
||||
/// The stable ID shared by newly created and migrated user-home game directories.
|
||||
static final GameDirectoryID HOME_GAME_DIRECTORY_ID =
|
||||
new GameDirectoryID(new UUID(0xf3eafde8506e5a77L, 0xbc88f24b4728dfb2L));
|
||||
|
||||
/// The default current-workspace game directory path.
|
||||
private static final PortablePath CURRENT_GAME_DIRECTORY_PATH = PortablePath.of(".minecraft");
|
||||
|
||||
/// The default user-home game directory path.
|
||||
private static final PortablePath HOME_GAME_DIRECTORY_PATH = PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY);
|
||||
|
||||
/// Prevents instantiation.
|
||||
private GameDirectoryManager() {
|
||||
}
|
||||
|
||||
/// Creates a game directory ID that does not collide with existing game directories.
|
||||
public static GameDirectoryID newGameDirectoryId() {
|
||||
GameDirectoryID id;
|
||||
do {
|
||||
id = GameDirectoryID.generate();
|
||||
} while (hasGameDirectoryId(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Returns whether an existing game directory uses the given ID.
|
||||
private static boolean hasGameDirectoryId(GameDirectoryID id) {
|
||||
return localGameDirectories().getGameDirectories().stream()
|
||||
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id))
|
||||
|| userGameDirectories().getGameDirectories().stream()
|
||||
.anyMatch(gameDirectory -> gameDirectory.getId().equals(id));
|
||||
}
|
||||
|
||||
/// Returns the display name for the given game directory.
|
||||
public static String getGameDirectoryDisplayName(GameDirectory gameDirectory) {
|
||||
String name = getGameDirectoryCustomName(gameDirectory);
|
||||
if (name != null) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (isGameDirectoryPath(gameDirectory, CURRENT_GAME_DIRECTORY_PATH)) {
|
||||
return i18n("game_directory.default");
|
||||
}
|
||||
if (isGameDirectoryPath(gameDirectory, HOME_GAME_DIRECTORY_PATH)) {
|
||||
return i18n("game_directory.home");
|
||||
}
|
||||
|
||||
return gameDirectory.getId().toString();
|
||||
}
|
||||
|
||||
/// Returns the custom game directory name in the current locale.
|
||||
public static @Nullable String getGameDirectoryCustomName(GameDirectory gameDirectory) {
|
||||
@Nullable LocalizedText name = gameDirectory.getName();
|
||||
return name != null ? name.getText(I18n.getLocale().getCandidateLocales()) : null;
|
||||
}
|
||||
|
||||
/// Returns whether the game directory uses the given path.
|
||||
private static boolean isGameDirectoryPath(GameDirectory gameDirectory, PortablePath expectedPath) {
|
||||
PortablePath actualPath = gameDirectory.getPath();
|
||||
return actualPath.isAbsolute() == expectedPath.isAbsolute()
|
||||
&& actualPath.getPath().equals(expectedPath.getPath());
|
||||
}
|
||||
|
||||
/// True if [#init()] hasn't been called.
|
||||
private static boolean initialized = false;
|
||||
|
||||
/// The mutable merged game directory list used by UI bindings and game directory selection.
|
||||
private static final ObservableList<GameDirectory> mergedGameDirectories =
|
||||
FXCollections.observableArrayList(gameDirectory -> new javafx.beans.Observable[]{gameDirectory});
|
||||
|
||||
/// Read-only view of [#mergedGameDirectories] exposed to callers.
|
||||
private static final @UnmodifiableView ObservableList<GameDirectory> mergedGameDirectoriesUnmodifiable =
|
||||
FXCollections.unmodifiableObservableList(mergedGameDirectories);
|
||||
|
||||
/// Runtime repositories keyed by game directory object identity.
|
||||
private static final Map<GameDirectory, HMCLGameRepository> repositories = new IdentityHashMap<>();
|
||||
|
||||
/// The selected game directory, or `null` before the fallback game directory is resolved.
|
||||
private static final ObjectProperty<@UnknownNullability GameDirectory> selectedGameDirectory = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedGameDirectory");
|
||||
|
||||
/// The selected game repository, or `null` before the fallback game directory is resolved.
|
||||
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
|
||||
|
||||
/// The selected instance projected from the selected repository's current snapshot.
|
||||
private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance =
|
||||
new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
|
||||
|
||||
/// Updates [#selectedInstance] when the selected repository changes its selected instance.
|
||||
private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener =
|
||||
(observable, oldValue, newValue) -> selectedInstance.set(newValue);
|
||||
|
||||
/// Reacts when the selected repository publishes a new snapshot.
|
||||
private static final ChangeListener<? super HMCLGameRepositorySnapshot> selectedRepositorySnapshotListener =
|
||||
(observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged();
|
||||
|
||||
/// Initializes game directory state from the stores loaded by [SettingsManager].
|
||||
///
|
||||
/// This method creates the built-in local and user-home game directories when required, rebuilds
|
||||
/// the merged game directory view, and restores the selected game directory from [LauncherSettings].
|
||||
public static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
initialized = true;
|
||||
|
||||
rebuildGameDirectories();
|
||||
|
||||
boolean needRebuildGameDirectories = false;
|
||||
if (localGameDirectories().isNewlyCreated() && localGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = localGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
if (userGameDirectories().isNewlyCreated() && userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
needRebuildGameDirectories = true;
|
||||
GameDirectories gameDirectories = userGameDirectories();
|
||||
gameDirectories.getGameDirectories().add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
}
|
||||
|
||||
needRebuildGameDirectories |= createDefaultGameDirectoriesIfEmpty();
|
||||
if (needRebuildGameDirectories) {
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
assert !mergedGameDirectories.isEmpty();
|
||||
|
||||
@Nullable GameDirectoryID selectedId = settings().selectedGameDirectoryProperty().get();
|
||||
GameDirectory currentGameDirectory = null;
|
||||
|
||||
if (selectedId != null) {
|
||||
for (GameDirectory gameDirectory : mergedGameDirectories) {
|
||||
if (gameDirectory.getId().equals(selectedId)) {
|
||||
currentGameDirectory = gameDirectory;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedGameDirectory.addListener((a, b, newValue) -> {
|
||||
if (newValue == null) {
|
||||
throw new IllegalStateException("selectedGameDirectory cannot be null");
|
||||
}
|
||||
|
||||
settings().selectedGameDirectoryProperty().set(newValue.getId());
|
||||
@Nullable HMCLGameRepository oldRepository = selectedRepository.get();
|
||||
if (oldRepository != null) {
|
||||
oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener);
|
||||
oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener);
|
||||
}
|
||||
HMCLGameRepository repository = getOrCreateRepository(newValue);
|
||||
selectedRepository.set(repository);
|
||||
selectedInstance.set(repository.getSelectedInstance());
|
||||
repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener);
|
||||
repository.snapshotProperty().addListener(selectedRepositorySnapshotListener);
|
||||
if (repository.isLoaded()) {
|
||||
onSelectedRepositorySnapshotChanged();
|
||||
}
|
||||
repository.refreshAsync().start();
|
||||
});
|
||||
selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0));
|
||||
}
|
||||
|
||||
/// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot.
|
||||
private static void onSelectedRepositorySnapshotChanged() {
|
||||
@Nullable HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository == null || !repository.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.refreshSelectedInstance();
|
||||
for (Consumer<HMCLGameRepository> listener : versionsListeners) {
|
||||
listener.accept(repository);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the built-in game directories only when no game directory exists.
|
||||
private static boolean createDefaultGameDirectoriesIfEmpty() {
|
||||
if (localGameDirectories().getGameDirectories().isEmpty()
|
||||
&& userGameDirectories().getGameDirectories().isEmpty()) {
|
||||
localGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
DEFAULT_GAME_DIRECTORY_ID, null, CURRENT_GAME_DIRECTORY_PATH));
|
||||
userGameDirectories().getGameDirectories()
|
||||
.add(new GameDirectory(
|
||||
HOME_GAME_DIRECTORY_ID, null, HOME_GAME_DIRECTORY_PATH));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the merged runtime game directory view from the two backing stores.
|
||||
private static void rebuildGameDirectories() {
|
||||
GameDirectories userGameDirectories = userGameDirectories();
|
||||
GameDirectories localGameDirectories = localGameDirectories();
|
||||
Map<GameDirectoryID, GameDirectory> visibleGameDirectories = new LinkedHashMap<>();
|
||||
|
||||
for (GameDirectory gameDirectory : localGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.put(id, gameDirectory);
|
||||
}
|
||||
for (GameDirectory gameDirectory : userGameDirectories.getGameDirectories()) {
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
visibleGameDirectories.putIfAbsent(id, gameDirectory);
|
||||
}
|
||||
|
||||
mergedGameDirectories.setAll(visibleGameDirectories.values());
|
||||
}
|
||||
|
||||
/// Returns the read-only merged game directory list.
|
||||
public static @UnmodifiableView ObservableList<GameDirectory> getGameDirectories() {
|
||||
return mergedGameDirectoriesUnmodifiable;
|
||||
}
|
||||
|
||||
/// Returns the repository for the given game directory, creating it when needed.
|
||||
private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the per-workspace store.
|
||||
public static void addLocalGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("Local game directories are read-only");
|
||||
}
|
||||
addGameDirectory(localGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Adds a game directory to the user store.
|
||||
public static void addUserGameDirectory(GameDirectory gameDirectory) {
|
||||
if (SettingsManager.isUserGameDirectoriesReadOnly()) {
|
||||
throw new IllegalStateException("User game directories are read-only");
|
||||
}
|
||||
addGameDirectory(userGameDirectories(), gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether an existing game directory can be updated to the given path.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
/// @return whether both the current and target stores may be edited
|
||||
public static boolean canUpdateGameDirectory(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
return !isGameDirectoriesReadOnly(source) && !isGameDirectoriesReadOnly(target);
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to update a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param path the new game directory path
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
forceOverwriteGameDirectoriesIfReadOnly(source);
|
||||
if (target != source) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a game directory, moving it between local and user stores when the path type changes.
|
||||
///
|
||||
/// @param gameDirectory the game directory to update
|
||||
/// @param name the new custom game directory name, or `null` for an unnamed game directory
|
||||
/// @param path the new game directory path
|
||||
public static void updateGameDirectory(GameDirectory gameDirectory, @Nullable LocalizedText name, PortablePath path) {
|
||||
GameDirectories source = requireGameDirectoryStore(gameDirectory);
|
||||
GameDirectories target = getGameDirectoryStore(path);
|
||||
if (isGameDirectoriesReadOnly(source) || isGameDirectoriesReadOnly(target)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
if (source == target) {
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
return;
|
||||
}
|
||||
|
||||
source.getGameDirectories().remove(gameDirectory);
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
addGameDirectory(target, gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns whether the game directory can be removed from all stores containing it.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
/// @return whether all containing stores may be edited
|
||||
public static boolean canRemoveGameDirectory(GameDirectory gameDirectory) {
|
||||
boolean localContains = localGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
boolean userContains = userGameDirectories().getGameDirectories().contains(gameDirectory);
|
||||
return (!localContains || !SettingsManager.isLocalGameDirectoriesReadOnly())
|
||||
&& (!userContains || !SettingsManager.isUserGameDirectoriesReadOnly());
|
||||
}
|
||||
|
||||
/// Backs up and overwrites read-only game directory files required to remove a game directory.
|
||||
///
|
||||
/// @param gameDirectory the game directory to remove
|
||||
public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory) {
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(localGameDirectories());
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
forceOverwriteGameDirectoriesIfReadOnly(userGameDirectories());
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a game directory to the given store, replacing a game directory with the same ID in that store.
|
||||
private static void addGameDirectory(GameDirectories gameDirectories, GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
ObservableList<GameDirectory> entries = gameDirectories.getGameDirectories();
|
||||
GameDirectoryID id = gameDirectory.getId();
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
if (entries.get(i).getId().equals(id)) {
|
||||
repositories.remove(entries.get(i));
|
||||
settings().setSelectedInstance(id, null);
|
||||
entries.set(i, gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(gameDirectory);
|
||||
rebuildGameDirectories();
|
||||
}
|
||||
|
||||
/// Removes a game directory and recreates the built-in game directories when the list becomes empty.
|
||||
public static void removeGameDirectory(GameDirectory gameDirectory) {
|
||||
if (!canRemoveGameDirectory(gameDirectory)) {
|
||||
throw new IllegalStateException("Game directories are read-only");
|
||||
}
|
||||
|
||||
userGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
localGameDirectories().getGameDirectories().remove(gameDirectory);
|
||||
repositories.remove(gameDirectory);
|
||||
createDefaultGameDirectoriesIfEmpty();
|
||||
rebuildGameDirectories();
|
||||
|
||||
if (!mergedGameDirectories.contains(selectedGameDirectory.get())) {
|
||||
setSelectedGameDirectory(mergedGameDirectories.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the store that should own game directories with the given path.
|
||||
private static GameDirectories getGameDirectoryStore(PortablePath path) {
|
||||
Objects.requireNonNull(path);
|
||||
return path.isAbsolute() ? userGameDirectories() : localGameDirectories();
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory.
|
||||
private static @Nullable GameDirectories findGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (localGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return localGameDirectories();
|
||||
}
|
||||
if (userGameDirectories().getGameDirectories().contains(gameDirectory)) {
|
||||
return userGameDirectories();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the store currently containing the game directory, or throws when it is detached.
|
||||
private static GameDirectories requireGameDirectoryStore(GameDirectory gameDirectory) {
|
||||
@Nullable GameDirectories gameDirectories = findGameDirectoryStore(gameDirectory);
|
||||
if (gameDirectories == null) {
|
||||
throw new IllegalArgumentException("Game directory does not belong to a game directory store");
|
||||
}
|
||||
return gameDirectories;
|
||||
}
|
||||
|
||||
/// Returns whether the given game directory store is read-only.
|
||||
private static boolean isGameDirectoriesReadOnly(GameDirectories gameDirectories) {
|
||||
return gameDirectories.isUserFile()
|
||||
? SettingsManager.isUserGameDirectoriesReadOnly()
|
||||
: SettingsManager.isLocalGameDirectoriesReadOnly();
|
||||
}
|
||||
|
||||
/// Backs up and overwrites the given game directory store when it is read-only.
|
||||
private static void forceOverwriteGameDirectoriesIfReadOnly(GameDirectories gameDirectories) {
|
||||
if (!isGameDirectoriesReadOnly(gameDirectories)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameDirectories.isUserFile()) {
|
||||
SettingsManager.forceOverwriteUserGameDirectories();
|
||||
} else {
|
||||
SettingsManager.forceOverwriteLocalGameDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected game directory, creating built-in game directories first if the list is empty.
|
||||
public static GameDirectory getSelectedGameDirectory() {
|
||||
GameDirectory gameDirectory = selectedGameDirectory.get();
|
||||
if (gameDirectory == null) {
|
||||
throw new IllegalStateException("Selected game directory cannot be null");
|
||||
}
|
||||
return gameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository.
|
||||
public static HMCLGameRepository getSelectedRepository() {
|
||||
HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository == null) {
|
||||
throw new IllegalStateException("Selected repository cannot be null");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// Sets the selected game directory.
|
||||
public static void setSelectedGameDirectory(GameDirectory gameDirectory) {
|
||||
Objects.requireNonNull(gameDirectory);
|
||||
if (!mergedGameDirectories.contains(gameDirectory)) {
|
||||
throw new IllegalArgumentException("Unknown game directory: " + gameDirectory);
|
||||
}
|
||||
selectedGameDirectory.set(gameDirectory);
|
||||
}
|
||||
|
||||
/// Returns the selected game directory property.
|
||||
///
|
||||
/// The property is exposed for UI binding. Values should be changed through
|
||||
/// [#setSelectedGameDirectory(GameDirectory)] or by bidirectional bindings that only select
|
||||
/// game directories from [#getGameDirectories()].
|
||||
public static ObjectProperty<GameDirectory> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// Returns the selected game repository property.
|
||||
public static ObjectProperty<HMCLGameRepository> selectedRepositoryProperty() {
|
||||
return selectedRepository;
|
||||
}
|
||||
|
||||
/// Returns the selected instance property projected from the selected repository.
|
||||
///
|
||||
/// The value is `null` when the selected repository has no registered selected instance.
|
||||
///
|
||||
/// @return the read-only selected-instance property
|
||||
public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() {
|
||||
return selectedInstance.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the selected instance from the selected repository's current snapshot.
|
||||
///
|
||||
/// @return the selected instance, or `null` when none is registered
|
||||
public static @Nullable HMCLGameInstance getSelectedInstance() {
|
||||
return getSelectedRepository().getSelectedInstance();
|
||||
}
|
||||
|
||||
/// Sets the selected instance for the selected repository.
|
||||
///
|
||||
/// @param instance the instance to select, or `null` to clear the selection
|
||||
/// @throws IllegalArgumentException if `instance` belongs to another repository
|
||||
public static void setSelectedInstance(@Nullable HMCLGameInstance instance) {
|
||||
getSelectedRepository().setSelectedInstance(instance);
|
||||
}
|
||||
|
||||
/// Listeners notified after the selected repository has loaded versions.
|
||||
private static final List<Consumer<HMCLGameRepository>> versionsListeners = new ArrayList<>(4);
|
||||
|
||||
/// Registers a listener that is notified when the selected repository's versions are available.
|
||||
public static void registerVersionsListener(Consumer<HMCLGameRepository> listener) {
|
||||
HMCLGameRepository repository = getSelectedRepository();
|
||||
if (repository.isLoaded())
|
||||
listener.accept(repository);
|
||||
versionsListeners.add(listener);
|
||||
}
|
||||
}
|
||||
+30
-11
@@ -18,10 +18,12 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public enum VersionIconType {
|
||||
public enum GameInstanceIconType {
|
||||
DEFAULT("/assets/img/grass.png"),
|
||||
|
||||
GRASS("/assets/img/grass.png"),
|
||||
@@ -42,21 +44,38 @@ public enum VersionIconType {
|
||||
|
||||
// Please append new items at last
|
||||
|
||||
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
|
||||
public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) {
|
||||
return switch (modLoaderType) {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGED -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
case CLEANROOM -> VersionIconType.CLEANROOM;
|
||||
default -> VersionIconType.COMMAND;
|
||||
case FORGE -> GameInstanceIconType.FORGE;
|
||||
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||
case FABRIC -> GameInstanceIconType.FABRIC;
|
||||
case LEGACY_FABRIC -> GameInstanceIconType.LEGACY_FABRIC;
|
||||
case QUILT -> GameInstanceIconType.QUILT;
|
||||
case LITE_LOADER -> GameInstanceIconType.CHICKEN;
|
||||
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||
default -> GameInstanceIconType.COMMAND;
|
||||
};
|
||||
}
|
||||
|
||||
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
|
||||
return switch (componentType) {
|
||||
case GAME -> GameInstanceIconType.GRASS;
|
||||
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
|
||||
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
|
||||
case FORGE -> GameInstanceIconType.FORGE;
|
||||
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||
case LITELOADER -> GameInstanceIconType.CHICKEN;
|
||||
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
|
||||
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
|
||||
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private final String resourceUrl;
|
||||
|
||||
VersionIconType(String resourceUrl) {
|
||||
GameInstanceIconType(String resourceUrl) {
|
||||
this.resourceUrl = resourceUrl;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.util.TypedID;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/// Stable identifier for a persisted game settings preset.
|
||||
///
|
||||
/// @param uuid the UUID payload
|
||||
@JsonAdapter(GameSettingsPresetID.Adapter.class)
|
||||
@JsonSerializable
|
||||
@NotNullByDefault
|
||||
public record GameSettingsPresetID(UUID uuid) implements TypedID {
|
||||
/// The serialized game settings preset ID prefix.
|
||||
public static final String PREFIX = "game-settings-preset";
|
||||
|
||||
/// The nil identifier value.
|
||||
public static final GameSettingsPresetID NIL = new GameSettingsPresetID(UUIDs.NIL);
|
||||
|
||||
/// Creates a game settings preset ID.
|
||||
public GameSettingsPresetID {
|
||||
Objects.requireNonNull(uuid);
|
||||
}
|
||||
|
||||
/// Parses a game settings preset ID from a prefixed UUID string.
|
||||
public static GameSettingsPresetID parse(String value) {
|
||||
return new GameSettingsPresetID(TypedID.parseUUID(PREFIX, value));
|
||||
}
|
||||
|
||||
/// Generates a new time-ordered game settings preset ID.
|
||||
public static GameSettingsPresetID generate() {
|
||||
return new GameSettingsPresetID(UUIDs.generateV7());
|
||||
}
|
||||
|
||||
/// Returns the prefixed game settings preset ID string.
|
||||
@Override
|
||||
public String toString() {
|
||||
return TypedID.format(PREFIX, uuid);
|
||||
}
|
||||
|
||||
/// Gson adapter for [GameSettingsPresetID].
|
||||
public static final class Adapter extends TypedID.Adapter<GameSettingsPresetID> {
|
||||
/// Creates a game settings preset ID adapter.
|
||||
public Adapter() {
|
||||
super(PREFIX, GameSettingsPresetID::new);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores reusable game settings presets independently from the main config file.
|
||||
///
|
||||
/// The JSON representation is saved as `config/game-settings.json` under the current HMCL
|
||||
/// directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(GameSettingsPresets.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this game settings preset store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty game settings preset store.
|
||||
public GameSettingsPresets() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Copies another preset store into this instance.
|
||||
void copyFrom(GameSettingsPresets source) {
|
||||
if (source == this) {
|
||||
return;
|
||||
}
|
||||
|
||||
presets.setAll(source.getPresets());
|
||||
}
|
||||
|
||||
/// The schema used by this game settings preset store file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this game settings preset store file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this game settings preset store file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this preset store may be saved back to `config/game-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this preset store may be saved back to `config/game-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/game-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Reusable game setting presets.
|
||||
@SerializedName("presets")
|
||||
private final ObservableList<GameSettings.Preset> presets =
|
||||
FXCollections.observableArrayList(setting -> new Observable[] { setting });
|
||||
|
||||
/// Returns the reusable game setting presets.
|
||||
public ObservableList<GameSettings.Preset> getPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
/// Creates a preset ID that does not collide with existing presets.
|
||||
public GameSettingsPresetID newPresetId() {
|
||||
GameSettingsPresetID id;
|
||||
do {
|
||||
id = GameSettingsPresetID.generate();
|
||||
} while (getPreset(id) != null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Returns the first automatic preset number that is not used by existing presets.
|
||||
public int newPresetAutoNameNumber() {
|
||||
mainLoop:
|
||||
for (int index = 1; ; index++) {
|
||||
for (GameSettings.Preset setting : getPresets()) {
|
||||
Integer autoNameNumber = setting.autoNameNumberProperty().getValue();
|
||||
if ((autoNameNumber != null && autoNameNumber == index)) {
|
||||
continue mainLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the preset with the given ID.
|
||||
public GameSettings.@Nullable Preset getPreset(@Nullable GameSettingsPresetID id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (GameSettings.Preset setting : presets) {
|
||||
if (id.equals(setting.idProperty().getValue())) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// JSON adapter for [GameSettingsPresets].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<GameSettingsPresets> {
|
||||
/// Creates an empty preset store for deserialization.
|
||||
@Override
|
||||
protected GameSettingsPresets createInstance() {
|
||||
return new GameSettingsPresets();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/// The initial display mode of the game window.
|
||||
///
|
||||
/// @author Glavo
|
||||
public enum GameWindowType {
|
||||
/// Start the game in a normal window.
|
||||
WINDOWED,
|
||||
|
||||
/// Start the game in fullscreen mode.
|
||||
FULLSCREEN,
|
||||
|
||||
/// Start the game in a maximized window.
|
||||
MAXIMIZED,
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(GlobalConfig.Adapter.class)
|
||||
public final class GlobalConfig extends ObservableSetting {
|
||||
|
||||
@Nullable
|
||||
public static GlobalConfig fromJson(String json) throws JsonParseException {
|
||||
return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
|
||||
}
|
||||
|
||||
public GlobalConfig() {
|
||||
register();
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
return Config.CONFIG_GSON.toJson(this);
|
||||
}
|
||||
|
||||
@SerializedName("agreementVersion")
|
||||
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty agreementVersionProperty() {
|
||||
return agreementVersion;
|
||||
}
|
||||
|
||||
public int getAgreementVersion() {
|
||||
return agreementVersion.get();
|
||||
}
|
||||
|
||||
public void setAgreementVersion(int agreementVersion) {
|
||||
this.agreementVersion.set(agreementVersion);
|
||||
}
|
||||
|
||||
@SerializedName("terracottaAgreementVersion")
|
||||
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty terracottaAgreementVersionProperty() {
|
||||
return terracottaAgreementVersion;
|
||||
}
|
||||
|
||||
public int getTerracottaAgreementVersion() {
|
||||
return terracottaAgreementVersion.get();
|
||||
}
|
||||
|
||||
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
|
||||
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
|
||||
}
|
||||
|
||||
@SerializedName("platformPromptVersion")
|
||||
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
public IntegerProperty platformPromptVersionProperty() {
|
||||
return platformPromptVersion;
|
||||
}
|
||||
|
||||
public int getPlatformPromptVersion() {
|
||||
return platformPromptVersion.get();
|
||||
}
|
||||
|
||||
public void setPlatformPromptVersion(int platformPromptVersion) {
|
||||
this.platformPromptVersion.set(platformPromptVersion);
|
||||
}
|
||||
|
||||
@SerializedName("logRetention")
|
||||
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
|
||||
|
||||
public IntegerProperty logRetentionProperty() {
|
||||
return logRetention;
|
||||
}
|
||||
|
||||
public int getLogRetention() {
|
||||
return logRetention.get();
|
||||
}
|
||||
|
||||
public void setLogRetention(int logRetention) {
|
||||
this.logRetention.set(logRetention);
|
||||
}
|
||||
|
||||
@SerializedName("enableOfflineAccount")
|
||||
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty enableOfflineAccountProperty() {
|
||||
return enableOfflineAccount;
|
||||
}
|
||||
|
||||
public boolean isEnableOfflineAccount() {
|
||||
return enableOfflineAccount.get();
|
||||
}
|
||||
|
||||
public void setEnableOfflineAccount(boolean value) {
|
||||
enableOfflineAccount.set(value);
|
||||
}
|
||||
|
||||
@SerializedName("fontAntiAliasing")
|
||||
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
|
||||
|
||||
public StringProperty fontAntiAliasingProperty() {
|
||||
return fontAntiAliasing;
|
||||
}
|
||||
|
||||
public String getFontAntiAliasing() {
|
||||
return fontAntiAliasing.get();
|
||||
}
|
||||
|
||||
public void setFontAntiAliasing(String value) {
|
||||
this.fontAntiAliasing.set(value);
|
||||
}
|
||||
|
||||
@SerializedName("userJava")
|
||||
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
public ObservableSet<String> getUserJava() {
|
||||
return userJava;
|
||||
}
|
||||
|
||||
@SerializedName("disabledJava")
|
||||
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
public ObservableSet<String> getDisabledJava() {
|
||||
return disabledJava;
|
||||
}
|
||||
|
||||
static final class Adapter extends ObservableSetting.Adapter<GlobalConfig> {
|
||||
@Override
|
||||
protected GlobalConfig createInstance() {
|
||||
return new GlobalConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,27 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Selects how HMCL resolves the Java runtime used to launch a game.
|
||||
///
|
||||
/// Additional payload for these modes is stored in `GameSettings`:
|
||||
/// - [VERSION] uses `customJavaVersion` as the user-entered Java version selector.
|
||||
/// - [DETECTED] uses `detectedJava` as the detected runtime reference.
|
||||
/// - [CUSTOM] uses `customJavaPath` as the executable path selected by the user.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum JavaVersionType {
|
||||
DEFAULT, AUTO, VERSION, DETECTED, CUSTOM
|
||||
/// Automatically selects a suitable Java runtime for the target game version.
|
||||
AUTO,
|
||||
|
||||
/// Selects an installed Java runtime by major version.
|
||||
VERSION,
|
||||
|
||||
/// Selects one runtime from the Java runtimes detected by HMCL.
|
||||
DETECTED,
|
||||
|
||||
/// Uses a user-provided Java executable path.
|
||||
CUSTOM
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Marks a detached settings object that stores its own JSON schema URL.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
interface JsonSchemaSetting {
|
||||
/// Returns the schema used by this JSON settings file.
|
||||
JsonSchema getSchema();
|
||||
|
||||
/// Sets the schema used by this JSON settings file.
|
||||
///
|
||||
/// @param schema the schema to store
|
||||
void setSchema(JsonSchema schema);
|
||||
|
||||
/// Returns whether this settings object may be saved back to its JSON file.
|
||||
boolean isSavable();
|
||||
|
||||
/// Sets whether this settings object may be saved back to its JSON file.
|
||||
///
|
||||
/// @param savable whether this settings object may be saved
|
||||
void setSavable(boolean savable);
|
||||
|
||||
/// Returns whether the next successful save should back up the current on-disk file first.
|
||||
boolean isBackupOnNextSave();
|
||||
|
||||
/// Sets whether the next successful save should back up the current on-disk file first.
|
||||
///
|
||||
/// @param backupOnNextSave whether the next successful save should back up the current on-disk file first
|
||||
void setBackupOnNextSave(boolean backupOnNextSave);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Loads, saves, and validates a detached JSON settings file with a [JsonSchema] marker.
|
||||
///
|
||||
/// @param <T> the settings object type
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
final class JsonSettingFile<T extends ObservableSetting & JsonSchemaSetting> {
|
||||
/// The settings file location.
|
||||
private final Path location;
|
||||
|
||||
/// The human-readable file type name used in logs.
|
||||
private final String displayName;
|
||||
|
||||
/// The settings object type.
|
||||
private final Class<T> type;
|
||||
|
||||
/// The JSON schema supported by the current code.
|
||||
private final JsonSchema expectedSchema;
|
||||
|
||||
/// Creates a default settings object.
|
||||
private final Supplier<T> createDefault;
|
||||
|
||||
/// Creates a detached JSON settings file helper.
|
||||
///
|
||||
/// @param location the settings file location
|
||||
/// @param displayName the human-readable file type name used in logs
|
||||
/// @param type the settings object type
|
||||
/// @param expectedSchema the JSON schema supported by the current code
|
||||
/// @param createDefault creates a default settings object
|
||||
JsonSettingFile(
|
||||
Path location,
|
||||
String displayName,
|
||||
Class<T> type,
|
||||
JsonSchema expectedSchema,
|
||||
Supplier<T> createDefault) {
|
||||
this.location = Objects.requireNonNull(location);
|
||||
this.displayName = Objects.requireNonNull(displayName);
|
||||
this.type = Objects.requireNonNull(type);
|
||||
this.expectedSchema = Objects.requireNonNull(expectedSchema);
|
||||
this.createDefault = Objects.requireNonNull(createDefault);
|
||||
}
|
||||
|
||||
/// Loads the settings file, falling back to migrated data or a default object when absent.
|
||||
///
|
||||
/// @param migrated migrated settings data used when the file is absent
|
||||
/// @return the loaded settings object
|
||||
/// @throws IOException if reading the file fails
|
||||
LoadResult<T> load(@Nullable T migrated) throws IOException {
|
||||
if (Files.exists(location)) {
|
||||
try {
|
||||
JsonObject jsonObject = JsonUtils.fromJsonFile(location, JsonObject.class);
|
||||
if (jsonObject == null) {
|
||||
LOG.warning(displayName + " are empty: " + location);
|
||||
return result(createDefault.get(), true);
|
||||
} else {
|
||||
JsonSchema.CompatibilityResult schemaResult =
|
||||
JsonSchema.check(jsonObject, expectedSchema);
|
||||
switch (schemaResult.status()) {
|
||||
case MISSING -> LOG.warning("Missing schema in " + displayName + ": " + location);
|
||||
case INVALID -> LOG.warning("Invalid schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.invalidValue());
|
||||
case UNPARSEABLE -> LOG.warning("Unparseable schema in " + displayName + ": "
|
||||
+ location + ", Actual: " + schemaResult.actual());
|
||||
case UNEXPECTED_ID -> LOG.warning("Unexpected " + displayName + " schema. Expected: "
|
||||
+ expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported " + displayName
|
||||
+ " schema. Expected: " + expectedSchema + ", Actual: " + schemaResult.actual());
|
||||
case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> {
|
||||
}
|
||||
}
|
||||
if (!schemaResult.readable()) {
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
T deserialized = LauncherSettings.SETTINGS_GSON.<@Nullable T>fromJson(jsonObject, type);
|
||||
if (deserialized != null) {
|
||||
// Patch-compatible files keep their original schema because unknown members are preserved.
|
||||
if (!schemaResult.preserveSchema() && !expectedSchema.equals(deserialized.getSchema())) {
|
||||
deserialized.setSchema(expectedSchema);
|
||||
}
|
||||
|
||||
return result(deserialized, schemaResult.allowSave()
|
||||
? SettingFileAccess.READ_WRITE
|
||||
: SettingFileAccess.READ_ONLY);
|
||||
}
|
||||
|
||||
LOG.warning(displayName + " deserialized to null: " + location);
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.warning("Malformed " + displayName + ".", e);
|
||||
return result(createDefault.get(), true, true);
|
||||
}
|
||||
|
||||
return result(createDefault.get(), SettingFileAccess.UNREADABLE);
|
||||
}
|
||||
|
||||
return result(Objects.requireNonNullElseGet(migrated, createDefault), true);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable) {
|
||||
return result(value, savable, false);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the saveability metadata on the settings object.
|
||||
private LoadResult<T> result(T value, boolean savable, boolean backupOnNextSave) {
|
||||
value.setSavable(savable);
|
||||
value.setBackupOnNextSave(backupOnNextSave);
|
||||
return new LoadResult<>(value, SettingFileAccess.READ_WRITE);
|
||||
}
|
||||
|
||||
/// Creates a load result and stores the access metadata on the settings object.
|
||||
private LoadResult<T> result(T value, SettingFileAccess access) {
|
||||
value.setSavable(access.canSave());
|
||||
value.setBackupOnNextSave(false);
|
||||
return new LoadResult<>(value, access);
|
||||
}
|
||||
|
||||
/// Installs an automatic save listener on a settings object.
|
||||
///
|
||||
/// @param value the settings object to observe
|
||||
void installAutoSave(T value) {
|
||||
value.addListener(observable -> {
|
||||
if (value.shouldSaveImmediately(observable)) {
|
||||
save(value);
|
||||
value.setSavePending(false);
|
||||
} else {
|
||||
value.setSavePending(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Saves a settings object.
|
||||
///
|
||||
/// @param value the settings object to save
|
||||
void save(T value) {
|
||||
if (value.isBackupOnNextSave()) {
|
||||
value.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
}
|
||||
FileSaver.save(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
|
||||
}
|
||||
|
||||
/// Saves a settings object synchronously.
|
||||
///
|
||||
/// @param value the settings object to save
|
||||
/// @throws IOException if saving the file fails
|
||||
void saveSync(T value) throws IOException {
|
||||
if (value.isBackupOnNextSave()) {
|
||||
value.setBackupOnNextSave(false);
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
}
|
||||
FileUtils.saveSafely(location, LauncherSettings.SETTINGS_GSON.toJson(value, type));
|
||||
}
|
||||
|
||||
/// Prepares a settings object for overwriting a detached settings file.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
private void prepareOverwrite(T value) {
|
||||
SettingFileUtils.backupInvalidConfig(location);
|
||||
value.setSchema(expectedSchema);
|
||||
value.setSavable(true);
|
||||
value.setBackupOnNextSave(false);
|
||||
}
|
||||
|
||||
/// Backs up the current file and overwrites it with the given value using the current schema.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
void backupAndOverwrite(T value) {
|
||||
prepareOverwrite(value);
|
||||
save(value);
|
||||
}
|
||||
|
||||
/// Backs up the current file and overwrites it synchronously with the given value using the current schema.
|
||||
///
|
||||
/// @param value the replacement settings object
|
||||
/// @throws IOException if saving the file fails
|
||||
void backupAndOverwriteSync(T value) throws IOException {
|
||||
prepareOverwrite(value);
|
||||
saveSync(value);
|
||||
}
|
||||
|
||||
/// Result of loading a detached JSON settings file.
|
||||
///
|
||||
/// @param value the loaded settings object
|
||||
/// @param access whether the source file may be read and overwritten
|
||||
record LoadResult<T extends ObservableSetting & JsonSchemaSetting>(T value, SettingFileAccess access) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.hildan.fxgson.creators.ObservableListCreator;
|
||||
import org.hildan.fxgson.creators.ObservableMapCreator;
|
||||
import org.hildan.fxgson.creators.ObservableSetCreator;
|
||||
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AccountID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
|
||||
import org.jackhuang.hmcl.theme.BuiltinBackground;
|
||||
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.ThemeReference;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/// Stores the current workspace's main launcher settings.
|
||||
///
|
||||
/// This file keeps launcher-level choices such as UI preferences, network settings, selected game directory,
|
||||
/// selected instances, and account selection. Larger domain-specific stores, such as game directories,
|
||||
/// game settings presets, accounts, launcher state, and authlib-injector servers, are persisted in detached
|
||||
/// JSON files managed by [SettingsManager].
|
||||
@JsonAdapter(value = LauncherSettings.Adapter.class)
|
||||
@NotNullByDefault
|
||||
public final class LauncherSettings extends ObservableSetting implements JsonSchemaSetting {
|
||||
|
||||
/// The JSON schema supported by this launcher settings class.
|
||||
public static final JsonSchema CURRENT_SCHEMA = new JsonSchema("launcher-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// The JSON property name for the default game setting preset ID.
|
||||
static final String PROPERTY_DEFAULT_GAME_SETTINGS_PRESET = "defaultGameSettingsPreset";
|
||||
|
||||
/// The JSON property name for the selected game directory ID.
|
||||
static final String PROPERTY_SELECTED_GAME_DIRECTORY = "selectedGameDirectory";
|
||||
|
||||
/// The JSON property name for selected instance IDs keyed by game directory ID.
|
||||
static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance";
|
||||
|
||||
/// Default launcher theme used when no stored theme reference is available.
|
||||
public static final ThemeReference DEFAULT_THEME_REFERENCE = new ThemeReference("hmcl.default", null);
|
||||
|
||||
/// Theme appearance override key for theme brightness mode.
|
||||
public static final String THEME_APPEARANCE_BRIGHTNESS_MODE = "themeBrightnessMode";
|
||||
|
||||
/// Theme appearance override key for theme color seed.
|
||||
public static final String THEME_APPEARANCE_COLOR = "themeColor";
|
||||
|
||||
/// Theme appearance override key for theme color style.
|
||||
public static final String THEME_APPEARANCE_COLOR_STYLE = "themeColorStyle";
|
||||
|
||||
/// Theme appearance override key for title-bar transparency.
|
||||
public static final String THEME_APPEARANCE_TITLE_BAR_TRANSPARENT = "titleBarTransparent";
|
||||
|
||||
/// Theme appearance override key for window transparency.
|
||||
public static final String THEME_APPEARANCE_WINDOW_TRANSPARENT = "windowTransparent";
|
||||
|
||||
/// Theme appearance override key for the primary background source.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND = "background";
|
||||
|
||||
/// Theme appearance override key for background opacity.
|
||||
public static final String THEME_APPEARANCE_BACKGROUND_OPACITY = "backgroundOpacity";
|
||||
|
||||
/// Gson instance used for launcher settings and related settings objects that depend on JavaFX properties.
|
||||
public static final Gson SETTINGS_GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
|
||||
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
|
||||
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
|
||||
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
|
||||
.registerTypeAdapter(Paint.class, new PaintAdapter())
|
||||
.setPrettyPrinting()
|
||||
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
|
||||
.create();
|
||||
|
||||
/// Deserializes launcher settings from JSON.
|
||||
///
|
||||
/// @param json the JSON object to read
|
||||
/// @return the deserialized launcher settings
|
||||
/// @throws JsonParseException if the JSON cannot be deserialized as launcher settings
|
||||
public static LauncherSettings fromJson(JsonObject json) throws JsonParseException {
|
||||
return SETTINGS_GSON.fromJson(json, LauncherSettings.class);
|
||||
}
|
||||
|
||||
/// Creates empty launcher settings using current defaults.
|
||||
public LauncherSettings() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Serializes these launcher settings to formatted JSON.
|
||||
public String toJson() {
|
||||
return SETTINGS_GSON.toJson(this);
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
/// The schema used by this launcher settings file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher settings file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher settings file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
private transient boolean backupOnNextSave = false;
|
||||
|
||||
/// Returns whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher settings object may be saved back to `config/launcher-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/launcher-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The launcher UI language.
|
||||
@SerializedName("language")
|
||||
private final ObjectProperty<SupportedLocale> language = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
/// Returns the launcher UI language property.
|
||||
public ObjectProperty<SupportedLocale> languageProperty() {
|
||||
return language;
|
||||
}
|
||||
|
||||
/// Whether preview builds are accepted by update checks.
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the preview update opt-in property.
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
/// Whether automatic update dialogs are disabled.
|
||||
@SerializedName("disableAutoShowUpdateDialog")
|
||||
private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the automatic update dialog disable property.
|
||||
public BooleanProperty disableAutoShowUpdateDialogProperty() {
|
||||
return disableAutoShowUpdateDialog;
|
||||
}
|
||||
|
||||
/// Whether April Fools features are disabled.
|
||||
@SerializedName("disableAprilFools")
|
||||
private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the April Fools disable property.
|
||||
public BooleanProperty disableAprilFoolsProperty() {
|
||||
return disableAprilFools;
|
||||
}
|
||||
|
||||
/// The common Minecraft directory selection mode.
|
||||
@SerializedName("commonDirectoryType")
|
||||
private final ObjectProperty<EnumCommonDirectory> commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT);
|
||||
|
||||
/// Returns the common Minecraft directory selection mode property.
|
||||
public ObjectProperty<EnumCommonDirectory> commonDirectoryTypeProperty() {
|
||||
return commonDirectoryType;
|
||||
}
|
||||
|
||||
/// The custom common Minecraft directory path.
|
||||
@SerializedName("commonDirectory")
|
||||
private final StringProperty commonDirectory = new SimpleStringProperty();
|
||||
|
||||
/// Returns the custom common Minecraft directory property.
|
||||
public StringProperty commonDirectoryProperty() {
|
||||
return commonDirectory;
|
||||
}
|
||||
|
||||
/// Returns the default common Minecraft directory path.
|
||||
public static String getDefaultCommonDirectory() {
|
||||
return Metadata.MINECRAFT_DIRECTORY.toString();
|
||||
}
|
||||
|
||||
/// Resolves the effective common Minecraft directory from the current directory settings.
|
||||
///
|
||||
/// @return the effective directory path, or `null` when the configured mode is not recognized
|
||||
public String getResolvedCommonDirectory() {
|
||||
EnumCommonDirectory type = commonDirectoryType.get();
|
||||
String customPath = commonDirectory.get();
|
||||
|
||||
return type == EnumCommonDirectory.CUSTOM && StringUtils.isNotBlank(customPath)
|
||||
? customPath
|
||||
: getDefaultCommonDirectory();
|
||||
}
|
||||
|
||||
/// The maximum number of log lines kept in log views.
|
||||
@SerializedName("logLines")
|
||||
private final ObjectProperty<@Nullable Integer> logLines = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the log line limit property.
|
||||
public ObjectProperty<@Nullable Integer> logLinesProperty() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
// Theme selection
|
||||
|
||||
/// The installed theme selected by the launcher, or `null` when older settings do not contain a theme reference.
|
||||
@SerializedName("selectedTheme")
|
||||
private final ObjectProperty<@Nullable ThemeReference> selectedTheme =
|
||||
new SimpleObjectProperty<>(DEFAULT_THEME_REFERENCE);
|
||||
|
||||
/// Returns the selected installed theme property.
|
||||
public ObjectProperty<@Nullable ThemeReference> selectedThemeProperty() {
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
/// Returns the selected installed theme, falling back to the built-in default theme.
|
||||
public ThemeReference getSelectedThemeOrDefault() {
|
||||
return Objects.requireNonNullElse(selectedTheme.get(), DEFAULT_THEME_REFERENCE);
|
||||
}
|
||||
|
||||
// Theme appearance overrides
|
||||
|
||||
/// Theme appearance setting keys overridden by the launcher.
|
||||
@SerializedName("themeAppearanceOverrides")
|
||||
private final ObservableSet<String> themeAppearanceOverrides = FXCollections.observableSet();
|
||||
|
||||
/// Returns the theme appearance setting keys overridden by the launcher.
|
||||
public ObservableSet<String> getThemeAppearanceOverrides() {
|
||||
return themeAppearanceOverrides;
|
||||
}
|
||||
|
||||
// Theme appearance values
|
||||
|
||||
/// The configured theme brightness mode identifier.
|
||||
@SerializedName("themeBrightnessMode")
|
||||
private final StringProperty themeBrightnessMode = new SimpleStringProperty("auto");
|
||||
|
||||
/// Returns the theme brightness mode property.
|
||||
public StringProperty themeBrightnessModeProperty() {
|
||||
return themeBrightnessMode;
|
||||
}
|
||||
|
||||
/// The custom launcher theme color preserved when dynamic color extraction is used.
|
||||
@SerializedName("customThemeColor")
|
||||
private final ObjectProperty<ThemeColor> customThemeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT);
|
||||
|
||||
/// Returns the custom launcher theme color property.
|
||||
public ObjectProperty<ThemeColor> customThemeColorProperty() {
|
||||
return customThemeColor;
|
||||
}
|
||||
|
||||
/// The source used to choose the launcher Monet theme color seed.
|
||||
@SerializedName("themeColorType")
|
||||
private final ObjectProperty<ThemeColorType> themeColorType = new RawPreservingObjectProperty<>(ThemeColorType.DEFAULT);
|
||||
|
||||
/// Returns the launcher theme color source type property.
|
||||
public ObjectProperty<ThemeColorType> themeColorTypeProperty() {
|
||||
return themeColorType;
|
||||
}
|
||||
|
||||
/// The MonetFX color style used to generate the launcher color scheme.
|
||||
@SerializedName("themeColorStyle")
|
||||
private final ObjectProperty<ColorStyle> themeColorStyle = new RawPreservingObjectProperty<>(ColorStyle.FIDELITY);
|
||||
|
||||
/// Returns the launcher theme color style property.
|
||||
public ObjectProperty<ColorStyle> themeColorStyleProperty() {
|
||||
return themeColorStyle;
|
||||
}
|
||||
|
||||
/// Whether the launcher title bar is transparent.
|
||||
@SerializedName("titleBarTransparent")
|
||||
private final BooleanProperty titleBarTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the transparent title-bar property.
|
||||
public BooleanProperty titleBarTransparentProperty() {
|
||||
return titleBarTransparent;
|
||||
}
|
||||
|
||||
/// Whether translucent launcher backgrounds reveal content behind the window.
|
||||
@SerializedName("windowTransparent")
|
||||
private final BooleanProperty windowTransparent = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the window transparency property.
|
||||
public BooleanProperty windowTransparentProperty() {
|
||||
return windowTransparent;
|
||||
}
|
||||
|
||||
// Background source
|
||||
|
||||
/// The launcher background source type.
|
||||
@SerializedName("backgroundType")
|
||||
private final ObjectProperty<BackgroundType> backgroundType = new RawPreservingObjectProperty<>(BackgroundType.DEFAULT);
|
||||
|
||||
/// Returns the launcher background source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundTypeProperty() {
|
||||
return backgroundType;
|
||||
}
|
||||
|
||||
/// The selected built-in launcher wallpaper ID.
|
||||
@SerializedName("builtinBackgroundId")
|
||||
private final StringProperty builtinBackgroundId = new SimpleStringProperty(BuiltinBackground.FALLBACK.id());
|
||||
|
||||
/// Returns the selected built-in launcher wallpaper ID property.
|
||||
public StringProperty builtinBackgroundIdProperty() {
|
||||
return builtinBackgroundId;
|
||||
}
|
||||
|
||||
/// The local custom launcher background image path.
|
||||
@SerializedName("customBackgroundImagePath")
|
||||
private final StringProperty customBackgroundImagePath = new SimpleStringProperty();
|
||||
|
||||
/// Returns the local custom launcher background image path property.
|
||||
public StringProperty customBackgroundImagePathProperty() {
|
||||
return customBackgroundImagePath;
|
||||
}
|
||||
|
||||
/// The remote network launcher background image URL.
|
||||
@SerializedName("networkBackgroundImageUrl")
|
||||
private final StringProperty networkBackgroundImageUrl = new SimpleStringProperty();
|
||||
|
||||
/// Returns the remote network launcher background image URL property.
|
||||
public StringProperty networkBackgroundImageUrlProperty() {
|
||||
return networkBackgroundImageUrl;
|
||||
}
|
||||
|
||||
/// The custom launcher background paint.
|
||||
@SerializedName("customBackgroundPaint")
|
||||
private final ObjectProperty<@Nullable Paint> customBackgroundPaint = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the custom launcher background paint property.
|
||||
public ObjectProperty<@Nullable Paint> customBackgroundPaintProperty() {
|
||||
return customBackgroundPaint;
|
||||
}
|
||||
|
||||
// Background appearance
|
||||
|
||||
/// The launcher background opacity value.
|
||||
@SerializedName("backgroundOpacity")
|
||||
private final DoubleProperty backgroundOpacity = new SimpleDoubleProperty(1.0);
|
||||
|
||||
/// Returns the custom launcher background opacity value property.
|
||||
public DoubleProperty backgroundOpacityProperty() {
|
||||
return backgroundOpacity;
|
||||
}
|
||||
|
||||
// Background loading
|
||||
|
||||
/// The URL image cache policy for network launcher backgrounds.
|
||||
@SerializedName("networkBackgroundImageCachePolicy")
|
||||
private final ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicy =
|
||||
new RawPreservingObjectProperty<>(NetworkBackgroundImageCachePolicy.ENABLED);
|
||||
|
||||
/// Returns the URL image cache policy for network launcher backgrounds.
|
||||
public ObjectProperty<NetworkBackgroundImageCachePolicy> networkBackgroundImageCachePolicyProperty() {
|
||||
return networkBackgroundImageCachePolicy;
|
||||
}
|
||||
|
||||
/// The fallback source used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackType")
|
||||
private final ObjectProperty<BackgroundType> backgroundFallbackType =
|
||||
new RawPreservingObjectProperty<>(BackgroundType.BUILTIN);
|
||||
|
||||
/// Returns the launcher background fallback source type property.
|
||||
public ObjectProperty<BackgroundType> backgroundFallbackTypeProperty() {
|
||||
return backgroundFallbackType;
|
||||
}
|
||||
|
||||
/// The fallback paint used when the selected launcher background cannot be loaded.
|
||||
@SerializedName("backgroundFallbackPaint")
|
||||
private final ObjectProperty<Paint> backgroundFallbackPaint = new SimpleObjectProperty<>(Color.WHITE);
|
||||
|
||||
/// Returns the launcher background fallback paint property.
|
||||
public ObjectProperty<Paint> backgroundFallbackPaintProperty() {
|
||||
return backgroundFallbackPaint;
|
||||
}
|
||||
|
||||
/// How the launcher displays its window while the selected background is loading.
|
||||
@SerializedName("backgroundLoadPolicy")
|
||||
private final ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicy =
|
||||
new RawPreservingObjectProperty<>(BackgroundLoadPolicy.WAIT_FOR_BACKGROUND);
|
||||
|
||||
/// Returns the launcher background loading policy property.
|
||||
public ObjectProperty<BackgroundLoadPolicy> backgroundLoadPolicyProperty() {
|
||||
return backgroundLoadPolicy;
|
||||
}
|
||||
|
||||
// Fonts
|
||||
|
||||
/// The font family used by launcher log views.
|
||||
@SerializedName("logFontFamily")
|
||||
private final StringProperty logFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher log font family property.
|
||||
public StringProperty logFontFamilyProperty() {
|
||||
return logFontFamily;
|
||||
}
|
||||
|
||||
/// The launcher log font size.
|
||||
@SerializedName("logFontSize")
|
||||
private final DoubleProperty logFontSize = new SimpleDoubleProperty(12);
|
||||
|
||||
/// Returns the launcher log font size property.
|
||||
public DoubleProperty logFontSizeProperty() {
|
||||
return logFontSize;
|
||||
}
|
||||
|
||||
/// The font family used by launcher chrome.
|
||||
@SerializedName("launcherFontFamily")
|
||||
private final StringProperty launcherFontFamily = new SimpleStringProperty();
|
||||
|
||||
/// Returns the launcher chrome font family property.
|
||||
public StringProperty launcherFontFamilyProperty() {
|
||||
return launcherFontFamily;
|
||||
}
|
||||
|
||||
// General UI
|
||||
|
||||
/// Whether UI animations are disabled.
|
||||
@SerializedName("animationDisabled")
|
||||
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the UI animation disable property.
|
||||
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
// Avoid accessing FXUtils too early during startup
|
||||
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
/// Whether HMCL automatically selects the number of download threads.
|
||||
@SerializedName("autoDownloadThreads")
|
||||
private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true);
|
||||
|
||||
/// Returns the automatic download thread count property.
|
||||
public BooleanProperty autoDownloadThreadsProperty() {
|
||||
return autoDownloadThreads;
|
||||
}
|
||||
|
||||
/// The configured number of download threads.
|
||||
@SerializedName("downloadThreads")
|
||||
private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64);
|
||||
|
||||
/// Returns the download thread count property.
|
||||
public IntegerProperty downloadThreadsProperty() {
|
||||
return downloadThreads;
|
||||
}
|
||||
|
||||
/// The selected game version list download source.
|
||||
@SerializedName("versionListSource")
|
||||
private final ObjectProperty<DownloadSource> versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected game version list download source property.
|
||||
public ObjectProperty<DownloadSource> versionListSourceProperty() {
|
||||
return versionListSource;
|
||||
}
|
||||
|
||||
/// The selected file download source.
|
||||
@SerializedName("fileDownloadSource")
|
||||
private final ObjectProperty<DownloadSource> fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT);
|
||||
|
||||
/// Returns the selected file download source property.
|
||||
public ObjectProperty<DownloadSource> fileDownloadSourceProperty() {
|
||||
return fileDownloadSource;
|
||||
}
|
||||
|
||||
/// The selected default add-on source ID.
|
||||
@SerializedName("defaultAddonSource")
|
||||
private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth");
|
||||
|
||||
/// Returns the selected default add-on source ID property.
|
||||
public StringProperty defaultAddonSourceProperty() {
|
||||
return defaultAddonSource;
|
||||
}
|
||||
|
||||
/// Whether proxy authentication is enabled.
|
||||
@SerializedName("hasProxyAuth")
|
||||
private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty();
|
||||
|
||||
/// Returns the proxy authentication enable property.
|
||||
public BooleanProperty hasProxyAuthProperty() {
|
||||
return hasProxyAuth;
|
||||
}
|
||||
|
||||
/// The configured network proxy selection mode.
|
||||
@SerializedName("proxyType")
|
||||
private final ObjectProperty<ProxyType> proxyType = new SimpleObjectProperty<>(ProxyType.SYSTEM);
|
||||
|
||||
/// Returns the network proxy selection mode property.
|
||||
public ObjectProperty<ProxyType> proxyTypeProperty() {
|
||||
return proxyType;
|
||||
}
|
||||
|
||||
/// The configured network proxy host.
|
||||
@SerializedName("proxyHost")
|
||||
private final StringProperty proxyHost = new SimpleStringProperty();
|
||||
|
||||
/// Returns the network proxy host property.
|
||||
public StringProperty proxyHostProperty() {
|
||||
return proxyHost;
|
||||
}
|
||||
|
||||
/// The configured network proxy port.
|
||||
@SerializedName("proxyPort")
|
||||
private final IntegerProperty proxyPort = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the network proxy port property.
|
||||
public IntegerProperty proxyPortProperty() {
|
||||
return proxyPort;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication username.
|
||||
@SerializedName("proxyUser")
|
||||
private final StringProperty proxyUser = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication username property.
|
||||
public StringProperty proxyUserProperty() {
|
||||
return proxyUser;
|
||||
}
|
||||
|
||||
/// The configured proxy authentication password.
|
||||
@SerializedName("proxyPassword")
|
||||
private final StringProperty proxyPassword = new SimpleStringProperty();
|
||||
|
||||
/// Returns the proxy authentication password property.
|
||||
public StringProperty proxyPasswordProperty() {
|
||||
return proxyPassword;
|
||||
}
|
||||
|
||||
/// The selected game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_GAME_DIRECTORY)
|
||||
private final ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectory =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_SELECTED_GAME_DIRECTORY);
|
||||
|
||||
/// Returns the selected game directory ID property.
|
||||
///
|
||||
/// This property is exposed for persistence and [GameDirectoryManager] integration. Code outside [GameDirectoryManager]
|
||||
/// should use `GameDirectoryManager.setSelectedGameDirectory` instead of modifying this property directly.
|
||||
public ObjectProperty<@Nullable GameDirectoryID> selectedGameDirectoryProperty() {
|
||||
return selectedGameDirectory;
|
||||
}
|
||||
|
||||
/// The default game setting preset ID.
|
||||
@SerializedName(PROPERTY_DEFAULT_GAME_SETTINGS_PRESET)
|
||||
private final ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPreset =
|
||||
new SimpleObjectProperty<>(this, PROPERTY_DEFAULT_GAME_SETTINGS_PRESET);
|
||||
|
||||
/// Returns the default game setting preset ID property.
|
||||
public ObjectProperty<@Nullable GameSettingsPresetID> defaultGameSettingsPresetProperty() {
|
||||
return defaultGameSettingsPreset;
|
||||
}
|
||||
|
||||
/// Selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// This field is owned by [GameDirectoryManager]. Code outside [GameDirectoryManager] should not modify it directly.
|
||||
@SerializedName(PROPERTY_SELECTED_INSTANCE)
|
||||
private final ObservableMap<GameDirectoryID, GameInstanceID> selectedInstance = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns selected instance IDs keyed by game directory ID.
|
||||
///
|
||||
/// The map stores persisted selected instance values by game directory ID.
|
||||
public ObservableMap<GameDirectoryID, GameInstanceID> getSelectedInstance() {
|
||||
return selectedInstance;
|
||||
}
|
||||
|
||||
/// Returns the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// The value is loaded by the game repository for the matching game directory.
|
||||
public @Nullable GameInstanceID getSelectedInstance(@Nullable GameDirectoryID gameDirectoryId) {
|
||||
return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null;
|
||||
}
|
||||
|
||||
/// Sets the selected instance ID for the given game directory ID.
|
||||
///
|
||||
/// Blank values remove the persisted selected instance entry.
|
||||
public void setSelectedInstance(@Nullable GameDirectoryID gameDirectoryId, @Nullable GameInstanceID selectedInstance) {
|
||||
if (gameDirectoryId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedInstance != null) {
|
||||
this.selectedInstance.put(gameDirectoryId, selectedInstance);
|
||||
} else {
|
||||
this.selectedInstance.remove(gameDirectoryId);
|
||||
}
|
||||
}
|
||||
|
||||
// Accounts
|
||||
|
||||
/// The preferred login type to use when the user wants to add an account.
|
||||
@SerializedName("preferredLoginType")
|
||||
private final StringProperty preferredLoginType = new SimpleStringProperty();
|
||||
|
||||
/// Returns the preferred login type property.
|
||||
public StringProperty preferredLoginTypeProperty() {
|
||||
return preferredLoginType;
|
||||
}
|
||||
|
||||
/// The selected account reference.
|
||||
@SerializedName("selectedAccount")
|
||||
private final ObjectProperty<@Nullable AccountID> selectedAccount = new SimpleObjectProperty<>();
|
||||
|
||||
/// Returns the selected account reference property.
|
||||
public ObjectProperty<@Nullable AccountID> selectedAccountProperty() {
|
||||
return selectedAccount;
|
||||
}
|
||||
|
||||
/// JSON adapter for [LauncherSettings].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<LauncherSettings> {
|
||||
/// Creates empty launcher settings for deserialization.
|
||||
@Override
|
||||
protected LauncherSettings createInstance() {
|
||||
return new LauncherSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableMap;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores per-workspace launcher runtime state independently from the main settings file.
|
||||
///
|
||||
/// The JSON representation is saved as `state/launcher-state.json` under the current HMCL directory.
|
||||
///
|
||||
/// @author Glavo
|
||||
@JsonAdapter(LauncherState.Adapter.class)
|
||||
@NotNullByDefault
|
||||
@JsonSerializable
|
||||
public final class LauncherState extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this launcher state store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("launcher-state", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Creates an empty launcher state store.
|
||||
public LauncherState() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Returns whether the changed field should trigger automatic persistence immediately.
|
||||
@Override
|
||||
public boolean shouldSaveImmediately(Observable observable) {
|
||||
return observable != x
|
||||
&& observable != y
|
||||
&& observable != width
|
||||
&& observable != height;
|
||||
}
|
||||
|
||||
/// The schema used by this launcher state file.
|
||||
@SerializedName(JsonSchema.PROPERTY_SCHEMA)
|
||||
private final ObjectProperty<JsonSchema> schema = new SimpleObjectProperty<>(CURRENT_SCHEMA);
|
||||
|
||||
/// Returns the schema property.
|
||||
public ObjectProperty<JsonSchema> schemaProperty() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/// Returns the schema used by this launcher state file.
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this launcher state file.
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this launcher state may be saved back to `state/launcher-state.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `state/launcher-state.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The normalized launcher window content X position.
|
||||
@SerializedName("x")
|
||||
private final DoubleProperty x = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content X position property.
|
||||
public DoubleProperty xProperty() {
|
||||
return x;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content X position.
|
||||
public double getX() {
|
||||
return x.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content X position.
|
||||
public void setX(double x) {
|
||||
this.x.set(x);
|
||||
}
|
||||
|
||||
/// The normalized launcher window content Y position.
|
||||
@SerializedName("y")
|
||||
private final DoubleProperty y = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the normalized launcher window content Y position property.
|
||||
public DoubleProperty yProperty() {
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Returns the normalized launcher window content Y position.
|
||||
public double getY() {
|
||||
return y.get();
|
||||
}
|
||||
|
||||
/// Sets the normalized launcher window content Y position.
|
||||
public void setY(double y) {
|
||||
this.y.set(y);
|
||||
}
|
||||
|
||||
/// The launcher window content width.
|
||||
@SerializedName("width")
|
||||
private final DoubleProperty width = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content width property.
|
||||
public DoubleProperty widthProperty() {
|
||||
return width;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content width.
|
||||
public double getWidth() {
|
||||
return width.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content width.
|
||||
public void setWidth(double width) {
|
||||
this.width.set(width);
|
||||
}
|
||||
|
||||
/// The launcher window content height.
|
||||
@SerializedName("height")
|
||||
private final DoubleProperty height = new SimpleDoubleProperty();
|
||||
|
||||
/// Returns the launcher window content height property.
|
||||
public DoubleProperty heightProperty() {
|
||||
return height;
|
||||
}
|
||||
|
||||
/// Returns the launcher window content height.
|
||||
public double getHeight() {
|
||||
return height.get();
|
||||
}
|
||||
|
||||
/// Sets the launcher window content height.
|
||||
public void setHeight(double height) {
|
||||
this.height.set(height);
|
||||
}
|
||||
|
||||
/// The latest update version that has already prompted the user.
|
||||
@SerializedName("promptedVersion")
|
||||
private final StringProperty promptedVersion = new SimpleStringProperty();
|
||||
|
||||
/// Returns the latest prompted update version.
|
||||
public @Nullable String getPromptedVersion() {
|
||||
return promptedVersion.get();
|
||||
}
|
||||
|
||||
/// Returns the latest prompted update version property.
|
||||
public StringProperty promptedVersionProperty() {
|
||||
return promptedVersion;
|
||||
}
|
||||
|
||||
/// Sets the latest prompted update version.
|
||||
public void setPromptedVersion(@Nullable String promptedVersion) {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
/// Tip markers that prevent repeated prompts.
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
/// Returns tip markers that prevent repeated prompts.
|
||||
public ObservableMap<String, Object> getShownTips() {
|
||||
return shownTips;
|
||||
}
|
||||
|
||||
/// JSON adapter for [LauncherState].
|
||||
public static final class Adapter extends ObservableSetting.Adapter<LauncherState> {
|
||||
/// Creates an empty launcher state store for deserialization.
|
||||
@Override
|
||||
protected LauncherState createInstance() {
|
||||
return new LauncherState();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,557 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.property.InheritableProperty;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
import org.jetbrains.annotations.VisibleForTesting;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Converts legacy game settings JSON into `GameSettings` models.
|
||||
@NotNullByDefault
|
||||
public final class LegacyGameSettingsMigrator {
|
||||
/// Legacy file name used by old per-version `VersionSetting` data.
|
||||
static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg";
|
||||
|
||||
/// Receipt file name used to record successful legacy per-version settings migration.
|
||||
private static final String LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME =
|
||||
"instance-game-settings.migration-receipt.json";
|
||||
|
||||
/// Directory under the version root that stores HMCL-managed instance metadata.
|
||||
static final String INSTANCE_METADATA_DIRECTORY = ".hmcl";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance configuration files.
|
||||
static final String INSTANCE_CONFIG_DIRECTORY = "config";
|
||||
|
||||
/// Directory under the instance metadata directory that stores instance state files.
|
||||
private static final String INSTANCE_STATE_DIRECTORY = "state";
|
||||
|
||||
/// Current file name for instance-specific game settings.
|
||||
static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json";
|
||||
|
||||
/// Legacy game directory modes stored by old configuration files.
|
||||
private enum GameDirectoryType {
|
||||
/// Use the root `.minecraft` folder.
|
||||
ROOT_FOLDER,
|
||||
|
||||
/// Use the version-specific folder.
|
||||
VERSION_FOLDER,
|
||||
|
||||
/// Use a custom game directory.
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
/// Legacy `VersionIconType` ordinal order used by old local settings.
|
||||
private static final GameInstanceIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = {
|
||||
GameInstanceIconType.DEFAULT,
|
||||
GameInstanceIconType.GRASS,
|
||||
GameInstanceIconType.CHEST,
|
||||
GameInstanceIconType.CHICKEN,
|
||||
GameInstanceIconType.COMMAND,
|
||||
GameInstanceIconType.OPTIFINE,
|
||||
GameInstanceIconType.CRAFT_TABLE,
|
||||
GameInstanceIconType.FABRIC,
|
||||
GameInstanceIconType.FORGE,
|
||||
GameInstanceIconType.NEO_FORGE,
|
||||
GameInstanceIconType.FURNACE,
|
||||
GameInstanceIconType.QUILT,
|
||||
GameInstanceIconType.APRIL_FOOLS,
|
||||
GameInstanceIconType.CLEANROOM,
|
||||
GameInstanceIconType.LEGACY_FABRIC
|
||||
};
|
||||
|
||||
/// Legacy `JavaVersionType` ordinal order used by old game settings.
|
||||
private static final JavaVersionType @Unmodifiable [] LEGACY_JAVA_VERSION_TYPES = {
|
||||
// Legacy ordinal 0 was DEFAULT and ordinal 1 was AUTO; both now migrate to AUTO.
|
||||
JavaVersionType.AUTO,
|
||||
JavaVersionType.AUTO,
|
||||
JavaVersionType.VERSION,
|
||||
JavaVersionType.DETECTED,
|
||||
JavaVersionType.CUSTOM
|
||||
};
|
||||
|
||||
/// Legacy native library mode ordinal order used by old game settings.
|
||||
private static final String @Unmodifiable [] LEGACY_NATIVES_DIRECTORY_TYPES = {
|
||||
"VERSION_FOLDER",
|
||||
"CUSTOM"
|
||||
};
|
||||
|
||||
/// Prevents instantiation.
|
||||
private LegacyGameSettingsMigrator() {
|
||||
}
|
||||
|
||||
/// Converts a legacy profile-level setting JSON object into a preset with the given ID.
|
||||
public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoNameNumber, @Nullable JsonObject source) {
|
||||
GameSettings.Preset target = new GameSettings.Preset(id);
|
||||
target.autoNameNumberProperty().setValue(autoNameNumber);
|
||||
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.VERSION_FOLDER) {
|
||||
target.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS);
|
||||
}
|
||||
if (source != null) {
|
||||
copyCommonProperties(source, target, GameSettings.DetectedJava::ofLegacyPath);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/// Migrates a legacy per-version game setting file into an instance setting.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the profile
|
||||
/// @return the migrated instance setting, or `null` when no legacy file can be migrated
|
||||
public static @Nullable InstanceMigrationResult migrateInstanceGameSettings(
|
||||
HMCLGameRepository repository,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable GameSettingsPresetID parent) {
|
||||
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
|
||||
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
|
||||
if (!Files.exists(file)) {
|
||||
return null;
|
||||
}
|
||||
Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId)
|
||||
.resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME);
|
||||
if (MigrationReceipt.matches(receiptLocation, file)) {
|
||||
LOG.info("Skipping already migrated legacy version setting " + file);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject legacySettingJson = JsonUtils.fromJsonFile(file, JsonObject.class);
|
||||
if (legacySettingJson == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean inheritsLegacyParent = JsonUtils.getBoolean(legacySettingJson, "usesGlobal", false);
|
||||
GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent);
|
||||
GameSettings.Instance setting = toInstance(
|
||||
parent,
|
||||
parentSetting,
|
||||
legacySettingJson,
|
||||
inheritsLegacyParent,
|
||||
GameSettings.DetectedJava::ofLegacyPath);
|
||||
if (!inheritsLegacyParent) {
|
||||
Path baseDirectory = repository.getBaseDirectory();
|
||||
if (parentSetting != null
|
||||
&& getLegacyGameDirType(legacySettingJson, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.ROOT_FOLDER
|
||||
&& StringUtils.isNotBlank(parentSetting.runningDirectoryProperty().getValue())) {
|
||||
setting.runningDirectoryProperty().setValue(baseDirectory.toString());
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
}
|
||||
return new InstanceMigrationResult(file, receiptLocation, setting);
|
||||
} catch (Exception ex) {
|
||||
LOG.warning("Failed to migrate legacy version setting " + file, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
public static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent) {
|
||||
return toInstance(parent, source, inheritsLegacyParent, GameSettings.DetectedJava::ofLegacyPath);
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting with a custom
|
||||
/// detected Java converter.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
|
||||
/// detected Java reference
|
||||
@VisibleForTesting
|
||||
static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
return toInstance(parent, null, source, inheritsLegacyParent, detectedJavaFactory);
|
||||
}
|
||||
|
||||
/// Converts a legacy local setting JSON object into an instance game setting with parent context.
|
||||
///
|
||||
/// @param parent the migrated parent preset ID for the instance
|
||||
/// @param parentSetting the migrated parent preset, if available during migration
|
||||
/// @param source the legacy local setting JSON object
|
||||
/// @param inheritsLegacyParent whether the legacy instance inherits its parent preset instead of
|
||||
/// carrying its own copied values
|
||||
/// @param detectedJavaFactory converts legacy detected Java version and path fields into the new
|
||||
/// detected Java reference
|
||||
@VisibleForTesting
|
||||
static GameSettings.Instance toInstance(
|
||||
@Nullable GameSettingsPresetID parent,
|
||||
@Nullable GameSettings.Preset parentSetting,
|
||||
@Nullable JsonObject source,
|
||||
boolean inheritsLegacyParent,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
GameSettings.Instance target = new GameSettings.Instance();
|
||||
target.parentProperty().setValue(parent);
|
||||
target.iconProperty().setValue(parseLegacyVersionIconType(source));
|
||||
if (inheritsLegacyParent
|
||||
&& parentSetting != null
|
||||
&& parentSetting.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS
|
||||
&& StringUtils.isBlank(parentSetting.runningDirectoryProperty().getValue())) {
|
||||
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
if (source != null && !inheritsLegacyParent) {
|
||||
copyCommonProperties(source, target, detectedJavaFactory);
|
||||
if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) != GameDirectoryType.ROOT_FOLDER) {
|
||||
target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
target.getOverrideProperties().addAll(List.of(
|
||||
GameSettings.PROPERTY_JAVA_TYPE,
|
||||
GameSettings.PROPERTY_CUSTOM_JAVA_VERSION,
|
||||
GameSettings.PROPERTY_CUSTOM_JAVA_PATH,
|
||||
GameSettings.PROPERTY_DETECTED_JAVA,
|
||||
GameSettings.PROPERTY_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NO_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NO_OPTIMIZING_JVM_OPTIONS,
|
||||
GameSettings.PROPERTY_NOT_CHECK_JVM,
|
||||
GameSettings.PROPERTY_NOT_CHECK_GAME,
|
||||
GameSettings.PROPERTY_AUTO_MEMORY,
|
||||
GameSettings.PROPERTY_MIN_MEMORY,
|
||||
GameSettings.PROPERTY_MAX_MEMORY,
|
||||
GameSettings.PROPERTY_PERM_SIZE,
|
||||
GameSettings.PROPERTY_WINDOW_TYPE,
|
||||
GameSettings.PROPERTY_WIDTH,
|
||||
GameSettings.PROPERTY_HEIGHT,
|
||||
GameSettings.PROPERTY_PROCESS_PRIORITY,
|
||||
GameSettings.PROPERTY_LAUNCHER_VISIBILITY,
|
||||
GameSettings.PROPERTY_GAME_ARGS,
|
||||
GameSettings.PROPERTY_GRAPHICS_BACKEND,
|
||||
GameSettings.PROPERTY_OPENGL_RENDERER,
|
||||
GameSettings.PROPERTY_VULKAN_RENDERER,
|
||||
GameSettings.PROPERTY_ENVIRONMENT_VARIABLES,
|
||||
GameSettings.PROPERTY_COMMAND_WRAPPER,
|
||||
GameSettings.PROPERTY_PRE_LAUNCH_COMMAND,
|
||||
GameSettings.PROPERTY_POST_EXIT_COMMAND,
|
||||
GameSettings.PROPERTY_QUICK_PLAY,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_MULTIPLAYER,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_SINGLEPLAYER,
|
||||
GameSettings.PROPERTY_QUICK_PLAY_REALMS,
|
||||
GameSettings.PROPERTY_SHOW_LOGS,
|
||||
GameSettings.PROPERTY_ENABLE_DEBUG_LOG_OUTPUT,
|
||||
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
|
||||
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
|
||||
GameSettings.PROPERTY_NATIVES_DIRECTORY,
|
||||
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
|
||||
GameSettings.PROPERTY_USE_NATIVE_OPENAL
|
||||
));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/// Returns the legacy game directory type from a setting JSON object.
|
||||
private static GameDirectoryType getLegacyGameDirType(@Nullable JsonObject source, GameDirectoryType defaultValue) {
|
||||
return parseEnum(source, "gameDirType", GameDirectoryType.class, defaultValue);
|
||||
}
|
||||
|
||||
/// Copies shared legacy properties into the target setting.
|
||||
private static void copyCommonProperties(
|
||||
JsonObject source,
|
||||
GameSettings target,
|
||||
BiFunction<String, String, GameSettings.DetectedJava> detectedJavaFactory) {
|
||||
JavaVersionType javaVersionType = parseLegacyJavaVersionType(source);
|
||||
String legacyJavaVersion = Objects.requireNonNullElse(parseLegacyJavaVersion(source), "");
|
||||
target.javaTypeProperty().setValue(javaVersionType);
|
||||
if (javaVersionType == JavaVersionType.VERSION) {
|
||||
target.customJavaVersionProperty().setValue(legacyJavaVersion);
|
||||
} else if (javaVersionType == JavaVersionType.DETECTED && StringUtils.isNotBlank(legacyJavaVersion)) {
|
||||
target.detectedJavaProperty().setValue(detectedJavaFactory.apply(
|
||||
legacyJavaVersion,
|
||||
JsonUtils.getString(source, "defaultJavaPath", "")));
|
||||
}
|
||||
target.customJavaPathProperty().setValue(JsonUtils.getString(source, "javaDir", ""));
|
||||
|
||||
target.jvmOptionsProperty().setValue(JsonUtils.getString(source, "javaArgs", ""));
|
||||
target.noJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noJVMArgs", false));
|
||||
target.noOptimizingJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noOptimizingJVMArgs", false));
|
||||
target.notCheckJVMProperty().setValue(JsonUtils.getBoolean(source, "notCheckJVM", false));
|
||||
target.notCheckGameProperty().setValue(JsonUtils.getBoolean(source, "notCheckGame", false));
|
||||
|
||||
target.autoMemoryProperty().setValue(JsonUtils.getBoolean(source, "autoMemory", true));
|
||||
target.minMemoryProperty().setValue(JsonUtils.getNullableInt(source, "minMemory"));
|
||||
int maxMemory = JsonUtils.getInt(source, "maxMemory", GameSettings.SUGGESTED_MEMORY);
|
||||
target.maxMemoryProperty().setValue(maxMemory > 0 ? maxMemory : GameSettings.SUGGESTED_MEMORY);
|
||||
target.permSizeProperty().setValue(JsonUtils.getString(source, "permSize", ""));
|
||||
|
||||
target.windowTypeProperty().setValue(JsonUtils.getBoolean(source, "fullscreen", false) ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED);
|
||||
target.widthProperty().setValue((double) JsonUtils.getInt(source, "width", 0));
|
||||
target.heightProperty().setValue((double) JsonUtils.getInt(source, "height", 0));
|
||||
target.runningDirectoryProperty().setValue(getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.CUSTOM
|
||||
? JsonUtils.getString(source, "gameDir", "")
|
||||
: "");
|
||||
|
||||
target.processPriorityProperty().setValue(parseEnum(source, "processPriority", ProcessPriority.class, ProcessPriority.NORMAL));
|
||||
target.launcherVisibilityProperty().setValue(parseEnum(source, "launcherVisibility", LauncherVisibility.class, LauncherVisibility.HIDE));
|
||||
target.gameArgumentsProperty().setValue(JsonUtils.getString(source, "minecraftArgs", ""));
|
||||
migrateLegacyRenderer(source, target);
|
||||
target.environmentVariablesProperty().setValue(JsonUtils.getString(source, "environmentVariables", ""));
|
||||
target.commandWrapperProperty().setValue(JsonUtils.getString(source, "wrapper", ""));
|
||||
target.preLaunchCommandProperty().setValue(JsonUtils.getString(source, "precalledCommand", ""));
|
||||
target.postExitCommandProperty().setValue(JsonUtils.getString(source, "postExitCommand", ""));
|
||||
|
||||
String serverIp = JsonUtils.getString(source, "serverIp", "");
|
||||
if (StringUtils.isBlank(serverIp)) {
|
||||
target.quickPlayProperty().setValue(QuickPlayType.NONE);
|
||||
} else {
|
||||
target.quickPlayProperty().setValue(QuickPlayType.MULTIPLAYER);
|
||||
target.quickPlayMultiplayerProperty().setValue(serverIp);
|
||||
}
|
||||
|
||||
target.showLogsProperty().setValue(JsonUtils.getBoolean(source, "showLogs", false));
|
||||
target.enableDebugLogOutputProperty().setValue(JsonUtils.getBoolean(source, "enableDebugLogOutput", false));
|
||||
target.notPatchNativesProperty().setValue(JsonUtils.getBoolean(source, "notPatchNatives", false));
|
||||
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
|
||||
target.useCustomNativesProperty().setValue(useCustomNatives);
|
||||
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
|
||||
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
|
||||
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
|
||||
}
|
||||
|
||||
/// Returns whether old game settings selected user-managed native libraries.
|
||||
private static boolean isLegacyCustomNativesDirectory(JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "nativesDirType");
|
||||
if (primitive == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0
|
||||
&& index < LEGACY_NATIVES_DIRECTORY_TYPES.length
|
||||
&& "CUSTOM".equals(LEGACY_NATIVES_DIRECTORY_TYPES[index]);
|
||||
}
|
||||
|
||||
return "CUSTOM".equalsIgnoreCase(primitive.getAsString());
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates the legacy renderer fields into current renderer properties.
|
||||
private static void migrateLegacyRenderer(JsonObject source, GameSettings setting) {
|
||||
Renderer renderer = parseLegacyRenderer(source);
|
||||
GraphicsAPI graphicsBackend = parseGraphicsBackend(source, renderer);
|
||||
setting.graphicsBackendProperty().setValue(graphicsBackend);
|
||||
|
||||
if (renderer instanceof Renderer.Driver driver) {
|
||||
rendererPropertyForApi(setting, driver.api()).setValue(renderer);
|
||||
if (graphicsBackend == GraphicsAPI.DEFAULT) {
|
||||
setting.graphicsBackendProperty().setValue(driver.api());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphicsBackend == GraphicsAPI.OPENGL || graphicsBackend == GraphicsAPI.VULKAN) {
|
||||
rendererPropertyForApi(setting, graphicsBackend).setValue(renderer);
|
||||
} else {
|
||||
setting.openGLRendererProperty().setValue(renderer);
|
||||
setting.vulkanRendererProperty().setValue(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the renderer property for the given non-default graphics API.
|
||||
private static InheritableProperty<Renderer> rendererPropertyForApi(GameSettings setting, GraphicsAPI api) {
|
||||
return switch (api) {
|
||||
case OPENGL -> setting.openGLRendererProperty();
|
||||
case VULKAN -> setting.vulkanRendererProperty();
|
||||
case DEFAULT -> throw new IllegalArgumentException("The default graphics API has no renderer property");
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy Java selection mode.
|
||||
private static JavaVersionType parseLegacyJavaVersionType(JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "javaVersionType");
|
||||
if (primitive != null) {
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < LEGACY_JAVA_VERSION_TYPES.length
|
||||
? LEGACY_JAVA_VERSION_TYPES[index]
|
||||
: JavaVersionType.AUTO;
|
||||
}
|
||||
|
||||
return switch (primitive.getAsString().toUpperCase(Locale.ROOT)) {
|
||||
case "DEFAULT", "AUTO" -> JavaVersionType.AUTO;
|
||||
case "VERSION" -> JavaVersionType.VERSION;
|
||||
case "DETECTED" -> JavaVersionType.DETECTED;
|
||||
case "CUSTOM" -> JavaVersionType.CUSTOM;
|
||||
default -> JavaVersionType.AUTO;
|
||||
};
|
||||
} catch (RuntimeException ignored) {
|
||||
return JavaVersionType.AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
return switch (JsonUtils.getString(source, "java", "")) {
|
||||
case "Default", "Auto" -> JavaVersionType.AUTO;
|
||||
case "Custom" -> JavaVersionType.CUSTOM;
|
||||
default -> JavaVersionType.AUTO;
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy Java version value.
|
||||
private static @Nullable String parseLegacyJavaVersion(JsonObject source) {
|
||||
if (source.has("javaVersionType")) {
|
||||
return switch (parseLegacyJavaVersionType(source)) {
|
||||
case VERSION, DETECTED -> JsonUtils.getString(source, "java", null);
|
||||
case AUTO, CUSTOM -> "";
|
||||
};
|
||||
}
|
||||
|
||||
String java = JsonUtils.getString(source, "java", "");
|
||||
return switch (java) {
|
||||
case "Default", "Auto", "Custom" -> "";
|
||||
default -> java;
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the legacy renderer selection.
|
||||
private static Renderer parseLegacyRenderer(JsonObject source) {
|
||||
JsonElement renderer = source.get("renderer");
|
||||
if (renderer != null && !renderer.isJsonNull()) {
|
||||
Renderer parsed = parseLegacyRenderer(renderer);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonUtils.getBoolean(source, "useSoftwareRenderer", false) ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT;
|
||||
}
|
||||
|
||||
/// Parses a renderer from a legacy JSON element.
|
||||
private static @Nullable Renderer parseLegacyRenderer(JsonElement element) {
|
||||
if (element instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return Renderer.of(primitive.getAsString());
|
||||
}
|
||||
|
||||
if (element.isJsonObject()) {
|
||||
JsonElement name = element.getAsJsonObject().get("name");
|
||||
if (name instanceof JsonPrimitive primitive && primitive.isString()) {
|
||||
return Renderer.of(primitive.getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parses the graphics API selection with renderer-derived fallback.
|
||||
private static GraphicsAPI parseGraphicsBackend(JsonObject source, Renderer renderer) {
|
||||
String name = JsonUtils.getString(source, "graphicsBackend", null);
|
||||
if (name != null) {
|
||||
try {
|
||||
return GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return renderer instanceof Renderer.Driver driver ? driver.api() : GraphicsAPI.DEFAULT;
|
||||
}
|
||||
|
||||
/// Parses the legacy icon selection with frozen ordinal order.
|
||||
private static GameInstanceIconType parseLegacyVersionIconType(@Nullable JsonObject source) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon");
|
||||
if (primitive == null) {
|
||||
return GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length
|
||||
? LEGACY_VERSION_ICON_TYPES[index]
|
||||
: GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
String value = primitive.getAsString();
|
||||
for (GameInstanceIconType iconType : LEGACY_VERSION_ICON_TYPES) {
|
||||
if (iconType.name().equalsIgnoreCase(value)) {
|
||||
return iconType;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
|
||||
return GameInstanceIconType.DEFAULT;
|
||||
}
|
||||
|
||||
/// Reads an enum property from either ordinal or name.
|
||||
private static <E extends Enum<E>> E parseEnum(@Nullable JsonObject source, String name, Class<E> type, E defaultValue) {
|
||||
JsonPrimitive primitive = JsonUtils.getPrimitive(source, name);
|
||||
if (primitive == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
E[] constants = type.getEnumConstants();
|
||||
try {
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < constants.length ? constants[index] : defaultValue;
|
||||
}
|
||||
|
||||
String value = primitive.getAsString();
|
||||
for (E constant : constants) {
|
||||
if (constant.name().equalsIgnoreCase(value)) {
|
||||
return constant;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// Result of migrating a legacy per-version game settings file.
|
||||
///
|
||||
/// @param path the legacy per-version game settings path
|
||||
/// @param receiptLocation the receipt path to write after saving the migrated settings
|
||||
/// @param setting the migrated instance game settings
|
||||
public record InstanceMigrationResult(
|
||||
Path path,
|
||||
Path receiptLocation,
|
||||
GameSettings.Instance setting) {
|
||||
/// Writes the migration receipt when receipt tracking is enabled.
|
||||
public void saveReceipt() {
|
||||
MigrationReceipt.save(receiptLocation, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Records successful legacy migrations so deleting a current file does not replay unchanged legacy content.
|
||||
///
|
||||
/// @param sourceSize the legacy source file size recorded for diagnostics, or `null` when omitted
|
||||
/// @param sourceLastModified the legacy source last-modified time recorded for diagnostics, or `null` when omitted
|
||||
/// @param sourceSHA256 the legacy source SHA-256 hash, or `null` when a malformed receipt omits it
|
||||
/// @param migratedAt when the migration receipt was written, or `null` before saving or when omitted
|
||||
@NotNullByDefault
|
||||
record MigrationReceipt(
|
||||
@Nullable Long sourceSize,
|
||||
@Nullable String sourceLastModified,
|
||||
@Nullable String sourceSHA256,
|
||||
@Nullable String migratedAt) {
|
||||
/// Returns whether the receipt records the current legacy source content.
|
||||
static boolean matches(Path receipt, Path source) {
|
||||
Objects.requireNonNull(receipt);
|
||||
Objects.requireNonNull(source);
|
||||
|
||||
if (!Files.exists(receipt) || !Files.exists(source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@Nullable MigrationReceipt object = JsonUtils.fromJsonFile(receipt, MigrationReceipt.class);
|
||||
return object != null && object.matches(create(source));
|
||||
} catch (IOException | JsonParseException | IllegalStateException e) {
|
||||
LOG.warning("Failed to read migration receipt " + receipt, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a receipt for a successful migration from the legacy source file.
|
||||
static void save(Path receipt, Path source) {
|
||||
Objects.requireNonNull(receipt);
|
||||
Objects.requireNonNull(source);
|
||||
|
||||
try {
|
||||
FileUtils.saveSafely(receipt, JsonUtils.GSON.toJson(create(source, Instant.now().toString())));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to write migration receipt " + receipt, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this receipt matches another legacy source content fingerprint.
|
||||
private boolean matches(MigrationReceipt current) {
|
||||
return sourceSHA256 != null && Objects.equals(sourceSHA256, current.sourceSHA256);
|
||||
}
|
||||
|
||||
/// Creates receipt content for the current source file state.
|
||||
private static MigrationReceipt create(Path source) throws IOException {
|
||||
return create(source, null);
|
||||
}
|
||||
|
||||
/// Creates receipt content for the current source file state and migration time.
|
||||
private static MigrationReceipt create(Path source, @Nullable String migratedAt) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(source, BasicFileAttributes.class);
|
||||
return new MigrationReceipt(
|
||||
attributes.size(),
|
||||
attributes.lastModifiedTime().toString(),
|
||||
DigestUtils.digestToString("SHA-256", source),
|
||||
migratedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.EventPriority;
|
||||
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
@JsonAdapter(Profile.Serializer.class)
|
||||
public final class Profile implements Observable {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private final HMCLGameRepository repository;
|
||||
|
||||
private final StringProperty selectedVersion = new SimpleStringProperty();
|
||||
|
||||
public StringProperty selectedVersionProperty() {
|
||||
return selectedVersion;
|
||||
}
|
||||
|
||||
public String getSelectedVersion() {
|
||||
return selectedVersion.get();
|
||||
}
|
||||
|
||||
public void setSelectedVersion(String selectedVersion) {
|
||||
this.selectedVersion.set(selectedVersion);
|
||||
}
|
||||
|
||||
private final ObjectProperty<Path> gameDir;
|
||||
|
||||
public ObjectProperty<Path> gameDirProperty() {
|
||||
return gameDir;
|
||||
}
|
||||
|
||||
public Path getGameDir() {
|
||||
return gameDir.get();
|
||||
}
|
||||
|
||||
public void setGameDir(Path gameDir) {
|
||||
this.gameDir.set(gameDir);
|
||||
}
|
||||
|
||||
private final ReadOnlyObjectWrapper<VersionSetting> global = new ReadOnlyObjectWrapper<>(this, "global");
|
||||
|
||||
public ReadOnlyObjectProperty<VersionSetting> globalProperty() {
|
||||
return global.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public VersionSetting getGlobal() {
|
||||
return global.get();
|
||||
}
|
||||
|
||||
private final SimpleStringProperty name;
|
||||
|
||||
public StringProperty nameProperty() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name.get();
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name.set(name);
|
||||
}
|
||||
|
||||
private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false);
|
||||
|
||||
public BooleanProperty useRelativePathProperty() {
|
||||
return useRelativePath;
|
||||
}
|
||||
|
||||
public boolean isUseRelativePath() {
|
||||
return useRelativePath.get();
|
||||
}
|
||||
|
||||
public void setUseRelativePath(boolean useRelativePath) {
|
||||
this.useRelativePath.set(useRelativePath);
|
||||
}
|
||||
|
||||
public Profile(String name) {
|
||||
this(name, Path.of(".minecraft"));
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir) {
|
||||
this(name, initialGameDir, new VersionSetting());
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir, VersionSetting global) {
|
||||
this(name, initialGameDir, global, null, false);
|
||||
}
|
||||
|
||||
public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) {
|
||||
this.name = new SimpleStringProperty(this, "name", name);
|
||||
gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir);
|
||||
repository = new HMCLGameRepository(this, initialGameDir);
|
||||
this.global.set(global == null ? new VersionSetting() : global);
|
||||
this.selectedVersion.set(selectedVersion);
|
||||
this.useRelativePath.set(useRelativePath);
|
||||
|
||||
gameDir.addListener((a, b, newValue) -> repository.changeDirectory(newValue));
|
||||
this.selectedVersion.addListener(o -> checkSelectedVersion());
|
||||
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST));
|
||||
|
||||
addPropertyChangedListener(onInvalidating(this::invalidate));
|
||||
}
|
||||
|
||||
private void checkSelectedVersion() {
|
||||
runInFX(() -> {
|
||||
if (!repository.isLoaded()) return;
|
||||
String newValue = selectedVersion.get();
|
||||
if (!repository.hasVersion(newValue)) {
|
||||
Optional<String> version = repository.getVersions().stream().findFirst().map(Version::getId);
|
||||
if (version.isPresent())
|
||||
selectedVersion.setValue(version.get());
|
||||
else if (newValue != null)
|
||||
selectedVersion.setValue(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public DefaultDependencyManager getDependency() {
|
||||
return getDependency(DownloadProviders.getDownloadProvider());
|
||||
}
|
||||
|
||||
public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) {
|
||||
return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY);
|
||||
}
|
||||
|
||||
public VersionSetting getVersionSetting(String id) {
|
||||
return repository.getVersionSetting(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("gameDir", getGameDir())
|
||||
.append("name", getName())
|
||||
.append("useRelativePath", isUseRelativePath())
|
||||
.toString();
|
||||
}
|
||||
|
||||
private void addPropertyChangedListener(InvalidationListener listener) {
|
||||
name.addListener(listener);
|
||||
global.addListener(listener);
|
||||
gameDir.addListener(listener);
|
||||
useRelativePath.addListener(listener);
|
||||
global.get().addListener(listener);
|
||||
selectedVersion.addListener(listener);
|
||||
}
|
||||
|
||||
private ObservableHelper observableHelper = new ObservableHelper(this);
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
observableHelper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
observableHelper.removeListener(listener);
|
||||
}
|
||||
|
||||
private void invalidate() {
|
||||
Platform.runLater(observableHelper::invalidate);
|
||||
}
|
||||
|
||||
public static class ProfileVersion {
|
||||
private final Profile profile;
|
||||
private final String version;
|
||||
|
||||
public ProfileVersion(Profile profile, String version) {
|
||||
this.profile = profile;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Serializer implements JsonSerializer<Profile>, JsonDeserializer<Profile> {
|
||||
@Override
|
||||
public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null)
|
||||
return JsonNull.INSTANCE;
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("global", context.serialize(src.getGlobal()));
|
||||
jsonObject.addProperty("gameDir", src.getGameDir().toString());
|
||||
jsonObject.addProperty("useRelativePath", src.isUseRelativePath());
|
||||
jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion());
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject obj)) return null;
|
||||
String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("");
|
||||
|
||||
return new Profile("Default",
|
||||
Path.of(gameDir),
|
||||
context.deserialize(obj.get("global"), VersionSetting.class),
|
||||
Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""),
|
||||
Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.RefreshedVersionsEvent;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class Profiles {
|
||||
|
||||
public static final String DEFAULT_PROFILE = "Default";
|
||||
public static final String HOME_PROFILE = "Home";
|
||||
|
||||
private Profiles() {
|
||||
}
|
||||
|
||||
public static String getProfileDisplayName(Profile profile) {
|
||||
return switch (profile.getName()) {
|
||||
case Profiles.DEFAULT_PROFILE -> i18n("profile.default");
|
||||
case Profiles.HOME_PROFILE -> i18n("profile.home");
|
||||
default -> profile.getName();
|
||||
};
|
||||
}
|
||||
|
||||
private static final ObservableList<Profile> profiles = observableArrayList(profile -> new Observable[] { profile });
|
||||
private static final ReadOnlyListWrapper<Profile> profilesWrapper = new ReadOnlyListWrapper<>(profiles);
|
||||
|
||||
private static final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<Profile>() {
|
||||
{
|
||||
profiles.addListener(onInvalidating(this::invalidated));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
if (!initialized)
|
||||
return;
|
||||
|
||||
Profile profile = get();
|
||||
|
||||
if (profiles.isEmpty()) {
|
||||
if (profile != null) {
|
||||
set(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!profiles.contains(profile)) {
|
||||
set(profiles.get(0));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
config().setSelectedProfile(profile == null ? "" : profile.getName());
|
||||
if (profile != null) {
|
||||
if (profile.getRepository().isLoaded())
|
||||
selectedVersion.bind(profile.selectedVersionProperty());
|
||||
else {
|
||||
selectedVersion.unbind();
|
||||
selectedVersion.set(null);
|
||||
// bind when repository was reloaded.
|
||||
profile.getRepository().refreshVersionsAsync().start();
|
||||
}
|
||||
} else {
|
||||
selectedVersion.unbind();
|
||||
selectedVersion.set(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static void checkProfiles() {
|
||||
if (profiles.isEmpty()) {
|
||||
Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true);
|
||||
Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY);
|
||||
Platform.runLater(() -> profiles.addAll(current, home));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if {@link #init()} hasn't been called.
|
||||
*/
|
||||
private static boolean initialized = false;
|
||||
|
||||
static {
|
||||
profiles.addListener(onInvalidating(Profiles::updateProfileStorages));
|
||||
profiles.addListener(onInvalidating(Profiles::checkProfiles));
|
||||
|
||||
selectedProfile.addListener((a, b, newValue) -> {
|
||||
if (newValue != null)
|
||||
newValue.getRepository().refreshVersionsAsync().start();
|
||||
});
|
||||
}
|
||||
|
||||
private static void updateProfileStorages() {
|
||||
// don't update the underlying storage before data loading is completed
|
||||
// otherwise it might cause data loss
|
||||
if (!initialized)
|
||||
return;
|
||||
// update storage
|
||||
TreeMap<String, Profile> newConfigurations = new TreeMap<>();
|
||||
for (Profile profile : profiles) {
|
||||
newConfigurations.put(profile.getName(), profile);
|
||||
}
|
||||
config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when it's ready to load profiles from {@link ConfigHolder#config()}.
|
||||
*/
|
||||
static void init() {
|
||||
if (initialized)
|
||||
throw new IllegalStateException("Already initialized");
|
||||
|
||||
HashSet<String> names = new HashSet<>();
|
||||
config().getConfigurations().forEach((name, profile) -> {
|
||||
if (!names.add(name)) return;
|
||||
profiles.add(profile);
|
||||
profile.setName(name);
|
||||
});
|
||||
checkProfiles();
|
||||
|
||||
// Platform.runLater is necessary or profiles will be empty
|
||||
// since checkProfiles adds 2 base profile later.
|
||||
Platform.runLater(() -> {
|
||||
initialized = true;
|
||||
|
||||
selectedProfile.set(
|
||||
profiles.stream()
|
||||
.filter(it -> it.getName().equals(config().getSelectedProfile()))
|
||||
.findFirst()
|
||||
.orElse(profiles.get(0)));
|
||||
});
|
||||
|
||||
EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> {
|
||||
runInFX(() -> {
|
||||
Profile profile = selectedProfile.get();
|
||||
if (profile != null && profile.getRepository() == event.getSource()) {
|
||||
selectedVersion.bind(profile.selectedVersionProperty());
|
||||
for (Consumer<Profile> listener : versionsListeners)
|
||||
listener.accept(profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static ObservableList<Profile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public static ReadOnlyListProperty<Profile> profilesProperty() {
|
||||
return profilesWrapper.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
public static Profile getSelectedProfile() {
|
||||
return selectedProfile.get();
|
||||
}
|
||||
|
||||
public static void setSelectedProfile(Profile profile) {
|
||||
selectedProfile.set(profile);
|
||||
}
|
||||
|
||||
public static ObjectProperty<Profile> selectedProfileProperty() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
private static final ReadOnlyStringWrapper selectedVersion = new ReadOnlyStringWrapper();
|
||||
|
||||
public static ReadOnlyStringProperty selectedVersionProperty() {
|
||||
return selectedVersion.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
// Guaranteed that the repository is loaded.
|
||||
public static String getSelectedVersion() {
|
||||
return selectedVersion.get();
|
||||
}
|
||||
|
||||
private static final List<Consumer<Profile>> versionsListeners = new ArrayList<>(4);
|
||||
|
||||
public static void registerVersionsListener(Consumer<Profile> listener) {
|
||||
Profile profile = getSelectedProfile();
|
||||
if (profile != null && profile.getRepository().isLoaded())
|
||||
listener.accept(profile);
|
||||
versionsListeners.add(listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores a JSON payload using HMCL's portable protection envelope.
|
||||
///
|
||||
/// This class is responsible for wrapping payloads with a protection marker and revealing them according to that
|
||||
/// marker. Protection modes define whether and how a payload is stored.
|
||||
@NotNullByDefault
|
||||
final class ProtectedPayload {
|
||||
|
||||
/// The JSON member containing the protection marker.
|
||||
static final String PROPERTY_PROTECTION = "protection";
|
||||
|
||||
/// The JSON member containing the envelope payload.
|
||||
static final String PROPERTY_PAYLOAD = "payload";
|
||||
|
||||
/// The JSON member containing the Base64-encoded encryption nonce.
|
||||
static final String PROPERTY_NONCE = "nonce";
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ProtectedPayload() {
|
||||
}
|
||||
|
||||
/// Selects how a protected payload is stored in its JSON envelope.
|
||||
@NotNullByDefault
|
||||
enum ProtectionMode {
|
||||
/// Stores the payload as plain JSON for development and diagnostics.
|
||||
PLAIN("plain") {
|
||||
/// Writes the payload into the given envelope.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload) {
|
||||
envelope.addProperty(PROPERTY_PROTECTION, id());
|
||||
envelope.add(PROPERTY_PAYLOAD, payload);
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
@Override
|
||||
protected JsonElement readPayload(JsonObject envelope) {
|
||||
JsonElement payload = envelope.get(PROPERTY_PAYLOAD);
|
||||
if (payload == null) {
|
||||
throw new JsonParseException("Missing payload");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
},
|
||||
|
||||
/// Stores the payload as a portable weakly encrypted envelope.
|
||||
///
|
||||
/// The payload is encrypted with a built-in application key, Base64-encoded, and split into padded strings. This
|
||||
/// avoids storing private data as directly readable JSON, but it should not be treated as device-bound secret
|
||||
/// storage. The nonce is stored separately, while the payload lanes store `ciphertext || authentication tag`.
|
||||
OBFUSCATED_V1("hmcl-obfuscated-v1") {
|
||||
/// The number of lanes used by the obfuscated payload.
|
||||
private static final int OBFUSCATED_LANE_COUNT = 4;
|
||||
|
||||
/// The number of padding elements stored before each lane.
|
||||
private static final int LANE_PADDING_COUNT = 63;
|
||||
|
||||
/// The number of elements written in an obfuscated payload array.
|
||||
private static final int WRITTEN_OBFUSCATED_PAYLOAD_SIZE = 256;
|
||||
|
||||
/// The JCA transformation used for payload encryption.
|
||||
private static final String CIPHER_TRANSFORMATION = "ChaCha20-Poly1305";
|
||||
|
||||
/// The ChaCha20-Poly1305 nonce size in bytes.
|
||||
private static final int NONCE_SIZE = 12;
|
||||
|
||||
/// The random source used to create payload nonces.
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
/// The built-in application key used by this weak portable protection mode.
|
||||
static final SecretKeySpec PROTECTION_KEY = new SecretKeySpec(new byte[]{
|
||||
(byte) 0x3c, (byte) 0xd8, (byte) 0xa2, (byte) 0x22,
|
||||
(byte) 0x11, (byte) 0xd2, (byte) 0x8d, (byte) 0x89,
|
||||
(byte) 0xb4, (byte) 0xf7, (byte) 0xd9, (byte) 0xb0,
|
||||
(byte) 0x65, (byte) 0xbc, (byte) 0x14, (byte) 0x8a,
|
||||
(byte) 0x6e, (byte) 0xb0, (byte) 0xa9, (byte) 0x4d,
|
||||
(byte) 0xeb, (byte) 0x93, (byte) 0x99, (byte) 0x6f,
|
||||
(byte) 0x84, (byte) 0x07, (byte) 0x5a, (byte) 0x9e,
|
||||
(byte) 0xbd, (byte) 0xc8, (byte) 0xd1, (byte) 0xeb
|
||||
}, "ChaCha20");
|
||||
|
||||
/// Encrypts the plain payload bytes.
|
||||
///
|
||||
/// @param payload the plain payload bytes
|
||||
/// @param nonce the encryption nonce
|
||||
/// @return the encrypted payload bytes with the authentication tag appended
|
||||
/// @throws JsonParseException if the cipher is not available
|
||||
private byte[] encryptPayload(byte[] payload, byte[] nonce) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
|
||||
return cipher.doFinal(payload);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new JsonParseException("Failed to protect JSON payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypts the protected payload bytes.
|
||||
///
|
||||
/// @param payload the encrypted payload bytes with the authentication tag appended
|
||||
/// @param nonce the encryption nonce
|
||||
/// @return the plain payload bytes
|
||||
/// @throws JsonParseException if the payload cannot be decrypted
|
||||
private byte[] decryptPayload(byte[] payload, byte[] nonce) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CIPHER_TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, PROTECTION_KEY, new IvParameterSpec(nonce));
|
||||
return cipher.doFinal(payload);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new JsonParseException("Failed to reveal protected JSON payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the payload array index storing one lane in an effective payload window.
|
||||
///
|
||||
/// @param laneIndex the lane index
|
||||
/// @param payloadSize the effective payload window size
|
||||
/// @return the payload array index
|
||||
private static int lanePayloadIndex(int laneIndex, int payloadSize) {
|
||||
int segmentSize = payloadSize / OBFUSCATED_LANE_COUNT;
|
||||
return (laneIndex + 1) * segmentSize - 1;
|
||||
}
|
||||
|
||||
/// Splits a Base64 payload into padded lanes.
|
||||
///
|
||||
/// @param payload the Base64 payload to split
|
||||
/// @return the padded payload lanes
|
||||
private static JsonArray splitObfuscatedPayload(String payload) {
|
||||
int laneLength = payload.length() / OBFUSCATED_LANE_COUNT;
|
||||
JsonArray result = new JsonArray(WRITTEN_OBFUSCATED_PAYLOAD_SIZE);
|
||||
for (int laneIndex = 0; laneIndex < OBFUSCATED_LANE_COUNT; laneIndex++) {
|
||||
int start = laneIndex * laneLength;
|
||||
String lane = payload.substring(start, start + laneLength);
|
||||
for (int i = 0; i < LANE_PADDING_COUNT; i++) {
|
||||
result.add(JsonNull.INSTANCE);
|
||||
}
|
||||
result.add(lane);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Joins Base64 payload lanes from the envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @return the restored Base64 payload
|
||||
/// @throws JsonParseException if the payload lanes are missing or malformed
|
||||
private static String joinObfuscatedPayload(JsonObject envelope) {
|
||||
if (!(envelope.get(PROPERTY_PAYLOAD) instanceof JsonArray lanes)
|
||||
|| lanes.size() < OBFUSCATED_LANE_COUNT) {
|
||||
throw new JsonParseException("Missing payload or payload array is too small");
|
||||
}
|
||||
|
||||
int effectivePayloadSize = Integer.highestOneBit(lanes.size());
|
||||
String[] laneTexts = new String[OBFUSCATED_LANE_COUNT];
|
||||
int totalLength = 0;
|
||||
for (int i = 0; i < OBFUSCATED_LANE_COUNT; i++) {
|
||||
int payloadIndex = lanePayloadIndex(i, effectivePayloadSize);
|
||||
JsonElement lane = lanes.get(payloadIndex);
|
||||
if (!lane.isJsonPrimitive() || !lane.getAsJsonPrimitive().isString()) {
|
||||
throw new JsonParseException("Protected payload lane is not a string");
|
||||
}
|
||||
|
||||
laneTexts[i] = lane.getAsString();
|
||||
totalLength += laneTexts[i].length();
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder(totalLength);
|
||||
for (String laneText : laneTexts) {
|
||||
result.append(laneText);
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload) {
|
||||
byte[] nonce = new byte[NONCE_SIZE];
|
||||
SECURE_RANDOM.nextBytes(nonce);
|
||||
writePayload(envelope, payload, nonce);
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope with a caller-provided nonce.
|
||||
@Override
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
|
||||
if (nonce.length != NONCE_SIZE) {
|
||||
throw new JsonParseException("Protected payload nonce has invalid length");
|
||||
}
|
||||
|
||||
String payloadText = JsonUtils.UGLY_GSON.toJson(payload);
|
||||
byte[] payloadBytes = payloadText.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] encryptedPayload = encryptPayload(payloadBytes, nonce);
|
||||
String actualPayload = Base64.getEncoder().encodeToString(encryptedPayload);
|
||||
|
||||
envelope.addProperty(PROPERTY_PROTECTION, id());
|
||||
envelope.add(PROPERTY_PAYLOAD, splitObfuscatedPayload(actualPayload));
|
||||
envelope.addProperty(PROPERTY_NONCE, Base64.getEncoder().encodeToString(nonce));
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
@Override
|
||||
protected JsonElement readPayload(JsonObject envelope) {
|
||||
try {
|
||||
String encodedNonce = JsonUtils.getString(envelope, PROPERTY_NONCE);
|
||||
if (encodedNonce == null) {
|
||||
throw new JsonParseException("Missing protected payload member: nonce");
|
||||
}
|
||||
|
||||
byte[] nonce = Base64.getDecoder().decode(encodedNonce);
|
||||
if (nonce.length != NONCE_SIZE) {
|
||||
throw new JsonParseException("Protected payload nonce has invalid length");
|
||||
}
|
||||
|
||||
String encodedPayload = joinObfuscatedPayload(envelope);
|
||||
byte[] encryptedPayload = Base64.getDecoder().decode(encodedPayload);
|
||||
byte[] payloadBytes = decryptPayload(encryptedPayload, nonce);
|
||||
return JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException("Failed to reveal protected JSON payload", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// The serialized protection marker.
|
||||
private final String id;
|
||||
|
||||
/// Creates a protection mode.
|
||||
///
|
||||
/// @param id the serialized protection marker
|
||||
ProtectionMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/// Returns the serialized protection marker.
|
||||
///
|
||||
/// @return the serialized protection marker
|
||||
String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Writes the payload into the given envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to write into
|
||||
/// @param payload the plain JSON payload
|
||||
/// @throws JsonParseException if the payload cannot be protected
|
||||
protected abstract void writePayload(JsonObject envelope, JsonElement payload);
|
||||
|
||||
/// Writes the payload into the given envelope with a caller-provided nonce.
|
||||
///
|
||||
/// @param envelope the envelope object to write into
|
||||
/// @param payload the plain JSON payload
|
||||
/// @param nonce the nonce to use for modes that require one
|
||||
/// @throws JsonParseException if the payload cannot be protected
|
||||
protected void writePayload(JsonObject envelope, JsonElement payload, byte[] nonce) {
|
||||
writePayload(envelope, payload);
|
||||
}
|
||||
|
||||
/// Reads the payload from the given envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @return the revealed JSON payload
|
||||
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
|
||||
protected abstract JsonElement readPayload(JsonObject envelope);
|
||||
|
||||
/// Reads the payload from the given envelope and checks its JSON element type.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @param payloadType the expected JSON element type
|
||||
/// @return the revealed JSON payload
|
||||
/// @param <T> the expected JSON element type
|
||||
/// @throws JsonParseException if the envelope is malformed, cannot be revealed, or has the wrong payload type
|
||||
public final <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) {
|
||||
Objects.requireNonNull(payloadType);
|
||||
JsonElement payload = readPayload(envelope);
|
||||
if (!payloadType.isInstance(payload)) {
|
||||
throw new JsonParseException("Protected payload is not a " + payloadType.getSimpleName());
|
||||
}
|
||||
return payloadType.cast(payload);
|
||||
}
|
||||
|
||||
/// Returns the write mode selected by a configuration value.
|
||||
///
|
||||
/// Unknown values intentionally fall back to obfuscation so opt-in plain storage cannot be enabled by typos.
|
||||
///
|
||||
/// @param id the configured protection marker
|
||||
/// @return the selected write mode
|
||||
static ProtectionMode fromConfiguredId(@Nullable String id) {
|
||||
for (ProtectionMode mode : values()) {
|
||||
if (mode.id.equals(id)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return OBFUSCATED_V1;
|
||||
}
|
||||
|
||||
/// Reads the protection mode from an envelope.
|
||||
///
|
||||
/// @param envelope the envelope object to inspect
|
||||
/// @return the protection mode declared by the envelope
|
||||
/// @throws JsonParseException if the declared protection mode is unsupported
|
||||
static ProtectionMode fromEnvelope(JsonObject envelope) {
|
||||
String protection = JsonUtils.getString(envelope, PROPERTY_PROTECTION);
|
||||
if (protection == null) {
|
||||
throw new JsonParseException("Missing protected payload member: protection");
|
||||
}
|
||||
|
||||
for (ProtectionMode mode : values()) {
|
||||
if (mode.id.equals(protection)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
throw new JsonParseException("Unsupported protected payload: " + protection);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and reveals a protected JSON payload from an envelope object.
|
||||
///
|
||||
/// @param envelope the envelope object to read from
|
||||
/// @param payloadType the expected JSON element type
|
||||
/// @return the revealed JSON payload
|
||||
/// @param <T> the expected JSON element type
|
||||
/// @throws JsonParseException if the envelope is malformed or cannot be revealed
|
||||
static <T extends JsonElement> T read(JsonObject envelope, Class<T> payloadType) throws JsonParseException {
|
||||
return ProtectionMode.fromEnvelope(envelope).read(envelope, payloadType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import java.net.*;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ProxyManager {
|
||||
@@ -48,28 +48,32 @@ public final class ProxyManager {
|
||||
private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null;
|
||||
|
||||
private static ProxySelector getProxySelector() {
|
||||
if (config().hasProxy()) {
|
||||
Proxy.Type proxyType = config().getProxyType();
|
||||
String host = config().getProxyHost();
|
||||
int port = config().getProxyPort();
|
||||
ProxyType proxyType = settings().proxyTypeProperty().get();
|
||||
return switch (proxyType) {
|
||||
case SYSTEM -> ProxyManager.SYSTEM_DEFAULT;
|
||||
case DIRECT -> NO_PROXY;
|
||||
case HTTP, SOCKS -> {
|
||||
String host = settings().proxyHostProperty().get();
|
||||
int port = settings().proxyPortProperty().get();
|
||||
|
||||
if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) {
|
||||
return NO_PROXY;
|
||||
} else if (port < 0 || port > 0xFFFF) {
|
||||
LOG.warning("Illegal proxy port: " + port);
|
||||
return NO_PROXY;
|
||||
} else {
|
||||
return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port))));
|
||||
if (StringUtils.isBlank(host)) {
|
||||
yield NO_PROXY;
|
||||
} else if (port < 0 || port > 0xFFFF) {
|
||||
LOG.warning("Illegal proxy port: " + port);
|
||||
yield NO_PROXY;
|
||||
} else {
|
||||
yield new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(
|
||||
Objects.requireNonNull(proxyType.jdkType()),
|
||||
new InetSocketAddress(host, port))));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return ProxyManager.SYSTEM_DEFAULT;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SimpleAuthenticator getAuthenticator() {
|
||||
if (config().hasProxy() && config().hasProxyAuth()) {
|
||||
String username = config().getProxyUser();
|
||||
String password = config().getProxyPass();
|
||||
if (settings().proxyTypeProperty().get().usesCustomAddress() && settings().hasProxyAuthProperty().get()) {
|
||||
String username = settings().proxyUserProperty().get();
|
||||
String password = settings().proxyPasswordProperty().get();
|
||||
|
||||
if (username != null || password != null)
|
||||
return new SimpleAuthenticator(
|
||||
@@ -82,7 +86,8 @@ public final class ProxyManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
static void init() {
|
||||
/// Installs proxy and authentication handlers backed by launcher settings.
|
||||
public static void init() {
|
||||
ProxySelector.setDefault(new ProxySelector() {
|
||||
@Override
|
||||
public List<Proxy> select(URI uri) {
|
||||
@@ -104,17 +109,16 @@ public final class ProxyManager {
|
||||
|
||||
defaultProxySelector = getProxySelector();
|
||||
InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector();
|
||||
config().proxyTypeProperty().addListener(updateProxySelector);
|
||||
config().proxyHostProperty().addListener(updateProxySelector);
|
||||
config().proxyPortProperty().addListener(updateProxySelector);
|
||||
config().hasProxyProperty().addListener(updateProxySelector);
|
||||
settings().proxyTypeProperty().addListener(updateProxySelector);
|
||||
settings().proxyHostProperty().addListener(updateProxySelector);
|
||||
settings().proxyPortProperty().addListener(updateProxySelector);
|
||||
|
||||
defaultAuthenticator = getAuthenticator();
|
||||
InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator();
|
||||
config().hasProxyProperty().addListener(updateAuthenticator);
|
||||
config().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
config().proxyUserProperty().addListener(updateAuthenticator);
|
||||
config().proxyPassProperty().addListener(updateAuthenticator);
|
||||
settings().proxyTypeProperty().addListener(updateAuthenticator);
|
||||
settings().hasProxyAuthProperty().addListener(updateAuthenticator);
|
||||
settings().proxyUserProperty().addListener(updateAuthenticator);
|
||||
settings().proxyPasswordProperty().addListener(updateAuthenticator);
|
||||
|
||||
FetchTask.notifyInitialized();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.Proxy;
|
||||
|
||||
/// Launcher proxy selection mode.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public enum ProxyType {
|
||||
/// Use the proxy selector provided by the host JVM or operating system.
|
||||
SYSTEM(null),
|
||||
|
||||
/// Disable proxy usage explicitly.
|
||||
DIRECT(Proxy.Type.DIRECT),
|
||||
|
||||
/// Use a manually configured HTTP proxy.
|
||||
HTTP(Proxy.Type.HTTP),
|
||||
|
||||
/// Use a manually configured SOCKS proxy.
|
||||
SOCKS(Proxy.Type.SOCKS);
|
||||
|
||||
/// The matching JDK proxy type, or `null` when this mode does not map to a concrete proxy.
|
||||
private final @Nullable Proxy.Type jdkType;
|
||||
|
||||
/// Creates a proxy selection mode.
|
||||
///
|
||||
/// @param jdkType the matching JDK proxy type, or `null` when this mode does not map to a concrete proxy
|
||||
ProxyType(@Nullable Proxy.Type jdkType) {
|
||||
this.jdkType = jdkType;
|
||||
}
|
||||
|
||||
/// Returns the matching JDK proxy type.
|
||||
///
|
||||
/// @return the JDK proxy type, or `null` when this mode does not map to a concrete proxy
|
||||
public @Nullable Proxy.Type jdkType() {
|
||||
return jdkType;
|
||||
}
|
||||
|
||||
/// Returns whether this mode needs a custom proxy host and port.
|
||||
public boolean usesCustomAddress() {
|
||||
return this == HTTP || this == SOCKS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Describes whether a settings file can be read and safely overwritten by this HMCL version.
|
||||
@NotNullByDefault
|
||||
public enum SettingFileAccess {
|
||||
/// The file can be read and overwritten safely.
|
||||
READ_WRITE,
|
||||
|
||||
/// The file can be read but must not be overwritten automatically.
|
||||
READ_ONLY,
|
||||
|
||||
/// The file cannot be read safely and must not be overwritten automatically.
|
||||
UNREADABLE;
|
||||
|
||||
/// Returns whether this access mode permits saving the file.
|
||||
public boolean canSave() {
|
||||
return this == READ_WRITE;
|
||||
}
|
||||
|
||||
/// Returns whether this access mode blocks normal editing of the owning feature.
|
||||
public boolean blocksEditing() {
|
||||
return this != READ_WRITE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Utility methods shared by launcher settings files.
|
||||
@NotNullByDefault
|
||||
public final class SettingFileUtils {
|
||||
/// Prevents instantiation.
|
||||
private SettingFileUtils() {
|
||||
}
|
||||
|
||||
/// Moves an invalid config file to a numbered backup path (for example `launcher-settings.json.1`)
|
||||
/// so the original bytes are preserved for diagnosis before the next successful overwrite.
|
||||
public static void backupInvalidConfig(Path location) {
|
||||
try {
|
||||
Path backup = null;
|
||||
for (int i = 1; i < Integer.MAX_VALUE; i++) {
|
||||
Path candidate = location.resolveSibling(location.getFileName() + "." + i);
|
||||
if (!Files.exists(candidate)) {
|
||||
backup = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (backup == null) {
|
||||
LOG.warning("Could not find an available backup path for " + location);
|
||||
return;
|
||||
}
|
||||
Files.move(location, backup, StandardCopyOption.REPLACE_EXISTING);
|
||||
LOG.info("Backed up invalid config to " + backup);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to back up invalid config " + location, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
|
||||
public final class Settings {
|
||||
|
||||
private static Settings instance;
|
||||
|
||||
public static Settings instance() {
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException("Settings hasn't been initialized");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called from {@link ConfigHolder#init()}.
|
||||
*/
|
||||
static void init() {
|
||||
instance = new Settings();
|
||||
}
|
||||
|
||||
private Settings() {
|
||||
DownloadProviders.init();
|
||||
ProxyManager.init();
|
||||
Accounts.init();
|
||||
Profiles.init();
|
||||
AuthlibInjectorServers.init();
|
||||
AnimationUtils.init();
|
||||
|
||||
CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY);
|
||||
HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> {
|
||||
if (FileUtils.canCreateDirectory(getCommonDirectory())) {
|
||||
return getCommonDirectory();
|
||||
} else {
|
||||
return getDefaultCommonDirectory();
|
||||
}
|
||||
}, config().commonDirectoryProperty(), config().commonDirTypeProperty()));
|
||||
}
|
||||
|
||||
public static String getDefaultCommonDirectory() {
|
||||
return Metadata.MINECRAFT_DIRECTORY.toString();
|
||||
}
|
||||
|
||||
public String getCommonDirectory() {
|
||||
switch (config().getCommonDirType()) {
|
||||
case DEFAULT:
|
||||
return getDefaultCommonDirectory();
|
||||
case CUSTOM:
|
||||
return config().getCommonDirectory();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,22 +25,15 @@ import javafx.scene.paint.Color;
|
||||
import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorRole;
|
||||
import org.glavo.monetfx.ColorScheme;
|
||||
import org.jackhuang.hmcl.theme.Theme;
|
||||
import org.jackhuang.hmcl.theme.ResolvedTheme;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
@@ -67,23 +60,8 @@ public final class StyleSheets {
|
||||
});
|
||||
}
|
||||
|
||||
private static String toStyleSheetUri(String styleSheet, String fallback) {
|
||||
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
|
||||
// JavaFX 17+ support loading stylesheets from data URIs
|
||||
// https://bugs.openjdk.org/browse/JDK-8267554
|
||||
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
|
||||
else
|
||||
try {
|
||||
Path temp = Files.createTempFile("hmcl", ".css");
|
||||
// For JavaFX 17 or earlier, CssParser uses the default charset
|
||||
// https://bugs.openjdk.org/browse/JDK-8279328
|
||||
Files.writeString(temp, styleSheet, Charset.defaultCharset());
|
||||
temp.toFile().deleteOnExit();
|
||||
return temp.toUri().toString();
|
||||
} catch (IOException | NullPointerException e) {
|
||||
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
|
||||
return fallback;
|
||||
}
|
||||
private static String toStyleSheetUri(String styleSheet) {
|
||||
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String getFontStyleSheet() {
|
||||
@@ -133,7 +111,7 @@ public final class StyleSheets {
|
||||
|
||||
builder.append('}');
|
||||
|
||||
return toStyleSheetUri(builder.toString(), defaultCss);
|
||||
return toStyleSheetUri(builder.toString());
|
||||
}
|
||||
|
||||
private static String getBrightnessStyleSheet() {
|
||||
@@ -161,7 +139,7 @@ public final class StyleSheets {
|
||||
private static String getThemeStyleSheet() {
|
||||
final String blueCss = "/assets/css/blue.css";
|
||||
|
||||
if (Theme.DEFAULT.equals(Themes.getTheme()))
|
||||
if (ResolvedTheme.DEFAULT.equals(Themes.getTheme()))
|
||||
return blueCss;
|
||||
|
||||
ColorScheme scheme = Themes.getColorScheme();
|
||||
@@ -175,6 +153,7 @@ public final class StyleSheets {
|
||||
addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed());
|
||||
|
||||
addColor(builder, scheme, ColorRole.PRIMARY, 0.5);
|
||||
addColor(builder, scheme, ColorRole.PRIMARY, 0.8);
|
||||
addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5);
|
||||
addColor(builder, scheme, ColorRole.SURFACE, 0.5);
|
||||
addColor(builder, scheme, ColorRole.SURFACE, 0.8);
|
||||
@@ -184,7 +163,7 @@ public final class StyleSheets {
|
||||
addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8);
|
||||
|
||||
builder.append("}\n");
|
||||
return toStyleSheetUri(builder.toString(), blueCss);
|
||||
return toStyleSheetUri(builder.toString());
|
||||
}
|
||||
|
||||
public static void init(Scene scene) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/// Selects the source used to choose the launcher Monet theme color seed.
|
||||
@NotNullByDefault
|
||||
public enum ThemeColorType {
|
||||
/// Uses the built-in launcher default theme color.
|
||||
DEFAULT,
|
||||
|
||||
/// Follows the accent color provided by the operating system.
|
||||
SYSTEM,
|
||||
|
||||
/// Uses the custom launcher theme color selected by the user.
|
||||
CUSTOM,
|
||||
|
||||
/// Extracts the launcher theme color from the current background when possible.
|
||||
BACKGROUND
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// Stores launcher settings shared by all workspaces for the current user.
|
||||
@NotNullByDefault
|
||||
@JsonAdapter(UserSettings.Adapter.class)
|
||||
@JsonSerializable
|
||||
public final class UserSettings extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this user settings store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("user-settings", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Deserializes user settings from JSON.
|
||||
///
|
||||
/// @param json the JSON content to parse
|
||||
/// @return the parsed settings, or {@code null} when the JSON value is {@code null}
|
||||
public static @Nullable UserSettings fromJson(String json) throws JsonParseException {
|
||||
return JsonUtils.fromJson(JsonUtils.GSON, json, UserSettings.class);
|
||||
}
|
||||
|
||||
/// Creates empty user settings with default values.
|
||||
public UserSettings() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Serializes these settings to JSON.
|
||||
public String toJson() {
|
||||
return JsonUtils.GSON.toJson(this);
|
||||
}
|
||||
|
||||
/// The schema used by this user 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 user settings file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this user settings file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this user settings store may be saved back to `config/user-settings.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `config/user-settings.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this user settings store may be saved back to `config/user-settings.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this user settings store may be saved back to `config/user-settings.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `config/user-settings.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `config/user-settings.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The number of launcher log files to retain.
|
||||
@SerializedName("logRetention")
|
||||
private final IntegerProperty logRetention = new SimpleIntegerProperty(20);
|
||||
|
||||
/// Returns the log retention property.
|
||||
public IntegerProperty logRetentionProperty() {
|
||||
return logRetention;
|
||||
}
|
||||
|
||||
/// Whether offline accounts are enabled for this user.
|
||||
@SerializedName("enableOfflineAccount")
|
||||
private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
/// Returns the offline account enablement property.
|
||||
public BooleanProperty enableOfflineAccountProperty() {
|
||||
return enableOfflineAccount;
|
||||
}
|
||||
|
||||
/// The JavaFX font antialiasing mode override.
|
||||
@SerializedName("fontAntiAliasing")
|
||||
private final StringProperty fontAntiAliasing = new SimpleStringProperty();
|
||||
|
||||
/// Returns the JavaFX font antialiasing mode override property.
|
||||
public StringProperty fontAntiAliasingProperty() {
|
||||
return fontAntiAliasing;
|
||||
}
|
||||
|
||||
/// User-added Java executable paths.
|
||||
@SerializedName("userJava")
|
||||
private final ObservableSet<String> userJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
/// Returns user-added Java executable paths.
|
||||
public ObservableSet<String> getUserJava() {
|
||||
return userJava;
|
||||
}
|
||||
|
||||
/// Disabled Java executable paths.
|
||||
@SerializedName("disabledJava")
|
||||
private final ObservableSet<String> disabledJava = FXCollections.observableSet(new LinkedHashSet<>());
|
||||
|
||||
/// Returns disabled Java executable paths.
|
||||
public ObservableSet<String> getDisabledJava() {
|
||||
return disabledJava;
|
||||
}
|
||||
|
||||
/// Gson adapter for observable user settings.
|
||||
static final class Adapter extends ObservableSetting.Adapter<UserSettings> {
|
||||
/// Creates an empty user settings instance during deserialization.
|
||||
@Override
|
||||
protected UserSettings createInstance() {
|
||||
return new UserSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.IntegerProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSchema;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.ObservableSetting;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/// Stores launcher state shared by all workspaces for the current user.
|
||||
@NotNullByDefault
|
||||
@JsonAdapter(UserState.Adapter.class)
|
||||
@JsonSerializable
|
||||
public final class UserState extends ObservableSetting implements JsonSchemaSetting {
|
||||
/// The JSON schema supported by this user state store.
|
||||
public static final JsonSchema CURRENT_SCHEMA =
|
||||
new JsonSchema("user-state", new JsonSchema.Version(1, 0, 0));
|
||||
|
||||
/// Deserializes user state from JSON.
|
||||
///
|
||||
/// @param json the JSON content to parse
|
||||
/// @return the parsed state, or {@code null} when the JSON value is {@code null}
|
||||
public static @Nullable UserState fromJson(String json) throws JsonParseException {
|
||||
return JsonUtils.fromJson(JsonUtils.GSON, json, UserState.class);
|
||||
}
|
||||
|
||||
/// Creates empty user state with default values.
|
||||
public UserState() {
|
||||
tracker.markDirty(schema);
|
||||
register();
|
||||
}
|
||||
|
||||
/// Serializes this state to JSON.
|
||||
public String toJson() {
|
||||
return JsonUtils.GSON.toJson(this);
|
||||
}
|
||||
|
||||
/// The schema used by this user 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 user state file.
|
||||
@Override
|
||||
public JsonSchema getSchema() {
|
||||
return schema.get();
|
||||
}
|
||||
|
||||
/// Sets the schema used by this user state file.
|
||||
@Override
|
||||
public void setSchema(JsonSchema schema) {
|
||||
this.schema.set(Objects.requireNonNull(schema));
|
||||
}
|
||||
|
||||
/// Whether this user state store may be saved back to `state/user-state.json`.
|
||||
private transient boolean savable = true;
|
||||
|
||||
/// Whether the next successful save should back up the current `state/user-state.json` first.
|
||||
private transient boolean backupOnNextSave;
|
||||
|
||||
/// Returns whether this user state store may be saved back to `state/user-state.json`.
|
||||
@Override
|
||||
public boolean isSavable() {
|
||||
return savable;
|
||||
}
|
||||
|
||||
/// Sets whether this user state store may be saved back to `state/user-state.json`.
|
||||
@Override
|
||||
public void setSavable(boolean savable) {
|
||||
this.savable = savable;
|
||||
}
|
||||
|
||||
/// Returns whether the next successful save should back up the current `state/user-state.json` first.
|
||||
@Override
|
||||
public boolean isBackupOnNextSave() {
|
||||
return backupOnNextSave;
|
||||
}
|
||||
|
||||
/// Sets whether the next successful save should back up the current `state/user-state.json` first.
|
||||
@Override
|
||||
public void setBackupOnNextSave(boolean backupOnNextSave) {
|
||||
this.backupOnNextSave = backupOnNextSave;
|
||||
}
|
||||
|
||||
/// The accepted launcher agreement version.
|
||||
@SerializedName("agreementVersion")
|
||||
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the accepted launcher agreement version property.
|
||||
public IntegerProperty agreementVersionProperty() {
|
||||
return agreementVersion;
|
||||
}
|
||||
|
||||
/// The accepted Terracotta agreement version.
|
||||
@SerializedName("terracottaAgreementVersion")
|
||||
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the accepted Terracotta agreement version property.
|
||||
public IntegerProperty terracottaAgreementVersionProperty() {
|
||||
return terracottaAgreementVersion;
|
||||
}
|
||||
|
||||
/// The platform prompt version shown to the user.
|
||||
@SerializedName("platformPromptVersion")
|
||||
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
/// Returns the platform prompt version property.
|
||||
public IntegerProperty platformPromptVersionProperty() {
|
||||
return platformPromptVersion;
|
||||
}
|
||||
|
||||
/// Gson adapter for observable user state.
|
||||
static final class Adapter extends ObservableSetting.Adapter<UserState> {
|
||||
/// Creates an empty user state instance during deserialization.
|
||||
@Override
|
||||
protected UserState createInstance() {
|
||||
return new UserState();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,962 +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 org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author huangyuhui
|
||||
*/
|
||||
@JsonAdapter(VersionSetting.Serializer.class)
|
||||
public final class VersionSetting implements Cloneable, Observable {
|
||||
|
||||
private static final int SUGGESTED_MEMORY;
|
||||
|
||||
static {
|
||||
double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize());
|
||||
SUGGESTED_MEMORY = totalMemoryMB >= 32768
|
||||
? 8192
|
||||
: Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256);
|
||||
}
|
||||
|
||||
private final transient ObservableHelper helper = new ObservableHelper(this);
|
||||
|
||||
public VersionSetting() {
|
||||
PropertyUtils.attachListener(this, helper);
|
||||
}
|
||||
|
||||
private final BooleanProperty usesGlobalProperty = new SimpleBooleanProperty(this, "usesGlobal", true);
|
||||
|
||||
public BooleanProperty usesGlobalProperty() {
|
||||
return usesGlobalProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* HMCL Version Settings have been divided into 2 parts.
|
||||
* 1. Global settings.
|
||||
* 2. Version settings.
|
||||
* If a version claims that it uses global settings, its version setting will be disabled.
|
||||
* <p>
|
||||
* Defaults false because if one version uses global first, custom version file will not be generated.
|
||||
*/
|
||||
public boolean isUsesGlobal() {
|
||||
return usesGlobalProperty.get();
|
||||
}
|
||||
|
||||
public void setUsesGlobal(boolean usesGlobal) {
|
||||
usesGlobalProperty.set(usesGlobal);
|
||||
}
|
||||
|
||||
// java
|
||||
|
||||
private final ObjectProperty<JavaVersionType> javaVersionTypeProperty = new SimpleObjectProperty<>(this, "javaVersionType", JavaVersionType.AUTO);
|
||||
|
||||
public ObjectProperty<JavaVersionType> javaVersionTypeProperty() {
|
||||
return javaVersionTypeProperty;
|
||||
}
|
||||
|
||||
public JavaVersionType getJavaVersionType() {
|
||||
return javaVersionTypeProperty.get();
|
||||
}
|
||||
|
||||
public void setJavaVersionType(JavaVersionType javaVersionType) {
|
||||
javaVersionTypeProperty.set(javaVersionType);
|
||||
}
|
||||
|
||||
private final StringProperty javaVersionProperty = new SimpleStringProperty(this, "javaVersion", "");
|
||||
|
||||
public StringProperty javaVersionProperty() {
|
||||
return javaVersionProperty;
|
||||
}
|
||||
|
||||
public String getJavaVersion() {
|
||||
return javaVersionProperty.get();
|
||||
}
|
||||
|
||||
public void setJavaVersion(String java) {
|
||||
javaVersionProperty.set(java);
|
||||
}
|
||||
|
||||
public void setUsesCustomJavaDir() {
|
||||
setJavaVersionType(JavaVersionType.CUSTOM);
|
||||
setJavaVersion("");
|
||||
setDefaultJavaPath(null);
|
||||
}
|
||||
|
||||
public void setJavaAutoSelected() {
|
||||
setJavaVersionType(JavaVersionType.AUTO);
|
||||
setJavaVersion("");
|
||||
setDefaultJavaPath(null);
|
||||
}
|
||||
|
||||
private final StringProperty defaultJavaPathProperty = new SimpleStringProperty(this, "defaultJavaPath", "");
|
||||
|
||||
/**
|
||||
* Path to Java executable, or null if user customizes java directory.
|
||||
* It's used to determine which JRE to use when multiple JREs match the selected Java version.
|
||||
*/
|
||||
public String getDefaultJavaPath() {
|
||||
return defaultJavaPathProperty.get();
|
||||
}
|
||||
|
||||
public StringProperty defaultJavaPathPropertyProperty() {
|
||||
return defaultJavaPathProperty;
|
||||
}
|
||||
|
||||
public void setDefaultJavaPath(String defaultJavaPath) {
|
||||
defaultJavaPathProperty.set(defaultJavaPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 - .minecraft/versions/<version>/natives/<br/>
|
||||
*/
|
||||
private final ObjectProperty<NativesDirectoryType> nativesDirTypeProperty = new SimpleObjectProperty<>(this, "nativesDirType", NativesDirectoryType.VERSION_FOLDER);
|
||||
|
||||
public ObjectProperty<NativesDirectoryType> nativesDirTypeProperty() {
|
||||
return nativesDirTypeProperty;
|
||||
}
|
||||
|
||||
public NativesDirectoryType getNativesDirType() {
|
||||
return nativesDirTypeProperty.get();
|
||||
}
|
||||
|
||||
public void setNativesDirType(NativesDirectoryType nativesDirType) {
|
||||
nativesDirTypeProperty.set(nativesDirType);
|
||||
}
|
||||
|
||||
// Path to lwjgl natives directory
|
||||
|
||||
private final StringProperty nativesDirProperty = new SimpleStringProperty(this, "nativesDirProperty", "");
|
||||
|
||||
public StringProperty nativesDirProperty() {
|
||||
return nativesDirProperty;
|
||||
}
|
||||
|
||||
public String getNativesDir() {
|
||||
return nativesDirProperty.get();
|
||||
}
|
||||
|
||||
public void setNativesDir(String nativesDir) {
|
||||
nativesDirProperty.set(nativesDir);
|
||||
}
|
||||
|
||||
private final StringProperty javaDirProperty = new SimpleStringProperty(this, "javaDir", "");
|
||||
|
||||
public StringProperty javaDirProperty() {
|
||||
return javaDirProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* User customized java directory or null if user uses system Java.
|
||||
*/
|
||||
public String getJavaDir() {
|
||||
return javaDirProperty.get();
|
||||
}
|
||||
|
||||
public void setJavaDir(String javaDir) {
|
||||
javaDirProperty.set(javaDir);
|
||||
}
|
||||
|
||||
private final StringProperty wrapperProperty = new SimpleStringProperty(this, "wrapper", "");
|
||||
|
||||
public StringProperty wrapperProperty() {
|
||||
return wrapperProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command to launch java, i.e. optirun.
|
||||
*/
|
||||
public String getWrapper() {
|
||||
return wrapperProperty.get();
|
||||
}
|
||||
|
||||
public void setWrapper(String wrapper) {
|
||||
wrapperProperty.set(wrapper);
|
||||
}
|
||||
|
||||
private final StringProperty permSizeProperty = new SimpleStringProperty(this, "permSize", "");
|
||||
|
||||
public StringProperty permSizeProperty() {
|
||||
return permSizeProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permanent generation size of JVM garbage collection.
|
||||
*/
|
||||
public String getPermSize() {
|
||||
return permSizeProperty.get();
|
||||
}
|
||||
|
||||
public void setPermSize(String permSize) {
|
||||
permSizeProperty.set(permSize);
|
||||
}
|
||||
|
||||
private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY);
|
||||
|
||||
public IntegerProperty maxMemoryProperty() {
|
||||
return maxMemoryProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum memory/MB that JVM can allocate for heap.
|
||||
*/
|
||||
public int getMaxMemory() {
|
||||
return maxMemoryProperty.get();
|
||||
}
|
||||
|
||||
public void setMaxMemory(int maxMemory) {
|
||||
maxMemoryProperty.set(maxMemory);
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum memory that JVM can allocate for heap.
|
||||
*/
|
||||
private final ObjectProperty<Integer> minMemoryProperty = new SimpleObjectProperty<>(this, "minMemory", null);
|
||||
|
||||
public ObjectProperty<Integer> minMemoryProperty() {
|
||||
return minMemoryProperty;
|
||||
}
|
||||
|
||||
public Integer getMinMemory() {
|
||||
return minMemoryProperty.get();
|
||||
}
|
||||
|
||||
public void setMinMemory(Integer minMemory) {
|
||||
minMemoryProperty.set(minMemory);
|
||||
}
|
||||
|
||||
private final BooleanProperty autoMemory = new SimpleBooleanProperty(this, "autoMemory", true);
|
||||
|
||||
public boolean isAutoMemory() {
|
||||
return autoMemory.get();
|
||||
}
|
||||
|
||||
public BooleanProperty autoMemoryProperty() {
|
||||
return autoMemory;
|
||||
}
|
||||
|
||||
public void setAutoMemory(boolean autoMemory) {
|
||||
this.autoMemory.set(autoMemory);
|
||||
}
|
||||
|
||||
private final StringProperty preLaunchCommandProperty = new SimpleStringProperty(this, "precalledCommand", "");
|
||||
|
||||
public StringProperty preLaunchCommandProperty() {
|
||||
return preLaunchCommandProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command that will be executed before launching the Minecraft.
|
||||
* Operating system relevant.
|
||||
*/
|
||||
public String getPreLaunchCommand() {
|
||||
return preLaunchCommandProperty.get();
|
||||
}
|
||||
|
||||
public void setPreLaunchCommand(String preLaunchCommand) {
|
||||
preLaunchCommandProperty.set(preLaunchCommand);
|
||||
}
|
||||
|
||||
private final StringProperty postExitCommand = new SimpleStringProperty(this, "postExitCommand", "");
|
||||
|
||||
public StringProperty postExitCommandProperty() {
|
||||
return postExitCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command that will be executed after game exits.
|
||||
* Operating system relevant.
|
||||
*/
|
||||
public String getPostExitCommand() {
|
||||
return postExitCommand.get();
|
||||
}
|
||||
|
||||
public void setPostExitCommand(String postExitCommand) {
|
||||
this.postExitCommand.set(postExitCommand);
|
||||
}
|
||||
|
||||
// options
|
||||
|
||||
private final StringProperty javaArgsProperty = new SimpleStringProperty(this, "javaArgs", "");
|
||||
|
||||
public StringProperty javaArgsProperty() {
|
||||
return javaArgsProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user customized arguments passed to JVM.
|
||||
*/
|
||||
public String getJavaArgs() {
|
||||
return javaArgsProperty.get();
|
||||
}
|
||||
|
||||
public void setJavaArgs(String javaArgs) {
|
||||
javaArgsProperty.set(javaArgs);
|
||||
}
|
||||
|
||||
private final StringProperty minecraftArgsProperty = new SimpleStringProperty(this, "minecraftArgs", "");
|
||||
|
||||
public StringProperty minecraftArgsProperty() {
|
||||
return minecraftArgsProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user customized arguments passed to Minecraft.
|
||||
*/
|
||||
public String getMinecraftArgs() {
|
||||
return minecraftArgsProperty.get();
|
||||
}
|
||||
|
||||
public void setMinecraftArgs(String minecraftArgs) {
|
||||
minecraftArgsProperty.set(minecraftArgs);
|
||||
}
|
||||
|
||||
private final StringProperty environmentVariablesProperty = new SimpleStringProperty(this, "environmentVariables", "");
|
||||
|
||||
public StringProperty environmentVariablesProperty() {
|
||||
return environmentVariablesProperty;
|
||||
}
|
||||
|
||||
public String getEnvironmentVariables() {
|
||||
return environmentVariablesProperty.get();
|
||||
}
|
||||
|
||||
public void setEnvironmentVariables(String env) {
|
||||
environmentVariablesProperty.set(env);
|
||||
}
|
||||
|
||||
private final BooleanProperty noJVMArgsProperty = new SimpleBooleanProperty(this, "noJVMArgs", false);
|
||||
|
||||
public BooleanProperty noJVMArgsProperty() {
|
||||
return noJVMArgsProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if disallow HMCL use default JVM arguments.
|
||||
*/
|
||||
public boolean isNoJVMArgs() {
|
||||
return noJVMArgsProperty.get();
|
||||
}
|
||||
|
||||
public void setNoJVMArgs(boolean noJVMArgs) {
|
||||
noJVMArgsProperty.set(noJVMArgs);
|
||||
}
|
||||
|
||||
private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false);
|
||||
|
||||
public BooleanProperty noOptimizingJVMArgsProperty() {
|
||||
return noOptimizingJVMArgsProperty;
|
||||
}
|
||||
|
||||
public boolean isNoOptimizingJVMArgs() {
|
||||
return noOptimizingJVMArgsProperty.get();
|
||||
}
|
||||
|
||||
public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) {
|
||||
noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs);
|
||||
}
|
||||
|
||||
private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false);
|
||||
|
||||
public BooleanProperty notCheckJVMProperty() {
|
||||
return notCheckJVMProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if HMCL does not check JVM validity.
|
||||
*/
|
||||
public boolean isNotCheckJVM() {
|
||||
return notCheckJVMProperty.get();
|
||||
}
|
||||
|
||||
public void setNotCheckJVM(boolean notCheckJVM) {
|
||||
notCheckJVMProperty.set(notCheckJVM);
|
||||
}
|
||||
|
||||
private final BooleanProperty notCheckGameProperty = new SimpleBooleanProperty(this, "notCheckGame", false);
|
||||
|
||||
public BooleanProperty notCheckGameProperty() {
|
||||
return notCheckGameProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if HMCL does not check game's completeness.
|
||||
*/
|
||||
public boolean isNotCheckGame() {
|
||||
return notCheckGameProperty.get();
|
||||
}
|
||||
|
||||
public void setNotCheckGame(boolean notCheckGame) {
|
||||
notCheckGameProperty.set(notCheckGame);
|
||||
}
|
||||
|
||||
private final BooleanProperty notPatchNativesProperty = new SimpleBooleanProperty(this, "notPatchNatives", false);
|
||||
|
||||
public BooleanProperty notPatchNativesProperty() {
|
||||
return notPatchNativesProperty;
|
||||
}
|
||||
|
||||
public boolean isNotPatchNatives() {
|
||||
return notPatchNativesProperty.get();
|
||||
}
|
||||
|
||||
public void setNotPatchNatives(boolean notPatchNatives) {
|
||||
notPatchNativesProperty.set(notPatchNatives);
|
||||
}
|
||||
|
||||
private final BooleanProperty showLogsProperty = new SimpleBooleanProperty(this, "showLogs", false);
|
||||
|
||||
public BooleanProperty showLogsProperty() {
|
||||
return showLogsProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if show the logs after game launched.
|
||||
*/
|
||||
public boolean isShowLogs() {
|
||||
return showLogsProperty.get();
|
||||
}
|
||||
|
||||
public void setShowLogs(boolean showLogs) {
|
||||
showLogsProperty.set(showLogs);
|
||||
}
|
||||
|
||||
private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false);
|
||||
|
||||
public BooleanProperty enableDebugLogOutputProperty() {
|
||||
return enableDebugLogOutputProperty;
|
||||
}
|
||||
|
||||
public boolean isEnableDebugLogOutput() {
|
||||
return enableDebugLogOutputProperty.get();
|
||||
}
|
||||
|
||||
public void setEnableDebugLogOutput(boolean u) {
|
||||
this.enableDebugLogOutputProperty.set(u);
|
||||
}
|
||||
|
||||
// Minecraft settings.
|
||||
|
||||
private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", "");
|
||||
|
||||
public StringProperty serverIpProperty() {
|
||||
return serverIpProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server ip that will be entered after Minecraft successfully loaded ly.
|
||||
* <p>
|
||||
* Format: ip:port or without port.
|
||||
*/
|
||||
public String getServerIp() {
|
||||
return serverIpProperty.get();
|
||||
}
|
||||
|
||||
public void setServerIp(String serverIp) {
|
||||
serverIpProperty.set(serverIp);
|
||||
}
|
||||
|
||||
|
||||
private final BooleanProperty fullscreenProperty = new SimpleBooleanProperty(this, "fullscreen", false);
|
||||
|
||||
public BooleanProperty fullscreenProperty() {
|
||||
return fullscreenProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if Minecraft started in fullscreen mode.
|
||||
*/
|
||||
public boolean isFullscreen() {
|
||||
return fullscreenProperty.get();
|
||||
}
|
||||
|
||||
public void setFullscreen(boolean fullscreen) {
|
||||
fullscreenProperty.set(fullscreen);
|
||||
}
|
||||
|
||||
private final IntegerProperty widthProperty = new SimpleIntegerProperty(this, "width", 854);
|
||||
|
||||
public IntegerProperty widthProperty() {
|
||||
return widthProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The width of Minecraft window, defaults 800.
|
||||
* <p>
|
||||
* The field saves int value.
|
||||
* String type prevents unexpected value from JsonParseException.
|
||||
* We can only reset this field instead of recreating the whole setting file.
|
||||
*/
|
||||
public int getWidth() {
|
||||
return widthProperty.get();
|
||||
}
|
||||
|
||||
public void setWidth(int width) {
|
||||
widthProperty.set(width);
|
||||
}
|
||||
|
||||
private final IntegerProperty heightProperty = new SimpleIntegerProperty(this, "height", 480);
|
||||
|
||||
public IntegerProperty heightProperty() {
|
||||
return heightProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* The height of Minecraft window, defaults 480.
|
||||
* <p>
|
||||
* The field saves int value.
|
||||
* String type prevents unexpected value from JsonParseException.
|
||||
* We can only reset this field instead of recreating the whole setting file.
|
||||
*/
|
||||
public int getHeight() {
|
||||
return heightProperty.get();
|
||||
}
|
||||
|
||||
public void setHeight(int height) {
|
||||
heightProperty.set(height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 - .minecraft<br/>
|
||||
* 1 - .minecraft/versions/<version>/<br/>
|
||||
*/
|
||||
private final ObjectProperty<GameDirectoryType> gameDirTypeProperty = new SimpleObjectProperty<>(this, "gameDirType", GameDirectoryType.ROOT_FOLDER);
|
||||
|
||||
public ObjectProperty<GameDirectoryType> gameDirTypeProperty() {
|
||||
return gameDirTypeProperty;
|
||||
}
|
||||
|
||||
public GameDirectoryType getGameDirType() {
|
||||
return gameDirTypeProperty.get();
|
||||
}
|
||||
|
||||
public void setGameDirType(GameDirectoryType gameDirType) {
|
||||
gameDirTypeProperty.set(gameDirType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Your custom gameDir
|
||||
*/
|
||||
private final StringProperty gameDirProperty = new SimpleStringProperty(this, "gameDir", "");
|
||||
|
||||
public StringProperty gameDirProperty() {
|
||||
return gameDirProperty;
|
||||
}
|
||||
|
||||
public String getGameDir() {
|
||||
return gameDirProperty.get();
|
||||
}
|
||||
|
||||
public void setGameDir(String gameDir) {
|
||||
gameDirProperty.set(gameDir);
|
||||
}
|
||||
|
||||
private final ObjectProperty<ProcessPriority> processPriorityProperty = new SimpleObjectProperty<>(this, "processPriority", ProcessPriority.NORMAL);
|
||||
|
||||
public ObjectProperty<ProcessPriority> processPriorityProperty() {
|
||||
return processPriorityProperty;
|
||||
}
|
||||
|
||||
public ProcessPriority getProcessPriority() {
|
||||
return processPriorityProperty.get();
|
||||
}
|
||||
|
||||
public void setProcessPriority(ProcessPriority processPriority) {
|
||||
processPriorityProperty.set(processPriority);
|
||||
}
|
||||
|
||||
private final ObjectProperty<GraphicsAPI> graphicsBackend = new SimpleObjectProperty<>(this, "graphicsBackend", GraphicsAPI.DEFAULT);
|
||||
|
||||
public ObjectProperty<GraphicsAPI> graphicsBackendProperty() {
|
||||
return graphicsBackend;
|
||||
}
|
||||
|
||||
public GraphicsAPI getGraphicsBackend() {
|
||||
return graphicsBackendProperty().get();
|
||||
}
|
||||
|
||||
public void setGraphicsBackend(GraphicsAPI api) {
|
||||
graphicsBackendProperty().set(api);
|
||||
}
|
||||
|
||||
private final ObjectProperty<Renderer> rendererProperty = new SimpleObjectProperty<>(this, "renderer", Renderer.DEFAULT);
|
||||
|
||||
public Renderer getRenderer() {
|
||||
return rendererProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Renderer> rendererProperty() {
|
||||
return rendererProperty;
|
||||
}
|
||||
|
||||
public void setRenderer(Renderer renderer) {
|
||||
this.rendererProperty.set(renderer);
|
||||
}
|
||||
|
||||
private final BooleanProperty useNativeGLFW = new SimpleBooleanProperty(this, "nativeGLFW", false);
|
||||
|
||||
public boolean isUseNativeGLFW() {
|
||||
return useNativeGLFW.get();
|
||||
}
|
||||
|
||||
public BooleanProperty useNativeGLFWProperty() {
|
||||
return useNativeGLFW;
|
||||
}
|
||||
|
||||
public void setUseNativeGLFW(boolean useNativeGLFW) {
|
||||
this.useNativeGLFW.set(useNativeGLFW);
|
||||
}
|
||||
|
||||
private final BooleanProperty useNativeOpenAL = new SimpleBooleanProperty(this, "nativeOpenAL", false);
|
||||
|
||||
public boolean isUseNativeOpenAL() {
|
||||
return useNativeOpenAL.get();
|
||||
}
|
||||
|
||||
public BooleanProperty useNativeOpenALProperty() {
|
||||
return useNativeOpenAL;
|
||||
}
|
||||
|
||||
public void setUseNativeOpenAL(boolean useNativeOpenAL) {
|
||||
this.useNativeOpenAL.set(useNativeOpenAL);
|
||||
}
|
||||
|
||||
private final ObjectProperty<VersionIconType> versionIcon = new SimpleObjectProperty<>(this, "versionIcon", VersionIconType.DEFAULT);
|
||||
|
||||
public VersionIconType getVersionIcon() {
|
||||
return versionIcon.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<VersionIconType> versionIconProperty() {
|
||||
return versionIcon;
|
||||
}
|
||||
|
||||
public void setVersionIcon(VersionIconType versionIcon) {
|
||||
this.versionIcon.set(versionIcon);
|
||||
}
|
||||
|
||||
// launcher settings
|
||||
|
||||
/**
|
||||
* 0 - Close the launcher when the game starts.<br/>
|
||||
* 1 - Hide the launcher when the game starts.<br/>
|
||||
* 2 - Keep the launcher open.<br/>
|
||||
*/
|
||||
private final ObjectProperty<LauncherVisibility> launcherVisibilityProperty = new SimpleObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE);
|
||||
|
||||
public ObjectProperty<LauncherVisibility> launcherVisibilityProperty() {
|
||||
return launcherVisibilityProperty;
|
||||
}
|
||||
|
||||
public LauncherVisibility getLauncherVisibility() {
|
||||
return launcherVisibilityProperty.get();
|
||||
}
|
||||
|
||||
public void setLauncherVisibility(LauncherVisibility launcherVisibility) {
|
||||
launcherVisibilityProperty.set(launcherVisibility);
|
||||
}
|
||||
|
||||
public JavaRuntime getJava(GameVersionNumber gameVersion, Version version) throws InterruptedException {
|
||||
switch (getJavaVersionType()) {
|
||||
case DEFAULT:
|
||||
return JavaRuntime.getDefault();
|
||||
case AUTO:
|
||||
return JavaManager.findSuitableJava(gameVersion, version);
|
||||
case CUSTOM:
|
||||
try {
|
||||
return JavaManager.getJava(Paths.get(getJavaDir()));
|
||||
} catch (IOException | InvalidPathException e) {
|
||||
return null; // Custom Java not found
|
||||
}
|
||||
case VERSION: {
|
||||
String javaVersion = getJavaVersion();
|
||||
if (StringUtils.isBlank(javaVersion)) {
|
||||
return JavaManager.findSuitableJava(gameVersion, version);
|
||||
}
|
||||
|
||||
int majorVersion = -1;
|
||||
try {
|
||||
majorVersion = Integer.parseInt(javaVersion);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
if (majorVersion < 0) {
|
||||
LOG.warning("Invalid Java version: " + javaVersion);
|
||||
return null;
|
||||
}
|
||||
|
||||
final int finalMajorVersion = majorVersion;
|
||||
Collection<JavaRuntime> allJava = JavaManager.getAllJava().stream()
|
||||
.filter(it -> it.getParsedVersion() == finalMajorVersion)
|
||||
.collect(Collectors.toList());
|
||||
return JavaManager.findSuitableJava(allJava, gameVersion, version);
|
||||
}
|
||||
case DETECTED: {
|
||||
String javaVersion = getJavaVersion();
|
||||
if (StringUtils.isBlank(javaVersion)) {
|
||||
return JavaManager.findSuitableJava(gameVersion, version);
|
||||
}
|
||||
|
||||
try {
|
||||
String defaultJavaPath = getDefaultJavaPath();
|
||||
if (StringUtils.isNotBlank(defaultJavaPath)) {
|
||||
JavaRuntime java = JavaManager.getJava(Paths.get(defaultJavaPath).toRealPath());
|
||||
if (java != null && java.getVersion().equals(javaVersion)) {
|
||||
return java;
|
||||
}
|
||||
}
|
||||
} catch (IOException | InvalidPathException ignored) {
|
||||
}
|
||||
|
||||
for (JavaRuntime java : JavaManager.getAllJava()) {
|
||||
if (java.getVersion().equals(javaVersion)) {
|
||||
return java;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
throw new AssertionError("JavaVersionType: " + getJavaVersionType());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
helper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
helper.removeListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VersionSetting clone() {
|
||||
VersionSetting cloned = new VersionSetting();
|
||||
PropertyUtils.copyProperties(this, cloned);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
public static class Serializer implements JsonSerializer<VersionSetting>, JsonDeserializer<VersionSetting> {
|
||||
@Override
|
||||
public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
if (src == null) return JsonNull.INSTANCE;
|
||||
JsonObject obj = new JsonObject();
|
||||
|
||||
obj.addProperty("usesGlobal", src.isUsesGlobal());
|
||||
obj.addProperty("javaArgs", src.getJavaArgs());
|
||||
obj.addProperty("minecraftArgs", src.getMinecraftArgs());
|
||||
obj.addProperty("environmentVariables", src.getEnvironmentVariables());
|
||||
obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory());
|
||||
obj.addProperty("minMemory", src.getMinMemory());
|
||||
obj.addProperty("autoMemory", src.isAutoMemory());
|
||||
obj.addProperty("permSize", src.getPermSize());
|
||||
obj.addProperty("width", src.getWidth());
|
||||
obj.addProperty("height", src.getHeight());
|
||||
obj.addProperty("javaDir", src.getJavaDir());
|
||||
obj.addProperty("precalledCommand", src.getPreLaunchCommand());
|
||||
obj.addProperty("postExitCommand", src.getPostExitCommand());
|
||||
obj.addProperty("serverIp", src.getServerIp());
|
||||
obj.addProperty("wrapper", src.getWrapper());
|
||||
obj.addProperty("fullscreen", src.isFullscreen());
|
||||
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
|
||||
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
|
||||
obj.addProperty("notCheckGame", src.isNotCheckGame());
|
||||
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
|
||||
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
|
||||
obj.addProperty("showLogs", src.isShowLogs());
|
||||
obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput());
|
||||
obj.addProperty("gameDir", src.getGameDir());
|
||||
obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal());
|
||||
obj.addProperty("processPriority", src.getProcessPriority().ordinal());
|
||||
obj.addProperty("useNativeGLFW", src.isUseNativeGLFW());
|
||||
obj.addProperty("useNativeOpenAL", src.isUseNativeOpenAL());
|
||||
obj.addProperty("gameDirType", src.getGameDirType().ordinal());
|
||||
obj.addProperty("defaultJavaPath", src.getDefaultJavaPath());
|
||||
obj.addProperty("nativesDir", src.getNativesDir());
|
||||
obj.addProperty("nativesDirType", src.getNativesDirType().ordinal());
|
||||
obj.addProperty("versionIcon", src.getVersionIcon().ordinal());
|
||||
|
||||
obj.addProperty("javaVersionType", src.getJavaVersionType().name());
|
||||
String java;
|
||||
switch (src.getJavaVersionType()) {
|
||||
case DEFAULT:
|
||||
java = "Default";
|
||||
break;
|
||||
case AUTO:
|
||||
java = "Auto";
|
||||
break;
|
||||
case CUSTOM:
|
||||
java = "Custom";
|
||||
break;
|
||||
default:
|
||||
java = src.getJavaVersion();
|
||||
break;
|
||||
}
|
||||
obj.addProperty("java", java);
|
||||
|
||||
obj.addProperty("graphicsBackend", src.getGraphicsBackend().name());
|
||||
obj.addProperty("renderer", src.getRenderer().name());
|
||||
if (src.getRenderer() == Renderer.OpenGL.LLVMPIPE)
|
||||
obj.addProperty("useSoftwareRenderer", true);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
if (!(json instanceof JsonObject))
|
||||
return null;
|
||||
JsonObject obj = (JsonObject) json;
|
||||
|
||||
int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY);
|
||||
if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY;
|
||||
|
||||
VersionSetting vs = new VersionSetting();
|
||||
|
||||
vs.setUsesGlobal(Optional.ofNullable(obj.get("usesGlobal")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setJavaArgs(Optional.ofNullable(obj.get("javaArgs")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setMinecraftArgs(Optional.ofNullable(obj.get("minecraftArgs")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setEnvironmentVariables(Optional.ofNullable(obj.get("environmentVariables")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setMaxMemory(maxMemoryN);
|
||||
vs.setMinMemory(Optional.ofNullable(obj.get("minMemory")).map(JsonElement::getAsInt).orElse(null));
|
||||
vs.setAutoMemory(Optional.ofNullable(obj.get("autoMemory")).map(JsonElement::getAsBoolean).orElse(true));
|
||||
vs.setPermSize(Optional.ofNullable(obj.get("permSize")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setWidth(Optional.ofNullable(obj.get("width")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0));
|
||||
vs.setHeight(Optional.ofNullable(obj.get("height")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0));
|
||||
vs.setJavaDir(Optional.ofNullable(obj.get("javaDir")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setPreLaunchCommand(Optional.ofNullable(obj.get("precalledCommand")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setPostExitCommand(Optional.ofNullable(obj.get("postExitCommand")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setServerIp(Optional.ofNullable(obj.get("serverIp")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setWrapper(Optional.ofNullable(obj.get("wrapper")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setGameDir(Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE));
|
||||
vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL));
|
||||
vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER));
|
||||
vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null));
|
||||
vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER));
|
||||
vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT));
|
||||
|
||||
if (obj.get("javaVersionType") != null) {
|
||||
JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO);
|
||||
vs.setJavaVersionType(javaVersionType);
|
||||
vs.setJavaVersion(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(null));
|
||||
} else {
|
||||
String java = Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse("");
|
||||
switch (java) {
|
||||
case "Default":
|
||||
vs.setJavaVersionType(JavaVersionType.DEFAULT);
|
||||
break;
|
||||
case "Auto":
|
||||
vs.setJavaVersionType(JavaVersionType.AUTO);
|
||||
break;
|
||||
case "Custom":
|
||||
vs.setJavaVersionType(JavaVersionType.CUSTOM);
|
||||
break;
|
||||
default:
|
||||
vs.setJavaVersion(java);
|
||||
}
|
||||
}
|
||||
|
||||
vs.setRenderer(Optional.ofNullable(obj.get("renderer")).map(JsonElement::getAsString)
|
||||
.map(Renderer::of).orElseGet(() -> {
|
||||
boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false);
|
||||
return useSoftwareRenderer ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT;
|
||||
}));
|
||||
|
||||
vs.setGraphicsBackend(Optional.ofNullable(obj.get("graphicsBackend")).map(JsonElement::getAsString)
|
||||
.flatMap(name -> {
|
||||
try {
|
||||
return Optional.of(GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT)));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}).orElseGet(() -> vs.getRenderer() instanceof Renderer.Driver renderer ? renderer.api() : GraphicsAPI.DEFAULT));
|
||||
|
||||
return vs;
|
||||
}
|
||||
|
||||
private int parseJsonPrimitive(JsonPrimitive primitive) {
|
||||
return parseJsonPrimitive(primitive, 0);
|
||||
}
|
||||
|
||||
private int parseJsonPrimitive(JsonPrimitive primitive, int defaultValue) {
|
||||
if (primitive == null)
|
||||
return defaultValue;
|
||||
else if (primitive.isNumber())
|
||||
return primitive.getAsInt();
|
||||
else
|
||||
return Lang.parseInt(primitive.getAsString(), defaultValue);
|
||||
}
|
||||
|
||||
private <E extends Enum<E>> E parseJsonPrimitive(JsonPrimitive primitive, Class<E> clazz, E defaultValue) {
|
||||
if (primitive == null)
|
||||
return defaultValue;
|
||||
else {
|
||||
E[] enumConstants = clazz.getEnumConstants();
|
||||
if (primitive.isNumber()) {
|
||||
int index = primitive.getAsInt();
|
||||
return index >= 0 && index < enumConstants.length ? enumConstants[index] : defaultValue;
|
||||
} else {
|
||||
String name = primitive.getAsString();
|
||||
for (E enumConstant : enumConstants) {
|
||||
if (enumConstant.name().equalsIgnoreCase(name)) {
|
||||
return enumConstant;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-21
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -15,26 +15,14 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
package org.jackhuang.hmcl.setting.property;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public class GameException extends Exception {
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
|
||||
public GameException() {
|
||||
}
|
||||
|
||||
public GameException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GameException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public GameException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
/// A setting value whose instance override state is stored in `GameSettings.Instance.overrideProperties`.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public interface InheritableProperty<T extends @UnknownNullability Object> extends SettingProperty<T> {
|
||||
}
|
||||
@@ -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.property;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.util.gson.RawPreservingProperty;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public interface SettingProperty<T extends @UnknownNullability Object> extends Property<T>, RawPreservingProperty<T> {
|
||||
@Override
|
||||
GameSettings getBean();
|
||||
|
||||
T defaultValue();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.property;
|
||||
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
|
||||
/// Stores a direct setting value that can be inherited by removing its name from `overrideProperties`.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class SimpleInheritableProperty<T extends @UnknownNullability Object>
|
||||
extends SimpleSettingProperty<T>
|
||||
implements InheritableProperty<T> {
|
||||
|
||||
/// Creates a property with the given owner, serialized name, and direct default value.
|
||||
public SimpleInheritableProperty(GameSettings bean, String name, T defaultValue) {
|
||||
super(bean, name, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.property;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnknownNullability;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public class SimpleSettingProperty<T extends @UnknownNullability Object> extends SimpleObjectProperty<T>
|
||||
implements SettingProperty<T> {
|
||||
private @Nullable JsonElement rawJson;
|
||||
|
||||
private final T defaultValue;
|
||||
|
||||
public SimpleSettingProperty(GameSettings bean,
|
||||
String name,
|
||||
T defaultValue) {
|
||||
super(bean, name, defaultValue);
|
||||
this.defaultValue = defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameSettings getBean() {
|
||||
return (GameSettings) super.getBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public T defaultValue() {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable JsonElement getRawJson() {
|
||||
return rawJson;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRawJson(@Nullable JsonElement rawJson) {
|
||||
this.rawJson = rawJson;
|
||||
}
|
||||
|
||||
/// Clears preserved raw JSON after the property value has been changed.
|
||||
@Override
|
||||
protected void invalidated() {
|
||||
this.rawJson = null;
|
||||
}
|
||||
}
|
||||
@@ -27,15 +27,15 @@ import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
@@ -45,6 +45,8 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class TerracottaBundle {
|
||||
private final Path root;
|
||||
|
||||
@@ -66,14 +68,20 @@ public final class TerracottaBundle {
|
||||
.thenComposeAsync(Schedulers.javafx(), pkg -> {
|
||||
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
|
||||
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void withResult(boolean success) {
|
||||
super.withResult(success);
|
||||
delegate.withResult(success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
delegate.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
context.checkCancellation();
|
||||
@@ -113,7 +121,7 @@ public final class TerracottaBundle {
|
||||
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
|
||||
try (
|
||||
InputStream is = tree.getInputStream(archive);
|
||||
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
|
||||
@@ -122,8 +130,8 @@ public final class TerracottaBundle {
|
||||
}
|
||||
|
||||
String hash = HexFormat.of().formatHex(digest.digest());
|
||||
if (!check.getChecksum().equalsIgnoreCase(hash)) {
|
||||
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
|
||||
if (!check.checksum().equalsIgnoreCase(hash)) {
|
||||
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
|
||||
}
|
||||
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
@@ -156,7 +164,7 @@ public final class TerracottaBundle {
|
||||
return AbstractTerracottaProvider.Status.LEGACY_VERSION;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Logger.LOG.warning("Cannot determine whether legacy versions exist.", e);
|
||||
LOG.warning("Cannot determine whether legacy versions exist.", e);
|
||||
}
|
||||
return AbstractTerracottaProvider.Status.NOT_EXIST;
|
||||
}
|
||||
@@ -172,7 +180,7 @@ public final class TerracottaBundle {
|
||||
return false;
|
||||
}
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
|
||||
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
|
||||
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
|
||||
int n;
|
||||
while ((n = is.read(buffer)) >= 0) {
|
||||
@@ -182,7 +190,7 @@ public final class TerracottaBundle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ public final class TerracottaManager {
|
||||
|
||||
private static String getPlayerName() {
|
||||
Account account = Accounts.getSelectedAccount();
|
||||
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
|
||||
return account != null ? account.getProfileName() : i18n("terracotta.player_anonymous");
|
||||
}
|
||||
|
||||
public static TerracottaState.HostScanning setScanning() {
|
||||
|
||||
@@ -59,7 +59,7 @@ public final class MacOSProvider extends AbstractTerracottaProvider {
|
||||
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
|
||||
}
|
||||
|
||||
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
|
||||
Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_USER_HOME, "terracotta-pkg")
|
||||
.toRealPath()
|
||||
.resolve(FileUtils.getName(installer));
|
||||
Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
+10
-18
@@ -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,24 +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.event;
|
||||
package org.jackhuang.hmcl.theme;
|
||||
|
||||
/**
|
||||
* This event gets fired when all the versions in .minecraft folder are loaded.
|
||||
* <br>
|
||||
* This event is fired on the {@link org.jackhuang.hmcl.event.EventBus#EVENT_BUS}
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public final class RefreshedVersionsEvent extends Event {
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param source {@link org.jackhuang.hmcl.game.GameRepository}
|
||||
*/
|
||||
public RefreshedVersionsEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
/// Controls how the launcher window is shown while the selected background is loading.
|
||||
@NotNullByDefault
|
||||
public enum BackgroundLoadPolicy {
|
||||
/// Loads the selected background before the launcher background property is first exposed to the UI.
|
||||
WAIT_FOR_BACKGROUND,
|
||||
|
||||
/// Shows the fallback background immediately, then replaces it when the selected background loads.
|
||||
SHOW_FALLBACK_WHILE_LOADING
|
||||
}
|
||||
Loaded 100 of 742 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user