Compare commits
170
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5768cd446e | ||
|
|
6f406cede8 | ||
|
|
0597282463 | ||
|
|
f91fbe29b4 | ||
|
|
cccff7effd | ||
|
|
101257cae8 | ||
|
|
a6e74e7c19 | ||
|
|
615ebe0311 | ||
|
|
8b3468e2d9 | ||
|
|
7f1a812373 | ||
|
|
22823151c3 | ||
|
|
c0844834cb | ||
|
|
7921c7abb5 | ||
|
|
c610519f87 | ||
|
|
bb1949f919 | ||
|
|
a1e946bba6 | ||
|
|
c1bf66b972 | ||
|
|
25952aa514 | ||
|
|
54d8d877be | ||
|
|
662a82f21f | ||
|
|
3f0fcc1382 | ||
|
|
258556b22c | ||
|
|
d7e3cc4473 | ||
|
|
93d4489b94 | ||
|
|
77eee17d36 | ||
|
|
4118fa9729 | ||
|
|
83ffc91a88 | ||
|
|
fdf883d09d | ||
|
|
342bcc5438 | ||
|
|
59bcc7fe62 | ||
|
|
77b42b6f99 | ||
|
|
2f5594d8c1 | ||
|
|
13ad50208f | ||
|
|
13910c24b7 | ||
|
|
cc9bdb62ca | ||
|
|
baa7c816b7 | ||
|
|
090f0b9822 | ||
|
|
6d9ae24912 | ||
|
|
d03851b196 | ||
|
|
97cf9dcf16 | ||
|
|
7bacc2bb5a | ||
|
|
c7637079d7 | ||
|
|
8f3282e9a2 | ||
|
|
8e491862b9 | ||
|
|
e0c276075c | ||
|
|
19ea64e14b | ||
|
|
5b26b8f4eb | ||
|
|
ed7ef92ad9 | ||
|
|
cd95891108 | ||
|
|
df52bc6e81 | ||
|
|
9d9fc78384 | ||
|
|
e287f672fd | ||
|
|
24702dc5a0 | ||
|
|
886a25a935 | ||
|
|
450504ceda | ||
|
|
8767cc0e98 | ||
|
|
7331f619cf | ||
|
|
7a8f931641 | ||
|
|
ce51dc4dd7 | ||
|
|
b97e7fbd55 | ||
|
|
7f578be08e | ||
|
|
513f53bc06 | ||
|
|
b2d4d36856 | ||
|
|
9c031c5ac1 | ||
|
|
e2d3ac847d | ||
|
|
0f4152406c | ||
|
|
1a258a2554 | ||
|
|
3742be0aa8 | ||
|
|
0e34554344 | ||
|
|
1acfc55004 | ||
|
|
1428183e36 | ||
|
|
cbc6daf3d5 | ||
|
|
0ad180c082 | ||
|
|
1da8c8d6ac | ||
|
|
ea232b0976 | ||
|
|
b4549b6f68 | ||
|
|
4cf5dbdf66 | ||
|
|
b179c44bec | ||
|
|
7dfc654d5d | ||
|
|
a4b930ded5 | ||
|
|
00cd20a6e9 | ||
|
|
9ebf29d060 | ||
|
|
d7d5e99185 | ||
|
|
5e9ed84605 | ||
|
|
2e01874e95 | ||
|
|
efb475879b | ||
|
|
cb69387151 | ||
|
|
70104cb9e5 | ||
|
|
931fbf5181 | ||
|
|
1d5ecb9236 | ||
|
|
b65104e38b | ||
|
|
8629a2bbe3 | ||
|
|
3142b2f894 | ||
|
|
b1759acb62 | ||
|
|
1363f0e70d | ||
|
|
45c4913b82 | ||
|
|
0d47ff95e1 | ||
|
|
4064c5c188 | ||
|
|
56a2f75c8b | ||
|
|
c4ebdeb77e | ||
|
|
13b87fff89 | ||
|
|
50072799d0 | ||
|
|
16d45890ad | ||
|
|
e0dacd0265 | ||
|
|
72071a41c9 | ||
|
|
c86c19a7f9 | ||
|
|
5475acdc9e | ||
|
|
6daeeb0b04 | ||
|
|
1a4ccd355d | ||
|
|
204a08342d | ||
|
|
ced06cb48c | ||
|
|
80b3883d4f | ||
|
|
60c7ea5a01 | ||
|
|
3323c6a50a | ||
|
|
7530f08ab1 | ||
|
|
15a983de28 | ||
|
|
5c0d4a7c7b | ||
|
|
1532247f2a | ||
|
|
587e92543a | ||
|
|
f0527e12b4 | ||
|
|
b0ac4fb5d0 | ||
|
|
083dbb18ad | ||
|
|
c9585198e1 | ||
|
|
0fd83e5177 | ||
|
|
5a017ecab6 | ||
|
|
da77f52492 | ||
|
|
dccfa7c9a9 | ||
|
|
f7bf2d468d | ||
|
|
a10c501d4f | ||
|
|
116629743e | ||
|
|
cfee31d339 | ||
|
|
3ac08d7f62 | ||
|
|
de643becf8 | ||
|
|
edbcc1f343 | ||
|
|
91fcc4e5ef | ||
|
|
34aeba1b1a | ||
|
|
9a36b86cfb | ||
|
|
71b4c02474 | ||
|
|
45f49d079b | ||
|
|
71d9daed16 | ||
|
|
5ae019ba17 | ||
|
|
770c58c2e7 | ||
|
|
a03c11be42 | ||
|
|
ca3746319f | ||
|
|
56629892f8 | ||
|
|
5238a361cd | ||
|
|
0323a7f321 | ||
|
|
a3c5e8e412 | ||
|
|
5c364f624a | ||
|
|
22275f2c57 | ||
|
|
11e54d199c | ||
|
|
cf21cf4a3c | ||
|
|
a0d1fa8f38 | ||
|
|
78b8d37891 | ||
|
|
0807c04572 | ||
|
|
8eec18450e | ||
|
|
15d4874366 | ||
|
|
c149401cf3 | ||
|
|
ff459a5bc4 | ||
|
|
5796dad18c | ||
|
|
0443ff7cd1 | ||
|
|
4c6fe865f5 | ||
|
|
1e54482f3b | ||
|
|
c329f0d2e8 | ||
|
|
af4d9feea5 | ||
|
|
af71c71663 | ||
|
|
4675442ecd | ||
|
|
c8929c80d9 | ||
|
|
81634b14df | ||
|
|
ccca396d91 |
No files matched your search
@@ -4,6 +4,7 @@
|
||||
|
||||
# ─── Explicitly text – source & build ────────────────────────────────────────
|
||||
*.java text eol=lf
|
||||
*.js text eol=lf
|
||||
*.kt text eol=lf
|
||||
*.kts text eol=lf
|
||||
*.groovy text eol=lf
|
||||
|
||||
@@ -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,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
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
.github/scripts/pr-size-label.js
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Label PR by filtered changed lines
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
*.jfr
|
||||
|
||||
.gradle
|
||||
gradle.properties
|
||||
|
||||
@@ -17,6 +19,7 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
hmcl-mod-update-list-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
|
||||
+13
-2
@@ -4,6 +4,7 @@ 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
|
||||
@@ -144,6 +145,7 @@ val addOpens = listOf(
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.graphics/com.sun.javafx.tk.quantum",
|
||||
"javafx.controls/javafx.scene.control",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
@@ -430,13 +432,22 @@ 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 = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBoxBase;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
@@ -28,6 +29,7 @@ import javafx.scene.paint.Paint;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.transform.Scale;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -178,6 +180,11 @@ public class JFXComboBoxListViewSkin<T> extends ComboBoxListViewSkin<T> {
|
||||
|
||||
comboBox.hide();
|
||||
});
|
||||
Platform.runLater(() -> {
|
||||
if (listView.lookup(".virtual-flow") instanceof VirtualFlow<?> virtualFlow) {
|
||||
FXUtils.smoothScrolling(virtualFlow);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.svg.SVGGlyph;
|
||||
import com.jfoenix.transitions.CachedTransition;
|
||||
import javafx.animation.*;
|
||||
import javafx.animation.Animation.Status;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.SnapshotParameters;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.image.WritableImage;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.SVGContainer;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Year;
|
||||
import java.time.YearMonth;
|
||||
import java.time.chrono.ChronoLocalDate;
|
||||
import java.time.chrono.Chronology;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DecimalStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class JFXDatePickerContent extends VBox {
|
||||
private static final String SPINNER_LABEL = "spinner-label";
|
||||
private static final String ROBOTO = "Roboto";
|
||||
protected JFXDatePicker datePicker;
|
||||
private JFXButton backMonthButton;
|
||||
private JFXButton forwardMonthButton;
|
||||
private Label selectedDateLabel;
|
||||
private Label selectedYearLabel;
|
||||
private Label monthYearLabel;
|
||||
protected GridPane contentGrid;
|
||||
private final StackPane calendarPlaceHolder = new StackPane();
|
||||
private final CachedTransition showTransition;
|
||||
private final CachedTransition hideTransition;
|
||||
private ParallelTransition tempImageTransition;
|
||||
private final int daysPerWeek = 7;
|
||||
private final List<DateCell> weekDaysCells = new ArrayList<>();
|
||||
private final List<DateCell> weekNumberCells = new ArrayList<>();
|
||||
protected List<DateCell> dayCells = new ArrayList<>();
|
||||
private LocalDate[] dayCellDates;
|
||||
private DateCell currentFocusedDayCell = null;
|
||||
private final ListView<String> yearsListView = new JFXListView<>() {
|
||||
{
|
||||
this.getStyleClass().addAll("date-picker-list-view", "no-padding");
|
||||
this.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
this.setCellFactory((listView) -> new ListCell<>() {
|
||||
static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
|
||||
final Label label = new Label();
|
||||
final StackPane root;
|
||||
final RipplerContainer ripplerContainer;
|
||||
|
||||
final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
{
|
||||
label.getStyleClass().add(SPINNER_LABEL);
|
||||
label.setMaxWidth(Double.MAX_VALUE);
|
||||
label.textProperty().bind(itemProperty());
|
||||
StackPane pane = new StackPane(label);
|
||||
pane.setPadding(new Insets(7));
|
||||
ripplerContainer = new RipplerContainer(pane);
|
||||
root = new StackPane(ripplerContainer);
|
||||
root.getStyleClass().add("data-picker-list-cell");
|
||||
this.setGraphic(root);
|
||||
|
||||
var selectedListenerWeak = holder.weak((InvalidationListener) observable -> {
|
||||
String newVal = JFXDatePickerContent.this.selectedYearLabel.getText();
|
||||
if (newVal != null && getItem() != null && getItem().equals(newVal))
|
||||
updateSelected(true);
|
||||
});
|
||||
JFXDatePickerContent.this.selectedYearLabel.textProperty().addListener(selectedListenerWeak);
|
||||
itemProperty().addListener(selectedListenerWeak);
|
||||
|
||||
FXUtils.onChangeAndOperate(selectedProperty(), selected -> {
|
||||
if (getListView() != null) {
|
||||
if (selected) {
|
||||
getListView().getSelectionModel().select(getIndex());
|
||||
} else {
|
||||
getListView().getSelectionModel().clearSelection(getIndex());
|
||||
}
|
||||
}
|
||||
root.pseudoClassStateChanged(SELECTED, selected);
|
||||
if (selected) {
|
||||
int offset = Integer.parseInt(getItem()) - Integer.parseInt(JFXDatePickerContent.this.selectedYearLabel.getText());
|
||||
JFXDatePickerContent.this.forward(offset, ChronoUnit.YEARS, false, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updateItem(String item, boolean empty) {
|
||||
String oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
setText(null);
|
||||
|
||||
if (Objects.equals(oldItem, item) && oldEmpty == empty) return;
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
if (!empty && item != null) {
|
||||
setGraphic(root);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
final DateTimeFormatter fullFormatter = DateTimeFormatter.ofPattern("EEE, MMM yyyy");
|
||||
final DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMMM");
|
||||
final DateTimeFormatter yearFormatter = DateTimeFormatter.ofPattern("y");
|
||||
final DateTimeFormatter weekNumberFormatter = DateTimeFormatter.ofPattern("w");
|
||||
final DateTimeFormatter weekDayNameFormatter = DateTimeFormatter.ofPattern("ccc");
|
||||
final DateTimeFormatter dayCellFormatter = DateTimeFormatter.ofPattern("d");
|
||||
private final ObjectProperty<YearMonth> selectedYearMonth = new SimpleObjectProperty<>(this, "selectedYearMonth");
|
||||
|
||||
final ObjectProperty<LocalDate> valueProperty = new SimpleObjectProperty<>(this, "value");
|
||||
|
||||
JFXDatePickerContent(final DatePicker datePicker) {
|
||||
this.datePicker = (JFXDatePicker) datePicker;
|
||||
this.getStyleClass().add("date-picker-popup");
|
||||
LocalDate date = datePicker.getValue();
|
||||
this.valueProperty.set(date);
|
||||
this.valueProperty.addListener((o, old, n) -> {
|
||||
this.displayedYearMonthProperty().set(n != null ? YearMonth.from(n) : YearMonth.now());
|
||||
this.updateValues();
|
||||
});
|
||||
this.selectedYearMonth.set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.selectedYearMonth.addListener((observable, oldValue, newValue) -> this.updateValues());
|
||||
this.getChildren().add(this.createHeaderPane());
|
||||
this.getChildren().add(new Separator());
|
||||
this.contentGrid = new GridPane() {
|
||||
protected double computePrefWidth(double height) {
|
||||
int nCols = JFXDatePickerContent.this.daysPerWeek + (datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
double leftSpace = this.snapSpaceX(this.getInsets().getLeft());
|
||||
double rightSpace = this.snapSpaceX(this.getInsets().getRight());
|
||||
double hgaps = this.snapSpaceX(this.getHgap()) * (double) (nCols - 1);
|
||||
double contentWidth = super.computePrefWidth(height) - leftSpace - rightSpace - hgaps;
|
||||
return this.snapSizeX(contentWidth / (double) nCols) * (double) nCols + leftSpace + rightSpace + hgaps;
|
||||
}
|
||||
|
||||
protected void layoutChildren() {
|
||||
if (this.getWidth() > (double) 0.0F && this.getHeight() > (double) 0.0F) {
|
||||
super.layoutChildren();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
this.contentGrid.setFocusTraversable(true);
|
||||
this.contentGrid.getStyleClass().add("calendar-grid");
|
||||
this.contentGrid.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.contentGrid.setPadding(new Insets(0.0F, 12.0F, 12.0F, 12.0F));
|
||||
this.contentGrid.setVgap(4);
|
||||
this.contentGrid.setHgap(4);
|
||||
this.createWeekDaysCells();
|
||||
this.createDayCells();
|
||||
|
||||
this.yearsListView.getItems().addAll(IntStream.rangeClosed(1900, Year.now().getValue() + 100).mapToObj(Integer::toString).toList());
|
||||
|
||||
StackPane main = new StackPane();
|
||||
this.yearsListView.setVisible(false);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.maxWidthProperty().bind(main.widthProperty());
|
||||
this.yearsListView.maxHeightProperty().bind(main.heightProperty());
|
||||
main.getChildren().setAll(this.contentGrid, this.yearsListView);
|
||||
|
||||
VBox contentHolder = new VBox();
|
||||
contentHolder.getChildren().setAll(this.createCalendarMonthLabelPane(), main);
|
||||
this.calendarPlaceHolder.getStyleClass().add("content");
|
||||
this.calendarPlaceHolder.getChildren().setAll(contentHolder, this.createCalendarArrowsPane());
|
||||
FXUtils.setOverflowHidden(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.calendarPlaceHolder);
|
||||
|
||||
this.getChildren().add(this.createActions());
|
||||
|
||||
this.refresh();
|
||||
this.addEventHandler(KeyEvent.ANY, (event) -> {
|
||||
Node node = this.getScene().getFocusOwner();
|
||||
if (node instanceof DateCell) {
|
||||
this.currentFocusedDayCell = (DateCell) node;
|
||||
}
|
||||
|
||||
switch (event.getCode()) {
|
||||
case HOME -> {
|
||||
this.init();
|
||||
this.goToDate(LocalDate.now(), true);
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_UP -> {
|
||||
if (!this.backMonthButton.isDisabled()) {
|
||||
this.forward(-1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case PAGE_DOWN -> {
|
||||
if (!this.forwardMonthButton.isDisabled()) {
|
||||
this.forward(1, ChronoUnit.MONTHS, true, true);
|
||||
}
|
||||
|
||||
event.consume();
|
||||
}
|
||||
case ESCAPE -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
event.consume();
|
||||
}
|
||||
default -> event.consume();
|
||||
}
|
||||
|
||||
});
|
||||
this.showTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void starting() {
|
||||
super.starting();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(true);
|
||||
}
|
||||
};
|
||||
this.hideTransition = new CachedTransition(this.yearsListView, new Timeline(new KeyFrame(Duration.millis(0.0F), new KeyValue(this.yearsListView.opacityProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(500.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(this.yearsListView.opacityProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.contentGrid.opacityProperty(), 1, Interpolator.EASE_BOTH)))) {
|
||||
{
|
||||
this.setCycleDuration(Duration.millis(320.0F));
|
||||
this.setDelay(Duration.seconds(0.0F));
|
||||
}
|
||||
|
||||
protected void stopping() {
|
||||
super.stopping();
|
||||
JFXDatePickerContent.this.yearsListView.setVisible(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ObjectProperty<YearMonth> displayedYearMonthProperty() {
|
||||
return this.selectedYearMonth;
|
||||
}
|
||||
|
||||
private void createWeekDaysCells() {
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("day-name-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
cell.setAlignment(Pos.BASELINE_CENTER);
|
||||
this.weekDaysCells.add(cell);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
DateCell cell = new DateCell();
|
||||
cell.getStyleClass().add("week-number-cell");
|
||||
cell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.weekNumberCells.add(cell);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected VBox createHeaderPane() {
|
||||
Label title = new Label(I18n.i18n("button.select_date"));
|
||||
title.getStyleClass().add("title");
|
||||
title.setFont(Font.font(ROBOTO, FontWeight.NORMAL, 12.0F));
|
||||
HBox titleContainer = new HBox(title);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
titleContainer.setPadding(new Insets(0, 0, 5, 0));
|
||||
this.selectedYearLabel = new Label();
|
||||
this.selectedYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 14.0F));
|
||||
HBox yearLabelContainer = new HBox();
|
||||
yearLabelContainer.getStyleClass().add("spinner");
|
||||
yearLabelContainer.getChildren().addAll(this.selectedYearLabel);
|
||||
yearLabelContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
yearLabelContainer.setFillHeight(false);
|
||||
yearLabelContainer.setOnMouseClicked((click) -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
this.selectedDateLabel = new Label();
|
||||
this.selectedDateLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.selectedDateLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 32.0F));
|
||||
HBox selectedDateContainer = new HBox();
|
||||
selectedDateContainer.getStyleClass().add("spinner");
|
||||
selectedDateContainer.getChildren().addAll(this.selectedDateLabel);
|
||||
selectedDateContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
selectedDateContainer.setOnMouseClicked((click) -> {
|
||||
if (this.yearsListView.isVisible()) {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
|
||||
});
|
||||
VBox headerPanel = new VBox();
|
||||
headerPanel.getStyleClass().add("month-year-pane");
|
||||
headerPanel.setPadding(new Insets(12.0F, 24.0F, 12.0F, 24.0F));
|
||||
headerPanel.getChildren().addAll(titleContainer, yearLabelContainer, selectedDateContainer);
|
||||
return headerPanel;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarArrowsPane() {
|
||||
SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.GRAY);
|
||||
SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.GRAY);
|
||||
leftChevron.setSize(6.0F, 11.0F);
|
||||
rightChevron.setSize(6.0F, 11.0F);
|
||||
this.backMonthButton = new JFXButton();
|
||||
this.backMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.backMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.backMonthButton.getStyleClass().add("left-button");
|
||||
this.backMonthButton.setGraphic(leftChevron);
|
||||
this.backMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.backMonthButton.setOnAction((t) -> this.forward(-1, ChronoUnit.MONTHS, false, true));
|
||||
this.forwardMonthButton = new JFXButton();
|
||||
this.forwardMonthButton.setMinSize(40.0F, 40.0F);
|
||||
this.forwardMonthButton.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(40.0F), Insets.EMPTY)));
|
||||
this.forwardMonthButton.getStyleClass().add("right-button");
|
||||
this.forwardMonthButton.setGraphic(rightChevron);
|
||||
this.forwardMonthButton.setRipplerFill(this.datePicker.getDefaultColor());
|
||||
this.forwardMonthButton.setOnAction((t) -> this.forward(1, ChronoUnit.MONTHS, false, true));
|
||||
BorderPane arrowsContainer = new BorderPane();
|
||||
arrowsContainer.setLeft(this.backMonthButton);
|
||||
arrowsContainer.setRight(this.forwardMonthButton);
|
||||
arrowsContainer.setPadding(new Insets(4.0F, 12.0F, 2.0F, 12.0F));
|
||||
arrowsContainer.setPickOnBounds(false);
|
||||
return arrowsContainer;
|
||||
}
|
||||
|
||||
protected BorderPane createCalendarMonthLabelPane() {
|
||||
this.monthYearLabel = new Label();
|
||||
this.monthYearLabel.getStyleClass().add(SPINNER_LABEL);
|
||||
this.monthYearLabel.setFont(Font.font(ROBOTO, FontWeight.BOLD, 13.0F));
|
||||
SVGContainer svgContainer = new SVGContainer(13);
|
||||
HBox box = new HBox(monthYearLabel, svgContainer);
|
||||
box.setSpacing(3);
|
||||
box.setAlignment(Pos.CENTER);
|
||||
FXUtils.onChangeAndOperate(this.yearsListView.visibleProperty(), b -> svgContainer.setIcon(b ? SVG.ARROW_DROP_UP : SVG.ARROW_DROP_DOWN));
|
||||
FXUtils.onClicked(box, () -> {
|
||||
if (!this.yearsListView.isVisible()) {
|
||||
int yearIndex = Integer.parseInt(this.selectedYearLabel.getText()) - 1900 - 2;
|
||||
this.yearsListView.scrollTo(yearIndex >= 0 ? yearIndex : yearIndex + 2);
|
||||
this.hideTransition.stop();
|
||||
this.showTransition.play();
|
||||
} else {
|
||||
this.showTransition.stop();
|
||||
this.hideTransition.play();
|
||||
}
|
||||
});
|
||||
BorderPane monthContainer = new BorderPane();
|
||||
monthContainer.setMinHeight(50.0F);
|
||||
monthContainer.setCenter(box);
|
||||
monthContainer.setPadding(new Insets(2.0F, 12.0F, 2.0F, 12.0F));
|
||||
return monthContainer;
|
||||
}
|
||||
|
||||
protected HBox createActions() {
|
||||
JFXButton cancelButton = new JFXButton(I18n.i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
FXUtils.onClicked(cancelButton, () -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
JFXButton acceptButton = new JFXButton(I18n.i18n("button.ok"));
|
||||
acceptButton.getStyleClass().add("dialog-accept");
|
||||
FXUtils.onClicked(acceptButton, () -> {
|
||||
datePicker.setValue(valueProperty.get());
|
||||
fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
|
||||
HBox hBox = new HBox(cancelButton, acceptButton);
|
||||
hBox.setPadding(new Insets(0, 5, 3, 0));
|
||||
hBox.setSpacing(5);
|
||||
hBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
return hBox;
|
||||
}
|
||||
|
||||
void updateContentGrid() {
|
||||
this.contentGrid.getColumnConstraints().clear();
|
||||
this.contentGrid.getChildren().clear();
|
||||
int colsNumber = this.daysPerWeek + (this.datePicker.isShowWeekNumbers() ? 1 : 0);
|
||||
ColumnConstraints columnConstraints = new ColumnConstraints();
|
||||
columnConstraints.setPercentWidth(100.0F);
|
||||
|
||||
for (int i = 0; i < colsNumber; ++i) {
|
||||
this.contentGrid.getColumnConstraints().add(columnConstraints);
|
||||
}
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
this.contentGrid.add(this.weekDaysCells.get(i), i + colsNumber - this.daysPerWeek, 1);
|
||||
}
|
||||
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
this.contentGrid.add(this.weekNumberCells.get(i), 0, i + 2);
|
||||
}
|
||||
}
|
||||
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
this.contentGrid.add(this.dayCells.get(row * this.daysPerWeek + col), col + colsNumber - this.daysPerWeek, row + 2);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
this.updateDayNameCells();
|
||||
this.updateValues();
|
||||
}
|
||||
|
||||
private void updateDayNameCells() {
|
||||
int weekFirstDay = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
LocalDate date = LocalDate.of(2009, 7, 12 + weekFirstDay);
|
||||
|
||||
for (int i = 0; i < this.daysPerWeek; ++i) {
|
||||
String name = this.weekDayNameFormatter.withLocale(this.getLocale()).format(date.plusDays(i));
|
||||
if (!LocaleUtils.isChinese(this.weekNumberFormatter.getLocale())) {
|
||||
name = name.substring(0, 1).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
this.weekDaysCells.get(i).setText(name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void updateValues() {
|
||||
this.updateWeekNumberDateCells();
|
||||
this.updateDayCells();
|
||||
this.updateMonthYearPane();
|
||||
}
|
||||
|
||||
void updateWeekNumberDateCells() {
|
||||
if (this.datePicker.isShowWeekNumbers()) {
|
||||
Locale locale = this.getLocale();
|
||||
LocalDate firstDayOfMonth = this.selectedYearMonth.get().atDay(1);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
LocalDate date = firstDayOfMonth.plusWeeks(i);
|
||||
String weekNumber = this.weekNumberFormatter.withLocale(locale).withDecimalStyle(DecimalStyle.of(locale)).format(date);
|
||||
this.weekNumberCells.get(i).setText(weekNumber);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void updateDayCells() {
|
||||
Locale locale = this.getLocale();
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
int firstDayOfWeek = WeekFields.of(this.getLocale()).getFirstDayOfWeek().getValue();
|
||||
int firstOfMonthIndex = this.selectedYearMonth.get().atDay(1).getDayOfWeek().getValue() - firstDayOfWeek;
|
||||
firstOfMonthIndex += firstOfMonthIndex < 0 ? this.daysPerWeek : 0;
|
||||
YearMonth currentYearMonth = this.selectedYearMonth.get();
|
||||
int daysInCurMonth = -1;
|
||||
|
||||
for (int i = 0; i < 6 * this.daysPerWeek; ++i) {
|
||||
DateCell dayCell = this.dayCells.get(i);
|
||||
try {
|
||||
if (daysInCurMonth == -1) {
|
||||
daysInCurMonth = currentYearMonth.lengthOfMonth();
|
||||
}
|
||||
|
||||
int dayIndex = i - firstOfMonthIndex + 1;
|
||||
LocalDate date;
|
||||
if (dayIndex <= 0) {
|
||||
YearMonth previous = currentYearMonth.minusMonths(1);
|
||||
date = previous.atDay(previous.lengthOfMonth() + dayIndex);
|
||||
} else if (dayIndex > daysInCurMonth) {
|
||||
YearMonth next = currentYearMonth.plusMonths(1);
|
||||
date = next.atDay(dayIndex - daysInCurMonth);
|
||||
} else {
|
||||
date = currentYearMonth.atDay(dayIndex);
|
||||
}
|
||||
this.dayCellDates[i] = date;
|
||||
if (date.equals(LocalDate.now())) {
|
||||
dayCell.getStyleClass().add("today");
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("today");
|
||||
}
|
||||
|
||||
if (date.equals(valueProperty.get())) {
|
||||
dayCell.getStyleClass().add("selected");
|
||||
this.currentFocusedDayCell = dayCell;
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("selected");
|
||||
}
|
||||
|
||||
ChronoLocalDate cDate = chrono.date(date);
|
||||
String cellText = this.dayCellFormatter.withLocale(locale).withChronology(chrono).withDecimalStyle(DecimalStyle.of(locale)).format(cDate);
|
||||
dayCell.setText(cellText);
|
||||
if (i < firstOfMonthIndex) {
|
||||
dayCell.getStyleClass().add("previous-month");
|
||||
dayCell.getStyleClass().removeAll("next-month");
|
||||
dayCell.setDisable(true);
|
||||
} else if (i >= firstOfMonthIndex + daysInCurMonth) {
|
||||
dayCell.getStyleClass().add("next-month");
|
||||
dayCell.getStyleClass().removeAll("previous-month");
|
||||
dayCell.setDisable(true);
|
||||
} else {
|
||||
dayCell.getStyleClass().removeAll("previous-month", "next-month");
|
||||
dayCell.setDisable(false);
|
||||
}
|
||||
|
||||
dayCell.updateItem(date, false);
|
||||
} catch (DateTimeException var13) {
|
||||
dayCell.setText("");
|
||||
dayCell.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void updateMonthYearPane() {
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
this.selectedDateLabel.setText(fullFormatter.withLocale(this.getLocale()).format(Objects.requireNonNullElseGet(valueProperty.get(), LocalDate::now)));
|
||||
|
||||
this.selectedYearLabel.setText(this.formatYear(yearMonth));
|
||||
this.monthYearLabel.setText(this.formatMonth(yearMonth) + " " + this.formatYear(yearMonth));
|
||||
Chronology chrono = this.datePicker.getChronology();
|
||||
LocalDate firstDayOfMonth = yearMonth.atDay(1);
|
||||
this.backMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, -1, ChronoUnit.DAYS));
|
||||
this.forwardMonthButton.setDisable(!this.isValidDate(chrono, firstDayOfMonth, 1, ChronoUnit.MONTHS));
|
||||
}
|
||||
|
||||
private String formatMonth(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.monthFormatter.withLocale(this.getLocale()).withChronology(chrono).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String formatYear(YearMonth yearMonth) {
|
||||
try {
|
||||
Chronology chrono = this.getPrimaryChronology();
|
||||
ChronoLocalDate cDate = chrono.date(yearMonth.atDay(1));
|
||||
return this.yearFormatter.withLocale(this.getLocale()).withChronology(chrono).withDecimalStyle(DecimalStyle.of(this.getLocale())).format(cDate);
|
||||
} catch (DateTimeException var4) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected LocalDate dayCellDate(DateCell dateCell) {
|
||||
assert this.dayCellDates != null;
|
||||
|
||||
return this.dayCellDates[this.dayCells.indexOf(dateCell)];
|
||||
}
|
||||
|
||||
protected void forward(int offset, ChronoUnit unit, boolean focusDayCell, boolean withAnimation) {
|
||||
if (withAnimation && (this.tempImageTransition == null || this.tempImageTransition.getStatus() == Status.STOPPED)) {
|
||||
Pane monthContent = (Pane) this.calendarPlaceHolder.getChildren().get(0);
|
||||
this.getParent().setManaged(false);
|
||||
SnapshotParameters snapShotparams = new SnapshotParameters();
|
||||
snapShotparams.setFill(Color.TRANSPARENT);
|
||||
WritableImage temp = monthContent.snapshot(snapShotparams, new WritableImage((int) monthContent.getWidth(), (int) monthContent.getHeight()));
|
||||
ImageView tempImage = new ImageView(temp);
|
||||
this.calendarPlaceHolder.getChildren().add(this.calendarPlaceHolder.getChildren().size() - 2, tempImage);
|
||||
TranslateTransition imageTransition = new TranslateTransition(Duration.millis(160.0F), tempImage);
|
||||
imageTransition.setToX((double) (-offset) * this.calendarPlaceHolder.getWidth());
|
||||
imageTransition.setOnFinished((finish) -> this.calendarPlaceHolder.getChildren().remove(tempImage));
|
||||
monthContent.setTranslateX((double) offset * this.calendarPlaceHolder.getWidth());
|
||||
TranslateTransition contentTransition = new TranslateTransition(Duration.millis(160.0F), monthContent);
|
||||
contentTransition.setToX(0.0F);
|
||||
this.tempImageTransition = new ParallelTransition(imageTransition, contentTransition);
|
||||
this.tempImageTransition.setOnFinished((finish) -> {
|
||||
this.calendarPlaceHolder.getChildren().remove(tempImage);
|
||||
this.getParent().setManaged(true);
|
||||
});
|
||||
this.tempImageTransition.play();
|
||||
}
|
||||
|
||||
YearMonth yearMonth = this.selectedYearMonth.get();
|
||||
DateCell dateCell = this.currentFocusedDayCell;
|
||||
if (dateCell == null || this.dayCellDate(dateCell).getMonth() != yearMonth.getMonth()) {
|
||||
dateCell = this.findDayCellOfDate(yearMonth.atDay(1));
|
||||
}
|
||||
|
||||
this.goToDayCell(dateCell, offset, unit, focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDayCell(DateCell dateCell, int offset, ChronoUnit unit, boolean focusDayCell) {
|
||||
this.goToDate(this.dayCellDate(dateCell).plus(offset, unit), focusDayCell);
|
||||
}
|
||||
|
||||
private void goToDate(LocalDate date, boolean focusDayCell) {
|
||||
if (this.isValidDate(this.datePicker.getChronology(), date)) {
|
||||
this.valueProperty.set(date);
|
||||
this.selectedYearMonth.set(YearMonth.from(date));
|
||||
if (focusDayCell) {
|
||||
this.findDayCellOfDate(date).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void selectDayCell(DateCell dateCell) {
|
||||
this.valueProperty.set(this.dayCellDate(dateCell));
|
||||
}
|
||||
|
||||
private DateCell findDayCellOfDate(LocalDate date) {
|
||||
for (int i = 0; i < this.dayCellDates.length; ++i) {
|
||||
if (date.equals(this.dayCellDates[i])) {
|
||||
return this.dayCells.get(i);
|
||||
}
|
||||
}
|
||||
|
||||
return this.dayCells.get(this.dayCells.size() / 2 + 1);
|
||||
}
|
||||
|
||||
void init() {
|
||||
this.contentGrid.setOpacity(1.0F);
|
||||
this.yearsListView.setOpacity(0.0F);
|
||||
this.yearsListView.setVisible(false);
|
||||
}
|
||||
|
||||
void clearFocus() {
|
||||
LocalDate focusDate = this.valueProperty.get();
|
||||
if (focusDate == null) {
|
||||
focusDate = LocalDate.now();
|
||||
}
|
||||
|
||||
if (YearMonth.from(focusDate).equals(this.selectedYearMonth.get())) {
|
||||
this.goToDate(focusDate, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected void createDayCells() {
|
||||
for (int row = 0; row < 6; ++row) {
|
||||
for (int col = 0; col < this.daysPerWeek; ++col) {
|
||||
DateCell dayCell = this.createDayCell();
|
||||
dayCell.addEventHandler(MouseEvent.MOUSE_CLICKED, (click) -> {
|
||||
if (click.getButton() == MouseButton.PRIMARY) {
|
||||
DateCell selectedDayCell = (DateCell) click.getSource();
|
||||
this.selectDayCell(selectedDayCell);
|
||||
}
|
||||
});
|
||||
dayCell.setOnMouseEntered((event) -> dayCell.getStyleClass().add("hovered"));
|
||||
dayCell.setOnMouseExited((event) -> dayCell.getStyleClass().removeAll("hovered"));
|
||||
dayCell.setAlignment(Pos.BASELINE_CENTER);
|
||||
dayCell.setBorder(new Border(new BorderStroke(Color.TRANSPARENT, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(5.0F))));
|
||||
dayCell.setFont(Font.font(ROBOTO, FontWeight.BOLD, 12.0F));
|
||||
this.dayCells.add(dayCell);
|
||||
}
|
||||
}
|
||||
|
||||
this.dayCellDates = new LocalDate[6 * this.daysPerWeek];
|
||||
this.updateContentGrid();
|
||||
}
|
||||
|
||||
private DateCell createDayCell() {
|
||||
DateCell dayCell = null;
|
||||
if (this.datePicker.getDayCellFactory() != null) {
|
||||
dayCell = this.datePicker.getDayCellFactory().call(this.datePicker);
|
||||
}
|
||||
|
||||
if (dayCell == null) {
|
||||
dayCell = new DateCell();
|
||||
}
|
||||
dayCell.getStyleClass().add("day-cell");
|
||||
dayCell.setPrefSize(36.0F, 36.0F);
|
||||
dayCell.setDisable(false);
|
||||
dayCell.setStyle(null);
|
||||
dayCell.setGraphic(null);
|
||||
dayCell.setTooltip(null);
|
||||
|
||||
return dayCell;
|
||||
}
|
||||
|
||||
protected Chronology getPrimaryChronology() {
|
||||
return this.datePicker.getChronology();
|
||||
}
|
||||
|
||||
protected Locale getLocale() {
|
||||
return SettingsManager.settings().languageProperty().get().getLocale();
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date, int offset, ChronoUnit unit) {
|
||||
return date != null && this.isValidDate(chrono, date.plus(offset, unit));
|
||||
}
|
||||
|
||||
protected boolean isValidDate(Chronology chrono, LocalDate date) {
|
||||
try {
|
||||
if (date != null) {
|
||||
chrono.date(date);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (DateTimeException var4) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by Fernflower decompiler)
|
||||
//
|
||||
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.ReflectionHelper;
|
||||
import com.jfoenix.adapters.skins.DatePickerSkin;
|
||||
import com.jfoenix.controls.JFXDatePicker;
|
||||
import com.jfoenix.controls.JFXDialog;
|
||||
import com.jfoenix.controls.JFXDialog.DialogTransition;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Stack;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class JFXDatePickerSkin extends DatePickerSkin {
|
||||
public static final String PROPERTY_DATE_PICKER_CONTENTS = JFXDatePicker.class.getName() + ".dialog.contents";
|
||||
|
||||
private final JFXDatePicker jfxDatePicker;
|
||||
private TextField displayNode;
|
||||
private JFXDatePickerContent content;
|
||||
private JFXDialog dialog;
|
||||
|
||||
public JFXDatePickerSkin(JFXDatePicker datePicker) {
|
||||
super(datePicker);
|
||||
this.jfxDatePicker = datePicker;
|
||||
|
||||
try {
|
||||
Object expressionHelper = ReflectionHelper.getFieldContent(datePicker.focusedProperty().getClass().getSuperclass(), datePicker.focusedProperty(), "helper");
|
||||
ChangeListener<Boolean>[] changeListeners = ReflectionHelper.getFieldContent(expressionHelper, "changeListeners");
|
||||
|
||||
int i = changeListeners.length - 1;
|
||||
if (i >= 0) {
|
||||
while (i >= 0 && changeListeners[i] == null) {
|
||||
--i;
|
||||
}
|
||||
if (i >= 0) datePicker.focusedProperty().removeListener(changeListeners[i]);
|
||||
}
|
||||
} catch (NullPointerException e) {
|
||||
LOG.warning("Cannot remove focusedProperty listener", e);
|
||||
}
|
||||
|
||||
datePicker.focusedProperty().addListener((obj, oldVal, newVal) -> {
|
||||
if (this.getEditor() != null && !newVal) {
|
||||
this.setTextFromTextFieldIntoComboBoxValue2();
|
||||
}
|
||||
|
||||
});
|
||||
var arrowBox = new HBox(new RipplerContainer(SVG.EDIT_CALENDAR.createIcon(30)));
|
||||
arrowBox.setAlignment(Pos.CENTER);
|
||||
this.setArrow(arrowBox);
|
||||
this.getArrowButton().getChildren().setAll(arrowBox);
|
||||
this.getArrowButton().setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, CornerRadii.EMPTY, Insets.EMPTY)));
|
||||
this.getArrowButton().setPadding(new Insets(1.0F, 8.0F, 1.0F, 8.0F));
|
||||
this.registerChangeListener2(datePicker.converterProperty(), "CONVERTER", this::updateDisplayNode2);
|
||||
this.registerChangeListener2(datePicker.dayCellFactoryProperty(), "DAY_CELL_FACTORY", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.content = null;
|
||||
this.setPopup2(null);
|
||||
});
|
||||
this.registerChangeListener2(datePicker.editorProperty(), "EDITOR", this::getEditableInputNode2);
|
||||
this.registerChangeListener2(datePicker.showingProperty(), "SHOWING", () -> {
|
||||
if (this.jfxDatePicker.isShowing()) {
|
||||
if (this.content != null) {
|
||||
LocalDate date = this.jfxDatePicker.getValue();
|
||||
this.content.displayedYearMonthProperty().set(date != null ? YearMonth.from(date) : YearMonth.now());
|
||||
this.content.updateValues();
|
||||
}
|
||||
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.showWeekNumbersProperty(), "SHOW_WEEK_NUMBERS", () -> {
|
||||
if (this.content != null) {
|
||||
this.content.updateContentGrid();
|
||||
this.content.updateWeekNumberDateCells();
|
||||
}
|
||||
|
||||
});
|
||||
this.registerChangeListener2(datePicker.valueProperty(), "VALUE", () -> {
|
||||
this.updateDisplayNode2();
|
||||
this.jfxDatePicker.fireEvent(new ActionEvent());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public JFXDatePickerContent getPopupContent() {
|
||||
if (this.content == null) {
|
||||
this.content = new JFXDatePickerContent(this.jfxDatePicker);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
if (!((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
super.show();
|
||||
}
|
||||
|
||||
if (this.content != null) {
|
||||
this.content.init();
|
||||
this.content.clearFocus();
|
||||
}
|
||||
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay() && this.dialog == null) {
|
||||
StackPane dialogParent = this.jfxDatePicker.getDialogParent();
|
||||
if (dialogParent == null) {
|
||||
dialogParent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
|
||||
this.dialog = new JFXDialog(dialogParent, this.getPopupContent(), DialogTransition.CENTER, true) {
|
||||
@Override
|
||||
public void close() {
|
||||
super.close();
|
||||
Object o = getDialogContainer().getProperties().get(PROPERTY_DATE_PICKER_CONTENTS);
|
||||
if (o instanceof Stack<?> stack) {
|
||||
stack.pop();
|
||||
}
|
||||
jfxDatePicker.hide();
|
||||
}
|
||||
};
|
||||
this.getPopupContent().addEventHandler(DialogCloseEvent.CLOSE, e -> this.dialog.close());
|
||||
getArrowButton().setOnMouseClicked((click) -> {
|
||||
if (((JFXDatePicker) this.getSkinnable()).isOverLay()) {
|
||||
StackPane parent = this.jfxDatePicker.getDialogParent();
|
||||
if (parent == null) {
|
||||
parent = (StackPane) this.getSkinnable().getScene().getRoot();
|
||||
}
|
||||
Stack<JFXDatePickerContent> contentStack = (Stack<JFXDatePickerContent>) parent.getProperties().computeIfAbsent(PROPERTY_DATE_PICKER_CONTENTS, k -> new Stack<>());
|
||||
contentStack.push(this.getPopupContent());
|
||||
|
||||
this.getPopupContent().valueProperty.set(this.getSkinnable().getValue());
|
||||
this.dialog.show(parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
JFXDialog getDialog() {
|
||||
return this.dialog;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getDisplayNode() {
|
||||
if (this.displayNode == null) {
|
||||
this.displayNode = this.getEditableInputNode2();
|
||||
this.displayNode.getStyleClass().add("date-picker-display-node");
|
||||
this.updateDisplayNode2();
|
||||
}
|
||||
|
||||
this.displayNode.setEditable(this.jfxDatePicker.isEditable());
|
||||
return this.displayNode;
|
||||
}
|
||||
|
||||
public void syncWithAutoUpdate() {
|
||||
if (!this.getPopup2().isShowing() && this.jfxDatePicker.isShowing()) {
|
||||
this.jfxDatePicker.hide();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,8 @@ 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;
|
||||
@@ -49,16 +47,20 @@ import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.platform.macos.LibSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Gdi32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Shell32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.User32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinReg;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryPoolMXBean;
|
||||
import java.net.CookieHandler;
|
||||
@@ -68,9 +70,11 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
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;
|
||||
@@ -78,6 +82,7 @@ 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();
|
||||
|
||||
@@ -137,12 +142,8 @@ public final class Launcher extends Application {
|
||||
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) {
|
||||
@@ -308,8 +309,8 @@ public final class Launcher extends Application {
|
||||
LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY);
|
||||
LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME);
|
||||
LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME);
|
||||
LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found"));
|
||||
LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory"));
|
||||
LOG.info("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()) {
|
||||
@@ -327,6 +328,9 @@ public final class Launcher extends Application {
|
||||
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
|
||||
}
|
||||
|
||||
@Nullable Boolean isRoot = isRootUser();
|
||||
LOG.info("Administrator User: " + (isRoot == null ? "Unknown" : isRoot.toString()));
|
||||
|
||||
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
@@ -383,6 +387,9 @@ public final class Launcher extends Application {
|
||||
return;
|
||||
if (!NativeUtils.USE_JNA || Shell32.INSTANCE == null)
|
||||
return;
|
||||
@Nullable Path thisJar = JarUtils.thisJarPath();
|
||||
if (thisJar == null || !Files.isRegularFile(thisJar) || !"exe".equalsIgnoreCase(FileUtils.getExtension(thisJar)))
|
||||
return;
|
||||
|
||||
try {
|
||||
int hr = Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(Metadata.WINDOWS_APP_USER_MODEL_ID));
|
||||
@@ -396,6 +403,8 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -451,14 +460,25 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
if (uiScale != null) {
|
||||
uiScale = uiScale.trim();
|
||||
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 {
|
||||
float scaleValue;
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
@@ -466,33 +486,60 @@ public final class Launcher extends Application {
|
||||
} 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);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,5 +596,68 @@ public final class Launcher extends Application {
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
private static @Nullable Boolean isRootUser() {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
WinReg reg = WinReg.INSTANCE;
|
||||
|
||||
if (reg == null)
|
||||
return null;
|
||||
try {
|
||||
return reg.exists(WinReg.HKEY.HKEY_USERS, "S-1-5-19");
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to check Windows administrator privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case LINUX -> {
|
||||
Path selfStatus = Paths.get("/proc/self/status");
|
||||
if (Files.exists(selfStatus)) {
|
||||
try (var reader = Files.newBufferedReader(selfStatus)) {
|
||||
@Nullable String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("Uid:")) {
|
||||
String[] parts = line.split("\t");
|
||||
if (parts.length >= 3) {
|
||||
String uid = parts[2].trim();
|
||||
return "0".equals(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read " + selfStatus, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
case MACOS -> {
|
||||
if (NativeUtils.USE_JNA) {
|
||||
LibSystem libSystem = LibSystem.INSTANCE;
|
||||
if (libSystem != null) {
|
||||
try {
|
||||
return libSystem.geteuid() == 0;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to check macOS root privileges", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS.isUnixLike()) {
|
||||
@Nullable Path id = SystemUtils.which("id");
|
||||
if (id != null) {
|
||||
try {
|
||||
return "0".equals(SystemUtils.run(id.toString(), "-u").trim());
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to run id -u", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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.util.EnumSet;
|
||||
|
||||
@@ -133,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;
|
||||
}
|
||||
|
||||
}
|
||||
+32
-20
@@ -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,47 +15,55 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
package org.jackhuang.hmcl.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<LocalAddonFile.AddonUpdate>> {
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final List<Task<LocalAddonFile.AddonUpdate>> dependents;
|
||||
public class AddonCheckUpdatesTask extends Task<AddonUpdates> {
|
||||
private final List<Task<Pair<AddonUpdate, @Nullable AddonUpdate>>> dependents;
|
||||
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<T> addons) {
|
||||
this.downloadProvider = downloadProvider;
|
||||
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<? extends LocalAddonFile> addons) {
|
||||
dependents = addons.stream().map(addon ->
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
LocalAddonFile.AddonUpdate candidate = null;
|
||||
AddonUpdate candidate = null;
|
||||
AddonUpdate candidateRelease = null;
|
||||
for (RemoteAddon.Source source : RemoteAddon.Source.values()) {
|
||||
LocalAddonFile.AddonUpdate update = null;
|
||||
AddonUpdate update = null;
|
||||
AddonUpdate updateRelease = null;
|
||||
try {
|
||||
update = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||
var u = addon.checkUpdates(downloadProvider, gameVersion, source);
|
||||
if (u != null) {
|
||||
update = u.key();
|
||||
updateRelease = u.value();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
|
||||
}
|
||||
if (update == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished())) {
|
||||
if (update == null) continue;
|
||||
if (candidate == null || candidate.targetVersion().datePublished().isBefore(update.targetVersion().datePublished()))
|
||||
candidate = update;
|
||||
}
|
||||
|
||||
if (updateRelease == null) continue;
|
||||
if (candidateRelease == null || candidateRelease.targetVersion().datePublished().isBefore(updateRelease.targetVersion().datePublished()))
|
||||
candidateRelease = updateRelease;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
if (candidate == null) return null; // If there's no candidate for all channels, then no candidate for release channel
|
||||
return Pair.pair(candidate, candidateRelease);
|
||||
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
|
||||
).toList();
|
||||
|
||||
@@ -85,8 +93,12 @@ public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<L
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
setResult(dependents.stream()
|
||||
.map(Task::getResult)
|
||||
.filter(Objects::nonNull).toList());
|
||||
List<AddonUpdate> commonUpdates = new ArrayList<>(), releaseUpdates = new ArrayList<>();
|
||||
dependents.stream().map(Task::getResult).filter(Objects::nonNull)
|
||||
.forEachOrdered(pair -> {
|
||||
commonUpdates.add(pair.key());
|
||||
if (pair.value() != null) releaseUpdates.add(pair.value());
|
||||
});
|
||||
setResult(new AddonUpdates(commonUpdates, releaseUpdates));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.addon.update;
|
||||
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents = new ArrayList<>();
|
||||
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
|
||||
|
||||
public AddonUpdateTask(DownloadProvider downloadProvider, Path addonDirectory, List<AddonUpdate> addons) {
|
||||
setStage("addon.check_update.confirm");
|
||||
getProperties().put("total", addons.size());
|
||||
|
||||
for (AddonUpdate addon : addons) {
|
||||
LocalAddonFile local = addon.localAddonFile();
|
||||
RemoteAddon.Version remote = addon.targetVersion();
|
||||
boolean isDisabled = local.isDisabled();
|
||||
String fileName = remote.file().filename();
|
||||
if (isDisabled)
|
||||
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
|
||||
String newFileName = fileName;
|
||||
Path targetFile = addonDirectory.resolve(newFileName);
|
||||
|
||||
dependents.add(Task
|
||||
.supplyAsync(Schedulers.io(), () -> Files.createTempFile("hmcl-addon-update-", ".tmp"))
|
||||
.thenComposeAsync(tempFile ->
|
||||
new FileDownloadTask(downloadProvider.getDownloadCandidates(remote.file().url()), tempFile)
|
||||
.setName(remote.name())
|
||||
.thenRunAsync(Schedulers.javafx(), () -> local.setOld(true))
|
||||
.thenRunAsync(Schedulers.io(), () -> Files.move(tempFile, targetFile))
|
||||
.whenComplete(Schedulers.javafx(), exception -> {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to delete temp file " + tempFile, e);
|
||||
}
|
||||
if (exception != null) {
|
||||
// restore state if failed
|
||||
local.setOld(false);
|
||||
if (isDisabled)
|
||||
local.markDisabled();
|
||||
} else {
|
||||
local.onUpdated(newFileName);
|
||||
if (!local.keepOldFiles()) {
|
||||
try {
|
||||
local.delete();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to update addon", exception);
|
||||
failedAddons.add(local);
|
||||
}
|
||||
}).withCounter("addon.check_update.confirm"));
|
||||
}
|
||||
}
|
||||
|
||||
public List<LocalAddonFile> getFailedAddons() {
|
||||
return failedAddons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return dependents;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPreExecute() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preExecute() {
|
||||
notifyPropertiesChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (!isDependentsSucceeded())
|
||||
throw getException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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.download;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.AssetObject;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.HMCLCacheRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadSource;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class HMCLDownloadProvider extends DownloadProvider {
|
||||
private volatile DownloadSource versionListSource = DownloadSource.DEFAULT;
|
||||
private volatile DownloadSource fileSource = DownloadSource.DEFAULT;
|
||||
|
||||
private static DownloadCandidates getCandidates(
|
||||
DownloadSource source,
|
||||
WebURL defaultUrl, WebURL bmclapiUrl
|
||||
) {
|
||||
if (LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
return switch (source) {
|
||||
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl, bmclapiUrl);
|
||||
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
|
||||
};
|
||||
} else {
|
||||
return switch (source) {
|
||||
case DEFAULT, OFFICIAL -> DownloadCandidates.of(defaultUrl);
|
||||
case MIRROR -> DownloadCandidates.of(bmclapiUrl, defaultUrl);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static DownloadCandidates getCandidates(DownloadSource source, List<MirrorRule> rules, List<String> urls) {
|
||||
if (urls.isEmpty()) {
|
||||
throw new IllegalArgumentException("urls cannot be empty");
|
||||
}
|
||||
|
||||
// true means prefer mirror, false means prefer official, null means do not use mirror
|
||||
@Nullable Boolean mirrorFirst;
|
||||
if (LocaleUtils.IS_CHINA_MAINLAND) {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, MIRROR -> true;
|
||||
case OFFICIAL -> false;
|
||||
};
|
||||
} else {
|
||||
mirrorFirst = switch (source) {
|
||||
case DEFAULT, OFFICIAL -> null;
|
||||
case MIRROR -> true;
|
||||
};
|
||||
}
|
||||
|
||||
if (mirrorFirst == null) {
|
||||
return DownloadCandidates.of(urls.stream().map(DownloadCandidate::of).toArray(DownloadCandidate[]::new));
|
||||
}
|
||||
|
||||
var candidates = new ArrayList<DownloadCandidate>(urls.size() * 2);
|
||||
for (String url : urls) {
|
||||
DownloadCandidate candidate = DownloadCandidate.of(url);
|
||||
if (candidate.url() == null) {
|
||||
// Invalid URL, just add it to the list and let the download task handle it.
|
||||
candidates.add(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
@Nullable DownloadCandidate mirrorCandidate = null;
|
||||
boolean fallback = false;
|
||||
for (MirrorRule rule : rules) {
|
||||
if (url.startsWith(rule.source)) {
|
||||
mirrorCandidate = DownloadCandidate.of(rule.target + url.substring(rule.source.length()));
|
||||
fallback = rule.fallback;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mirrorCandidate == null) {
|
||||
candidates.add(candidate);
|
||||
} else if (mirrorFirst && !fallback) {
|
||||
candidates.add(mirrorCandidate);
|
||||
candidates.add(candidate);
|
||||
} else {
|
||||
candidates.add(candidate);
|
||||
candidates.add(mirrorCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadCandidates.of(List.copyOf(candidates));
|
||||
}
|
||||
|
||||
private final String bmclRoot;
|
||||
private final List<MirrorRule> rules;
|
||||
|
||||
public HMCLDownloadProvider(String bmclRoot) {
|
||||
this.bmclRoot = bmclRoot;
|
||||
this.rules = List.of(
|
||||
new MirrorRule("https://bmclapi2.bangbang93.com", bmclRoot),
|
||||
new MirrorRule("https://launchermeta.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://piston-meta.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://piston-data.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://launcher.mojang.com", bmclRoot),
|
||||
new MirrorRule("https://libraries.minecraft.net", bmclRoot + "/libraries"),
|
||||
new MirrorRule("http://files.minecraftforge.net/maven", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://files.minecraftforge.net/maven", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://maven.minecraftforge.net", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://maven.neoforged.net/releases/", bmclRoot + "/maven/"),
|
||||
new MirrorRule("http://dl.liteloader.com/versions", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://dl.liteloader.com/versions", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://meta.fabricmc.net", bmclRoot + "/fabric-meta"),
|
||||
new MirrorRule("https://maven.fabricmc.net", bmclRoot + "/maven"),
|
||||
new MirrorRule("https://authlib-injector.yushi.moe", bmclRoot + "/mirrors/authlib-injector"),
|
||||
new MirrorRule("https://repo1.maven.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new MirrorRule("https://repo.maven.apache.org/maven2", "https://mirrors.cloud.tencent.com/nexus/repository/maven-public"),
|
||||
new MirrorRule("https://hmcl.glavo.site/metadata/cleanroom", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom"),
|
||||
new MirrorRule("https://hmcl.glavo.site/metadata/fmllibs", "https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/fmllibs"),
|
||||
new MirrorRule("https://zkitefly.github.io/unlisted-versions-of-minecraft", "https://alist.8mi.tech/d/mirror/unlisted-versions-of-minecraft/Auto"),
|
||||
|
||||
// https://github.com/mcmod-info-mirror/mcim-rust-api
|
||||
new MirrorRule("https://api.modrinth.com", "https://mod.mcimirror.top/modrinth", true),
|
||||
new MirrorRule("https://cdn.modrinth.com", "https://mod.mcimirror.top", true),
|
||||
new MirrorRule("https://api.curseforge.com", "https://mod.mcimirror.top/curseforge", true),
|
||||
new MirrorRule("https://edge.forgecdn.net", "https://mod.mcimirror.top", true)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<? extends ComponentRemoteVersionList<?>> fetchVersionsAsync(GameComponentType type, @Nullable GameVersionNumber gameVersion) {
|
||||
assert (gameVersion == null) == (type == GameComponentType.GAME);
|
||||
switch (type) {
|
||||
case FORGE -> {
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchOfficial = ForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
|
||||
Task<ComponentRemoteVersionList<ForgeRemoteVersion>> fetchBMCL = ForgeRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
if (source == DownloadSource.MIRROR) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case NEO_FORGE -> {
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchOfficial = NeoForgeRemoteVersion.fetchAsync(this, gameVersion);
|
||||
|
||||
DownloadSource source = versionListSource;
|
||||
if (!LocaleUtils.IS_CHINA_MAINLAND && (source == DownloadSource.DEFAULT || source == DownloadSource.OFFICIAL)) {
|
||||
return fetchOfficial;
|
||||
}
|
||||
Task<ComponentRemoteVersionList<NeoForgeRemoteVersion>> fetchBMCL = NeoForgeRemoteVersion.fetchBMCLAsync(bmclRoot, gameVersion);
|
||||
if (source == DownloadSource.MIRROR || source == DownloadSource.DEFAULT) {
|
||||
return new FallbackTask<>(fetchBMCL, fetchOfficial);
|
||||
} else {
|
||||
return new FallbackTask<>(fetchOfficial, fetchBMCL);
|
||||
}
|
||||
}
|
||||
case OPTIFINE -> {
|
||||
return OptiFineRemoteVersion.fetchBMCLAsync(this, bmclRoot, gameVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return super.fetchVersionsAsync(type, gameVersion);
|
||||
}
|
||||
|
||||
public void setFileSource(DownloadSource fileSource) {
|
||||
this.fileSource = Objects.requireNonNull(fileSource);
|
||||
}
|
||||
|
||||
public void setVersionListSource(DownloadSource versionListSource) {
|
||||
this.versionListSource = Objects.requireNonNull(versionListSource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HMCLCacheRepository getCacheRepository() {
|
||||
return HMCLCacheRepository.REPOSITORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getGameVersionListCandidates() {
|
||||
return getCandidates(
|
||||
versionListSource,
|
||||
GameRemoteVersion.VERSION_MANIFEST_URL,
|
||||
WebURL.parse(bmclRoot + "/mc/game/version_manifest.json")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FabricLikeVersionListCandidates getFabricVersionListCandidates() {
|
||||
DownloadSource source = versionListSource;
|
||||
return new FabricLikeVersionListCandidates(
|
||||
getCandidates(source, FabricRemoteVersion.LOADER_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/loader")),
|
||||
getCandidates(source, FabricRemoteVersion.GAME_META_URL, WebURL.parse(bmclRoot + "/fabric-meta/v2/versions/game"))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getCleanroomVersionListCandidates() {
|
||||
return LocaleUtils.IS_CHINA_MAINLAND
|
||||
? DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL, WebURL.parse("https://alist.8mi.tech/d/mirror/HMCL-Metadata/Auto/cleanroom/index.json"))
|
||||
: DownloadCandidates.of(CleanroomRemoteVersion.LOADER_LIST_URL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getDownloadCandidates(List<String> urls) {
|
||||
return getCandidates(fileSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getVersionListCandidates(List<String> urls) {
|
||||
return getCandidates(versionListSource, rules, urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadCandidates getAssetObjectCandidates(AssetObject assetObject) {
|
||||
return getCandidates(
|
||||
fileSource,
|
||||
WebURL.parse("https://resources.download.minecraft.net/" + assetObject.getLocation()),
|
||||
WebURL.parse(bmclRoot + "/assets/" + assetObject.getLocation())
|
||||
);
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class FallbackTask<T> extends Task<T> {
|
||||
|
||||
private final Task<? extends T> task;
|
||||
private final Task<? extends T> fallback;
|
||||
|
||||
private List<Task<?>> dependencies = List.of();
|
||||
|
||||
public FallbackTask(Task<? extends T> task, Task<? extends T> fallback) {
|
||||
this.task = task;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends Task<?>> getDependents() {
|
||||
return List.of(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelyingOnDependents() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
if (isDependentsSucceeded()) {
|
||||
setResult(task.getResult());
|
||||
} else {
|
||||
dependencies = List.of(fallback);
|
||||
fallback.storeTo(this::setResult);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Task<?>> getDependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
private record MirrorRule(String source, String target, boolean fallback) {
|
||||
public MirrorRule(String source, String target) {
|
||||
this(source, target, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,27 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) {
|
||||
this(repository, manifest, 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, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) {
|
||||
this(repository, manifest, authInfo, options, listener, true);
|
||||
}
|
||||
|
||||
public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) {
|
||||
super(repository, manifest, 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
|
||||
@@ -66,7 +77,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
if (options.isDisableAutoGameOptions())
|
||||
return;
|
||||
|
||||
Path runDir = repository.getRunDirectory(manifest.id());
|
||||
Path runDir = instance.getRunDirectory();
|
||||
Path optionsFile = runDir.resolve("options.txt");
|
||||
Path configFolder = runDir.resolve("config");
|
||||
|
||||
@@ -91,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(manifest));
|
||||
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);
|
||||
@@ -180,7 +191,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion));
|
||||
String fileName = library.artifact().getFileName();
|
||||
|
||||
Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize();
|
||||
Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize();
|
||||
if (agentPath.toString().contains("=")) {
|
||||
throw new IOException("Invalid library path: " + agentPath);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,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,7 +19,7 @@ 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.download.GameBuilder;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
@@ -27,53 +27,110 @@ 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 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(HMCLGameRepository repository, Path zipFile, Modpack modpack, GameInstanceID instanceId) {
|
||||
/// 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.instanceId = instanceId;
|
||||
this.updateTarget = updateTarget;
|
||||
this.modpack = modpack;
|
||||
if (this.updateTarget != null) {
|
||||
dependency.validateGameInstance(this.updateTarget);
|
||||
}
|
||||
|
||||
Path run = repository.getRunDirectory(this.instanceId);
|
||||
Path json = repository.getModpackConfiguration(this.instanceId);
|
||||
if (repository.hasInstance(this.instanceId) && Files.notExists(json))
|
||||
throw new IllegalArgumentException("Instance " + instanceId + " 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.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync());
|
||||
|
||||
onDone().register(event -> {
|
||||
if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId);
|
||||
});
|
||||
|
||||
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()))
|
||||
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(this.instanceId)).withStage("hmcl.modpack"));
|
||||
|
||||
onDone().register(event -> {
|
||||
if (this.updateTarget == null && event.isFailed()) {
|
||||
repository.removeInstanceFromDisk(this.instanceId);
|
||||
}
|
||||
});
|
||||
|
||||
dependents.add(new ModpackInstallTask<>(zipFile, run, List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,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");
|
||||
GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalManifest, null);
|
||||
Task<GameInstanceManifest> libraryTask = Task.supplyAsync(() -> originalManifest);
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,10 +28,11 @@ 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();
|
||||
@@ -42,12 +43,17 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) {
|
||||
public @Nullable Task<?> createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, 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());
|
||||
|
||||
@@ -55,13 +61,13 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository");
|
||||
}
|
||||
|
||||
return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId));
|
||||
return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException {
|
||||
public Modpack readManifest(ZipArchiveReader file, Path path) throws IOException, JsonParseException {
|
||||
String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json");
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding);
|
||||
Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class);
|
||||
String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json");
|
||||
GameInstanceManifest game = JsonUtils.fromNonNullJson(gameJson, GameInstanceManifest.class);
|
||||
if (game.jar() == null)
|
||||
@@ -76,7 +82,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) {
|
||||
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,8 +25,6 @@ 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;
|
||||
@@ -34,15 +32,14 @@ import org.jackhuang.hmcl.launch.*;
|
||||
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;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PromptDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
@@ -59,7 +56,6 @@ import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
@@ -84,9 +80,8 @@ public final class LauncherHelper {
|
||||
|
||||
private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";
|
||||
|
||||
private final HMCLGameRepository repository;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private Account account;
|
||||
private final GameInstanceID selectedInstanceId;
|
||||
private Path scriptFile;
|
||||
private final GameSettings.Effective setting;
|
||||
private LauncherVisibility launcherVisibility;
|
||||
@@ -94,16 +89,23 @@ public final class LauncherHelper {
|
||||
private QuickPlayOption quickPlayOption;
|
||||
private boolean disableOfflineSkin = false;
|
||||
|
||||
public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
public LauncherHelper(HMCLGameInstance gameInstance, Account account) {
|
||||
this.gameInstance = Objects.requireNonNull(gameInstance);
|
||||
this.account = Objects.requireNonNull(account);
|
||||
this.selectedInstanceId = selectedInstanceId;
|
||||
this.setting = repository.getEffectiveGameSettings(selectedInstanceId);
|
||||
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);
|
||||
|
||||
public Account getAccount() {
|
||||
@@ -134,7 +136,7 @@ public final class LauncherHelper {
|
||||
public void launch() {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
LOG.info("Launching game version: " + selectedInstanceId);
|
||||
LOG.info("Launching game instance: " + gameInstance.getId());
|
||||
|
||||
Controllers.dialog(launchingStepsPane);
|
||||
launch0();
|
||||
@@ -145,48 +147,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 = repository();
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
AtomicReference<GameInstanceManifest> version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest()));
|
||||
Optional<String> gameVersion = repository.getGameVersion(version.get());
|
||||
boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId);
|
||||
// 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(repository, 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));
|
||||
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(selectedInstanceId));
|
||||
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, selectedInstanceId);
|
||||
else return provider.createCompletionTask(
|
||||
dependencyManager,
|
||||
gameInstance);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Task.composeAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
|
||||
|| !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) 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;
|
||||
@@ -194,7 +203,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 {
|
||||
@@ -204,18 +213,13 @@ public final class LauncherHelper {
|
||||
})
|
||||
);
|
||||
}).withStage("launch.state.dependencies")
|
||||
.thenComposeAsync(() -> {
|
||||
if (gameVersion.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get());
|
||||
})
|
||||
.thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get()))
|
||||
.thenComposeAsync(() -> {
|
||||
if (setting.getInheritable(GameSettings::allowAutoAgentProperty)
|
||||
|| setting.getInheritable(GameSettings::noJVMOptionsProperty)
|
||||
|| setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)
|
||||
|| Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP))
|
||||
|| !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
|| !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) {
|
||||
return Task.completed(null);
|
||||
} else {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
@@ -234,8 +238,8 @@ public final class LauncherHelper {
|
||||
})
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions(
|
||||
selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions(
|
||||
javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null);
|
||||
if (disableOfflineSkin) {
|
||||
launchOptionsBuilder.setDaemon(false);
|
||||
}
|
||||
@@ -275,16 +279,16 @@ public final class LauncherHelper {
|
||||
|
||||
LaunchOptions launchOptions = launchOptionsBuilder.create();
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 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) {
|
||||
@@ -348,30 +352,28 @@ public final class LauncherHelper {
|
||||
message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage();
|
||||
} else if (ex instanceof LibraryDownloadException) {
|
||||
message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n";
|
||||
if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex.getCause();
|
||||
if (ex.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", uri);
|
||||
message += i18n("download.code.404", url);
|
||||
else
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
} else if (ex instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
message = i18n("install.failed.downloading.timeout", url);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), url);
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else {
|
||||
message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
message = i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(ex.getCause());
|
||||
}
|
||||
} else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||
message = i18n("assets.index.malformed");
|
||||
@@ -379,14 +381,13 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.injector_download_failure");
|
||||
} else if (ex instanceof CharacterDeletedException) {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message = i18n("download.code.404", uri);
|
||||
message = i18n("download.code.404", url);
|
||||
else
|
||||
message = i18n("download.failed", uri, responseCode);
|
||||
message = i18n("download.failed", url, responseCode);
|
||||
} else if (ex instanceof CommandTooLongException) {
|
||||
message = i18n("launch.failed.command_too_long");
|
||||
} else if (ex instanceof ExecutionPolicyLimitException) {
|
||||
@@ -423,9 +424,9 @@ public final class LauncherHelper {
|
||||
executor.start();
|
||||
}
|
||||
|
||||
private static Task<JavaRuntime> checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, GameInstanceManifest manifest) {
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).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 {
|
||||
@@ -456,9 +457,9 @@ public final class LauncherHelper {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,9 +481,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +492,7 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) {
|
||||
downloadJava(targetJavaVersion, repository)
|
||||
downloadJava(targetJavaVersion, gameInstance.getRepository())
|
||||
.whenCompleteAsync((downloadedJava, exception) -> {
|
||||
if (exception == null) {
|
||||
future.complete(downloadedJava);
|
||||
@@ -554,10 +555,9 @@ 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))
|
||||
@@ -568,7 +568,7 @@ public final class LauncherHelper {
|
||||
gameJavaVersion = null;
|
||||
|
||||
if (gameJavaVersion != null) {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> {
|
||||
if (throwable == null) {
|
||||
setting.setJavaAutoSelected();
|
||||
future.complete(downloadedJava);
|
||||
@@ -638,7 +638,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
|
||||
@@ -651,8 +651,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, ""));
|
||||
@@ -681,7 +681,7 @@ public final class LauncherHelper {
|
||||
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);
|
||||
|
||||
@@ -742,7 +742,7 @@ public final class LauncherHelper {
|
||||
Throwable resolvedException = resolveException(exception);
|
||||
LOG.warning("Failed to download java", exception);
|
||||
if (!(resolvedException instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
Controllers.dialog(I18n.localizeErrorMessage(resolvedException), i18n("install.failed"));
|
||||
}
|
||||
future.completeExceptionally(new CancellationException());
|
||||
}
|
||||
@@ -829,16 +829,12 @@ 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]
|
||||
*/
|
||||
/// 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 GameInstanceManifest manifest;
|
||||
private final LaunchOptions launchOptions;
|
||||
private ManagedProcess process;
|
||||
private volatile boolean lwjgl;
|
||||
@@ -850,9 +846,7 @@ public final class LauncherHelper {
|
||||
private Thread submitLogThread;
|
||||
private LinkedBlockingQueue<Log> logBuffer;
|
||||
|
||||
public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.repository = repository;
|
||||
this.manifest = manifest;
|
||||
public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) {
|
||||
this.launchOptions = launchOptions;
|
||||
this.launchingLatch = launchingLatch;
|
||||
this.detectWindow = detectWindow;
|
||||
@@ -979,7 +973,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();
|
||||
@@ -1037,8 +1031,8 @@ public final class LauncherHelper {
|
||||
}
|
||||
|
||||
if (exitType != ExitType.NORMAL) {
|
||||
repository.markInstanceLaunchedAbnormally(manifest.id());
|
||||
runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show());
|
||||
gameInstance.markLaunchedAbnormally();
|
||||
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
|
||||
}
|
||||
|
||||
checkExit();
|
||||
|
||||
@@ -23,6 +23,7 @@ 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;
|
||||
@@ -38,18 +39,26 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
protected abstract RemoteAddonRepository getBackedRemoteModRepository();
|
||||
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
@Override
|
||||
public String getApiBaseUrl() {
|
||||
return getBackedRemoteModRepository().getApiBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
public String getBaseUrl() {
|
||||
return getBackedRemoteModRepository().getBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, RemoteAddon.Type type, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
if (!StringUtils.containsChinese(searchFilter)) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
|
||||
|
||||
int count = 0;
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
|
||||
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByAddonType(type).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
@@ -60,7 +69,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
}
|
||||
|
||||
if (englishSearchFiltersSet.isEmpty()) {
|
||||
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
return getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
|
||||
}
|
||||
|
||||
RemoteAddon[] searchResultArray = new RemoteAddon[pageSize];
|
||||
@@ -69,8 +78,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
SearchResult searchResult = null;
|
||||
List<RemoteAddon> remoteAddons = List.of();
|
||||
for (String englishSearchFilter : englishSearchFiltersSet) {
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
|
||||
remoteAddons = searchResult.getUnsortedResults().toList();
|
||||
searchResult = getBackedRemoteModRepository().search(downloadProvider, type, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder);
|
||||
remoteAddons = searchResult.results().toList();
|
||||
if (!remoteAddons.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
@@ -82,19 +91,19 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
continue;
|
||||
}
|
||||
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
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.slug());
|
||||
ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
@@ -113,8 +122,8 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<Category> getCategories() throws IOException {
|
||||
return getBackedRemoteModRepository().getCategories();
|
||||
public Stream<Category> getCategories(RemoteAddon.Type type) throws IOException {
|
||||
return getBackedRemoteModRepository().getCategories(type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -127,6 +136,11 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
return getBackedRemoteModRepository().getAddonById(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException {
|
||||
return getBackedRemoteModRepository().resolveDependency(downloadProvider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException {
|
||||
return getBackedRemoteModRepository().getAddonFile(projectId, fileId);
|
||||
@@ -136,4 +150,14 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,18 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import kala.encdet.EncodingDetector;
|
||||
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;
|
||||
@@ -41,22 +45,24 @@ public final class LogExporter {
|
||||
}
|
||||
|
||||
public static CompletableFuture<Void> exportLogs(
|
||||
Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript,
|
||||
Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript,
|
||||
PathMatcher logMatcher) {
|
||||
Path runDirectory = repository.getRunDirectory(instanceId);
|
||||
Path baseDirectory = repository.getBaseDirectory();
|
||||
DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot();
|
||||
Path runDirectory = options.getGameDir();
|
||||
List<GameInstanceID> instances = new ArrayList<>();
|
||||
|
||||
GameInstanceID currentInstanceId = instanceId;
|
||||
GameInstanceID currentInstanceId = instance.id;
|
||||
HashSet<GameInstanceID> resolvedSoFar = new HashSet<>();
|
||||
while (true) {
|
||||
if (resolvedSoFar.contains(currentInstanceId)) break;
|
||||
resolvedSoFar.add(currentInstanceId);
|
||||
GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId);
|
||||
@Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId);
|
||||
if (currentInstance == null)
|
||||
break;
|
||||
instances.add(currentInstanceId);
|
||||
|
||||
if (currentVersion.inheritsFrom() != null) {
|
||||
currentInstanceId = currentVersion.inheritsFrom();
|
||||
if (currentInstance.getManifest().inheritsFrom() != null) {
|
||||
currentInstanceId = currentInstance.getManifest().inheritsFrom();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -74,9 +80,11 @@ public final class LogExporter {
|
||||
zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh");
|
||||
|
||||
for (GameInstanceID id : instances) {
|
||||
Path instanceJson = repository.getInstanceJson(id);
|
||||
if (Files.exists(instanceJson)) {
|
||||
zipper.putFile(instanceJson, id + ".json");
|
||||
@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) {
|
||||
@@ -90,7 +98,7 @@ public final class LogExporter {
|
||||
for (Path file : stream) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
if (logMatcher == null || logMatcher.matches(file)) {
|
||||
try (BufferedReader reader = EncodingDetector.MODERN_WEB.newBufferedReader(file)) {
|
||||
try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) {
|
||||
zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read log file: " + file, e);
|
||||
|
||||
@@ -18,11 +18,8 @@
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
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;
|
||||
|
||||
@@ -31,12 +28,10 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
private final Path zipFile;
|
||||
private final Charset charset;
|
||||
private final String name;
|
||||
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, Charset charset, String name) {
|
||||
public ManuallyCreatedModpackInstallTask(Path zipFile, String name) {
|
||||
this.zipFile = zipFile;
|
||||
this.charset = charset;
|
||||
this.name = name;
|
||||
|
||||
setName(i18n("modpack.installing"));
|
||||
@@ -44,19 +39,15 @@ public class ManuallyCreatedModpackInstallTask extends Task<Path> {
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,7 @@ import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackProvider;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackRemoteInstallTask;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameWindowType;
|
||||
import org.jackhuang.hmcl.setting.JavaVersionType;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
@@ -46,13 +42,12 @@ 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;
|
||||
@@ -60,10 +55,9 @@ 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.
|
||||
@@ -95,8 +89,8 @@ public final class ModpackHelper {
|
||||
return "zip".equals(ext) || "mrpack".equals(ext);
|
||||
}
|
||||
|
||||
public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) {
|
||||
public static Modpack readModpackManifest(Path file) throws UnsupportedModpackException, ManuallyCreatedModpackException {
|
||||
try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file)) {
|
||||
// Order for trying detecting manifest is necessary here.
|
||||
// Do not change to iterating providers.
|
||||
for (ModpackProvider provider : new ModpackProvider[]{
|
||||
@@ -107,15 +101,15 @@ public final class ModpackHelper {
|
||||
MultiMCModpackProvider.INSTANCE,
|
||||
ServerModpackProvider.INSTANCE}) {
|
||||
try {
|
||||
return provider.readManifest(zipFile, file, charset);
|
||||
return provider.readManifest(zipFile, file);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
||||
try {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||
throw new ManuallyCreatedModpackException(file);
|
||||
} catch (IOException e) {
|
||||
// ignore it
|
||||
@@ -124,28 +118,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 {
|
||||
@@ -157,15 +158,9 @@ public final class ModpackHelper {
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) {
|
||||
repository.markInstanceAsModpack(instanceId);
|
||||
|
||||
ExceptionalRunnable<?> success = () -> {
|
||||
repository.refresh();
|
||||
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
|
||||
repository.undoMark(instanceId);
|
||||
if (setting != null) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -177,6 +172,7 @@ public final class ModpackHelper {
|
||||
|
||||
return new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
@@ -184,12 +180,13 @@ public final class ModpackHelper {
|
||||
return Files.exists(Paths.get("externalgames").resolve(name));
|
||||
}
|
||||
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name, Charset charset) {
|
||||
/// Extracts a manually created modpack and selects its new external game directory.
|
||||
public static Task<?> getInstallManuallyCreatedModpackTask(Path zipFile, String name) {
|
||||
if (isExternalGameNameConflicts(name)) {
|
||||
throw new IllegalArgumentException("name existing");
|
||||
}
|
||||
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, charset, name)
|
||||
return new ManuallyCreatedModpackInstallTask(zipFile, name)
|
||||
.thenAcceptAsync(Schedulers.javafx(), location -> {
|
||||
GameDirectory newGameDirectory = new GameDirectory(
|
||||
GameDirectoryManager.newGameDirectoryId(),
|
||||
@@ -197,19 +194,35 @@ public final class ModpackHelper {
|
||||
PortablePath.fromPath(location));
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
});
|
||||
})
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints("hmcl.modpack");
|
||||
}
|
||||
|
||||
public static Task<?> getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) {
|
||||
repository.markInstanceAsModpack(instanceId);
|
||||
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 = () -> {
|
||||
repository.refresh();
|
||||
GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
|
||||
repository.undoMark(instanceId);
|
||||
if (setting != null) {
|
||||
setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
}
|
||||
repository.getInstance(instanceId).enableIsolation();
|
||||
};
|
||||
|
||||
ExceptionalConsumer<Exception, ?> failure = ex -> {
|
||||
@@ -220,45 +233,77 @@ public final class ModpackHelper {
|
||||
};
|
||||
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else if (modpack.getManifest() instanceof McbbsModpackManifest)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId))
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl)
|
||||
return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles)
|
||||
.whenComplete(Schedulers.defaultScheduler(), success, failure)
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
/// Updates a server modpack with backup, repository refresh, and modpack stage hints.
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId))
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE: {
|
||||
HMCLGameInstance instance = repository.getInstance(instanceId);
|
||||
return new ModpackUpdateTask(
|
||||
instance,
|
||||
new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance))
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset);
|
||||
ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
public static Task<?> getUpdateTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
return getUpdateTask(repository, zipFile, instanceId, configuration, null);
|
||||
}
|
||||
|
||||
/// Creates an update task that respects optional-file selection.
|
||||
///
|
||||
/// @param repository the target repository
|
||||
/// @param zipFile the modpack archive
|
||||
/// @param instanceId the instance to update
|
||||
/// @param configuration the existing modpack configuration
|
||||
/// @param excludedFiles keys of optional files the user chose not to install; `null` means install all.
|
||||
/// When non-null, must not contain `null` elements.
|
||||
/// @return the update task
|
||||
public static Task<?> getUpdateTask(
|
||||
HMCLGameRepository repository,
|
||||
Path zipFile,
|
||||
GameInstanceID instanceId,
|
||||
ModpackConfiguration<?> configuration,
|
||||
@Nullable Set<String> excludedFiles)
|
||||
throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(zipFile);
|
||||
@Nullable ModpackProvider provider = getProviderByType(configuration.getType());
|
||||
if (provider == null) {
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
|
||||
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
else
|
||||
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles)
|
||||
.thenComposeAsync(repository.refreshAsync())
|
||||
.withStage("hmcl.modpack")
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
|
||||
public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) {
|
||||
|
||||
@@ -53,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;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
||||
import org.jackhuang.hmcl.auth.offline.Skin;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -99,7 +100,7 @@ public final class TexturesLoader {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
// download it
|
||||
try {
|
||||
new FileDownloadTask(texture.url(), file).run();
|
||||
new FileDownloadTask(DownloadProviders.getDownloadProvider().getDownloadCandidates(texture.url()), file).run();
|
||||
LOG.info("Texture downloaded: " + texture.url());
|
||||
} catch (Exception e) {
|
||||
if (Files.isRegularFile(file)) {
|
||||
|
||||
@@ -26,18 +26,13 @@ import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
@@ -321,7 +316,7 @@ public final class JavaManager {
|
||||
|
||||
@Nullable
|
||||
public static JavaRuntime findSuitableJava(Collection<JavaRuntime> javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) {
|
||||
LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null;
|
||||
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)
|
||||
@@ -438,14 +433,14 @@ public final class JavaManager {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false);
|
||||
}
|
||||
searcher.searchAllOfficialJava(CacheRepository.getInstance().getCacheDirectory().resolve("java"), true);
|
||||
searcher.searchAllOfficialJava(HMCLCacheRepository.REPOSITORY.getCacheDirectory().resolve("java"), true);
|
||||
|
||||
// Search in PATH.
|
||||
if (System.getenv("PATH") != null) {
|
||||
@@ -499,7 +494,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;
|
||||
@@ -842,7 +837,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);
|
||||
|
||||
@@ -47,13 +47,8 @@ import java.util.*;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static javafx.collections.FXCollections.observableArrayList;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAccountMetadataRecords;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getUserAccountMetadataRecords;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
|
||||
import static org.jackhuang.hmcl.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.i18n.I18n.i18n;
|
||||
@@ -73,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<>();
|
||||
|
||||
@@ -18,44 +18,47 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
private static final DownloadProviderWrapper PROVIDER_WRAPPER;
|
||||
|
||||
private static final DownloadProvider MOJANG_PROVIDER;
|
||||
private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER;
|
||||
private static final DownloadProvider DEFAULT_PROVIDER;
|
||||
private static final String DEFAULT_BMCLAPI_ROOT = "https://bmclapi2.bangbang93.com";
|
||||
private static final String BMCLAPI_ROOT;
|
||||
|
||||
static {
|
||||
String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com");
|
||||
BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
MOJANG_PROVIDER = new MojangDownloadProvider();
|
||||
DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT);
|
||||
PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER);
|
||||
@Nullable String bmclOverride = System.getProperty("hmcl.bmclapi.override");
|
||||
if (StringUtils.isBlank(bmclOverride)) {
|
||||
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
|
||||
} else {
|
||||
WebURL url;
|
||||
try {
|
||||
url = WebURL.parseBrowserInput(bmclOverride);
|
||||
} catch (Exception e) {
|
||||
url = null;
|
||||
LOG.warning("Invalid BMCLAPI override URL: " + bmclOverride, e);
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
BMCLAPI_ROOT = DEFAULT_BMCLAPI_ROOT;
|
||||
} else {
|
||||
BMCLAPI_ROOT = StringUtils.removeSuffix(url.toString(), "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final HMCLDownloadProvider PROVIDER = new HMCLDownloadProvider(BMCLAPI_ROOT);
|
||||
|
||||
/// Initializes download provider settings and synchronizes download thread settings.
|
||||
public static void init() {
|
||||
InvalidationListener onChangeDownloadThreads = observable -> {
|
||||
@@ -67,73 +70,15 @@ public final class DownloadProviders {
|
||||
settings().downloadThreadsProperty().addListener(onChangeDownloadThreads);
|
||||
onChangeDownloadThreads.invalidated(null);
|
||||
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
PROVIDER_WRAPPER.setProvider(createDownloadProvider(
|
||||
settings().versionListSourceProperty().get(),
|
||||
settings().fileDownloadSourceProperty().get()));
|
||||
};
|
||||
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);
|
||||
};
|
||||
FXUtils.onChangeAndOperate(settings().versionListSourceProperty(), PROVIDER::setVersionListSource);
|
||||
FXUtils.onChangeAndOperate(settings().fileDownloadSourceProperty(), PROVIDER::setFileSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
public static DownloadProvider getDownloadProvider() {
|
||||
return PROVIDER_WRAPPER;
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
if (exception instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
return i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
return i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception.getCause() instanceof FileNotFoundException) {
|
||||
return i18n("download.code.404", uri);
|
||||
} else if (exception.getCause() instanceof AccessDeniedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile());
|
||||
} else if (exception.getCause() instanceof ArtifactMalformedException) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed");
|
||||
} else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) {
|
||||
if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution");
|
||||
}
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake");
|
||||
} else {
|
||||
return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
} else if (exception instanceof ArtifactMalformedException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
} else if (exception instanceof CancellationException) {
|
||||
return i18n("message.cancelled");
|
||||
}
|
||||
return StringUtils.getStackTrace(exception);
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,17 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
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;
|
||||
@@ -34,17 +36,10 @@ 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.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Manages the merged runtime view of local and user game directories.
|
||||
@@ -141,13 +136,18 @@ public final class GameDirectoryManager {
|
||||
/// The selected game repository, or `null` before the fallback game directory is resolved.
|
||||
private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository");
|
||||
|
||||
/// The selected instance ID projected from the selected repository.
|
||||
private static final ReadOnlyObjectWrapper<GameInstanceID> selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance");
|
||||
/// 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<GameInstanceID> selectedRepositoryInstanceListener =
|
||||
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
|
||||
@@ -202,25 +202,32 @@ public final class GameDirectoryManager {
|
||||
@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));
|
||||
}
|
||||
|
||||
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> {
|
||||
runInFX(() -> {
|
||||
@Nullable HMCLGameRepository repository = selectedRepository.get();
|
||||
if (repository != null && repository == event.getSource()) {
|
||||
repository.refreshSelectedInstance();
|
||||
for (Consumer<HMCLGameRepository> listener : versionsListeners)
|
||||
listener.accept(repository);
|
||||
}
|
||||
});
|
||||
});
|
||||
/// 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.
|
||||
@@ -323,6 +330,7 @@ public final class GameDirectoryManager {
|
||||
if (source == target) {
|
||||
gameDirectory.setName(name);
|
||||
gameDirectory.setPath(path);
|
||||
rebuildGameDirectories();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -480,17 +488,26 @@ public final class GameDirectoryManager {
|
||||
}
|
||||
|
||||
/// Returns the selected instance property projected from the selected repository.
|
||||
public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() {
|
||||
///
|
||||
/// 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 ID for the selected repository.
|
||||
public static @Nullable GameInstanceID getSelectedInstance() {
|
||||
/// 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 ID for the selected repository.
|
||||
public static void setSelectedInstance(@Nullable GameInstanceID instance) {
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public enum GameInstanceIconType {
|
||||
DEFAULT("/assets/img/grass.png"),
|
||||
@@ -47,6 +49,7 @@ public enum GameInstanceIconType {
|
||||
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;
|
||||
@@ -54,6 +57,22 @@ public enum GameInstanceIconType {
|
||||
};
|
||||
}
|
||||
|
||||
public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) {
|
||||
return switch (componentType) {
|
||||
case GAME -> GameInstanceIconType.GRASS;
|
||||
case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC;
|
||||
case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC;
|
||||
case FORGE -> GameInstanceIconType.FORGE;
|
||||
case CLEANROOM -> GameInstanceIconType.CLEANROOM;
|
||||
case LITELOADER -> GameInstanceIconType.CHICKEN;
|
||||
case OPTIFINE -> GameInstanceIconType.OPTIFINE;
|
||||
case QUILT, QUILT_API -> GameInstanceIconType.QUILT;
|
||||
case NEO_FORGE -> GameInstanceIconType.NEO_FORGE;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private final String resourceUrl;
|
||||
|
||||
GameInstanceIconType(String resourceUrl) {
|
||||
|
||||
@@ -813,15 +813,15 @@ public sealed abstract class GameSettings extends ObservableSetting {
|
||||
}
|
||||
|
||||
/// Property name for using native GLFW.
|
||||
public static final String PROPERTY_USE_NATIVE_GLFW = "useNativeGLFW";
|
||||
public static final String PROPERTY_USE_NATIVE_GLFW_OR_SDL = "useNativeGLFW";
|
||||
|
||||
/// If `true`, HMCL will use native GLFW.
|
||||
@SerializedName(PROPERTY_USE_NATIVE_GLFW)
|
||||
private final InheritableProperty<Boolean> useNativeGLFW = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW, false);
|
||||
@SerializedName(PROPERTY_USE_NATIVE_GLFW_OR_SDL)
|
||||
private final InheritableProperty<Boolean> useNativeGLFWorSDL = newInheritableProperty(PROPERTY_USE_NATIVE_GLFW_OR_SDL, false);
|
||||
|
||||
/// Returns the native GLFW property.
|
||||
public InheritableProperty<Boolean> useNativeGLFWProperty() {
|
||||
return useNativeGLFW;
|
||||
public InheritableProperty<Boolean> useNativeGLFWorSDLProperty() {
|
||||
return useNativeGLFWorSDL;
|
||||
}
|
||||
|
||||
/// Property name for using native OpenAL.
|
||||
|
||||
@@ -132,12 +132,12 @@ public final class LegacyGameSettingsMigrator {
|
||||
HMCLGameRepository repository,
|
||||
GameInstanceID instanceId,
|
||||
@Nullable GameSettingsPresetID parent) {
|
||||
Path instanceRoot = repository.getInstanceRoot(instanceId);
|
||||
Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId);
|
||||
Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME);
|
||||
if (!Files.exists(file)) {
|
||||
return null;
|
||||
}
|
||||
Path receiptLocation = repository.getInstanceStateDirectory(instanceId)
|
||||
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);
|
||||
@@ -271,7 +271,7 @@ public final class LegacyGameSettingsMigrator {
|
||||
GameSettings.PROPERTY_NOT_PATCH_NATIVES,
|
||||
GameSettings.PROPERTY_USE_CUSTOM_NATIVES,
|
||||
GameSettings.PROPERTY_NATIVES_DIRECTORY,
|
||||
GameSettings.PROPERTY_USE_NATIVE_GLFW,
|
||||
GameSettings.PROPERTY_USE_NATIVE_GLFW_OR_SDL,
|
||||
GameSettings.PROPERTY_USE_NATIVE_OPENAL
|
||||
));
|
||||
}
|
||||
@@ -342,7 +342,7 @@ public final class LegacyGameSettingsMigrator {
|
||||
boolean useCustomNatives = isLegacyCustomNativesDirectory(source);
|
||||
target.useCustomNativesProperty().setValue(useCustomNatives);
|
||||
target.nativesDirectoryProperty().setValue(useCustomNatives ? JsonUtils.getString(source, "nativesDir", "") : "");
|
||||
target.useNativeGLFWProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
|
||||
target.useNativeGLFWorSDLProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false));
|
||||
target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false));
|
||||
}
|
||||
|
||||
|
||||
@@ -153,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);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.ArtifactMalformedException;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
@@ -29,13 +30,12 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
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 org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
@@ -50,13 +50,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public final class TerracottaBundle {
|
||||
private final Path root;
|
||||
|
||||
private final List<URI> links;
|
||||
/// Candidate bundle URLs in attempt order.
|
||||
private final List<WebURL> links;
|
||||
|
||||
private final FileDownloadTask.IntegrityCheck hash;
|
||||
|
||||
private final Map<String, FileDownloadTask.IntegrityCheck> files;
|
||||
|
||||
public TerracottaBundle(Path root, List<URI> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
|
||||
/// Creates a bundle using the supplied download candidates and archive/file integrity metadata.
|
||||
/// The candidate list and file map are retained without copying.
|
||||
public TerracottaBundle(Path root, List<WebURL> links, FileDownloadTask.IntegrityCheck hash, Map<String, FileDownloadTask.IntegrityCheck> files) {
|
||||
this.root = root;
|
||||
this.links = links;
|
||||
this.hash = hash;
|
||||
|
||||
@@ -22,6 +22,7 @@ import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
@@ -42,7 +43,6 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -288,7 +288,7 @@ public final class TerracottaManager {
|
||||
|
||||
public static Task<String> exportLogs() {
|
||||
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
return new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
return Task.completed(null);
|
||||
@@ -297,7 +297,7 @@ public final class TerracottaManager {
|
||||
public static TerracottaState.Waiting setWaiting() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
new GetTask(WebURL.parse(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
return new TerracottaState.Waiting(-1, -1, null);
|
||||
@@ -317,7 +317,7 @@ public final class TerracottaManager {
|
||||
.thenComposeAsync(nodes -> {
|
||||
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI node : nodes) {
|
||||
for (WebURL node : nodes) {
|
||||
query.add(pair("public_nodes", node.toString()));
|
||||
}
|
||||
return new GetTask(NetworkUtils.withQuery(
|
||||
@@ -338,7 +338,7 @@ public final class TerracottaManager {
|
||||
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
|
||||
query.add(pair("room", room));
|
||||
query.add(pair("player", getPlayerName()));
|
||||
for (URI node : nodes) {
|
||||
for (WebURL node : nodes) {
|
||||
query.add(pair("public_nodes", node.toString()));
|
||||
}
|
||||
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
|
||||
@@ -35,10 +36,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionRange;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -90,8 +91,8 @@ public final class TerracottaMetadata {
|
||||
}
|
||||
|
||||
Stream<String> stream = downloads.stream(), streamCN = downloadsCN.stream();
|
||||
List<URI> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
.map(link -> URI.create(options.replace(link)))
|
||||
@Unmodifiable List<WebURL> links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN))
|
||||
.map(link -> WebURL.parse(options.replace(link)))
|
||||
.toList();
|
||||
|
||||
Map<String, FileDownloadTask.IntegrityCheck> files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
@@ -26,9 +27,8 @@ import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -43,17 +43,19 @@ public final class TerracottaNodeList {
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
|
||||
try {
|
||||
new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
WebURL.parse(url);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new JsonParseException("Invalid URL: " + url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile List<URI> list;
|
||||
/// Cached node URLs, or `null` before the first fetch completes.
|
||||
private static volatile @Nullable @Unmodifiable List<WebURL> list;
|
||||
|
||||
public static List<URI> fetch() {
|
||||
List<URI> list = TerracottaNodeList.list;
|
||||
/// Returns the cached unmodifiable node URLs, fetching once and caching an empty list on failure.
|
||||
public static @Unmodifiable List<WebURL> fetch() {
|
||||
@Nullable @Unmodifiable List<WebURL> list = TerracottaNodeList.list;
|
||||
if (list != null)
|
||||
return list;
|
||||
|
||||
@@ -84,7 +86,7 @@ public final class TerracottaNodeList {
|
||||
|
||||
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
|
||||
})
|
||||
.map(it -> URI.create(it.url()))
|
||||
.map(it -> WebURL.parse(it.url()))
|
||||
.toList();
|
||||
LOG.info("Terracotta node list: " + list);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorRole;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.glavo.monetfx.Contrast;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.setting.BackgroundType;
|
||||
import org.jackhuang.hmcl.setting.LauncherSettings;
|
||||
@@ -36,8 +37,8 @@ import org.jackhuang.hmcl.util.MathUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.ContentEncoding;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
@@ -48,7 +49,6 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
@@ -1497,28 +1497,28 @@ public final class ThemePackManager {
|
||||
List<Path> temporaryFiles,
|
||||
ResolvedBackground background) throws IOException {
|
||||
String url = requireNonBlank(background.networkImageUrl(), "background.url");
|
||||
URI uri = NetworkUtils.toURI(url);
|
||||
if (!NetworkUtils.isHttpUri(uri)) {
|
||||
WebURL webUrl = WebURL.parse(url);
|
||||
if (!NetworkUtils.isHttpUri(webUrl)) {
|
||||
throw new IOException("Theme background URL must be HTTP or HTTPS: " + url);
|
||||
}
|
||||
|
||||
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(uri);
|
||||
String entryName = "assets/wallpapers/" + networkBackgroundAssetName(webUrl);
|
||||
if (background.networkImageCachePolicy() == NetworkBackgroundImageCachePolicy.ENABLED) {
|
||||
try {
|
||||
@Nullable Path cachedFile = new CacheFileTask(uri).run();
|
||||
@Nullable Path cachedFile = new CacheFileTask(webUrl).run();
|
||||
if (cachedFile != null && Files.isRegularFile(cachedFile)) {
|
||||
assets.add(new ThemePackAsset(cachedFile, entryName));
|
||||
return new ThemeBackground.Image(entryName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to cache theme background, falling back to direct download: " + uri, e);
|
||||
LOG.warning("Failed to cache theme background, falling back to direct download: " + webUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
Path temporaryFile = Files.createTempFile("hmcl-theme-background-", ".tmp");
|
||||
boolean success = false;
|
||||
try {
|
||||
downloadNetworkBackground(uri, temporaryFile);
|
||||
downloadNetworkBackground(webUrl, temporaryFile);
|
||||
assets.add(new ThemePackAsset(temporaryFile, entryName));
|
||||
temporaryFiles.add(temporaryFile);
|
||||
success = true;
|
||||
@@ -1535,12 +1535,12 @@ public final class ThemePackManager {
|
||||
}
|
||||
|
||||
/// Downloads one network background into a temporary file without installing it into the persistent image cache.
|
||||
private static void downloadNetworkBackground(URI uri, Path outputFile) throws IOException {
|
||||
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(uri));
|
||||
private static void downloadNetworkBackground(WebURL url, Path outputFile) throws IOException {
|
||||
HttpURLConnection connection = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(url));
|
||||
try {
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode / 100 != 2) {
|
||||
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + uri);
|
||||
throw new IOException("Failed to download theme background: HTTP " + responseCode + " " + url);
|
||||
}
|
||||
|
||||
ContentEncoding contentEncoding = ContentEncoding.fromConnection(connection);
|
||||
@@ -1554,8 +1554,8 @@ public final class ThemePackManager {
|
||||
}
|
||||
|
||||
/// Returns a safe theme-pack asset file name for a downloaded network background.
|
||||
private static String networkBackgroundAssetName(URI uri) {
|
||||
String path = Objects.toString(uri.getPath(), "");
|
||||
private static String networkBackgroundAssetName(WebURL url) {
|
||||
String path = Objects.toString(url.getPath(), "");
|
||||
@Nullable Path fileNamePath = path.isBlank() ? null : Path.of(path).getFileName();
|
||||
String fileName = fileNamePath != null ? fileNamePath.toString() : "";
|
||||
String sanitized = sanitizePathSegment(fileName);
|
||||
|
||||
@@ -24,8 +24,7 @@ import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.beans.binding.ObjectBinding;
|
||||
import javafx.beans.binding.ObjectExpression;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.SetChangeListener;
|
||||
@@ -80,6 +79,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -91,6 +91,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
/// Provides the current launcher MonetFX theme and derived color bindings.
|
||||
@NotNullByDefault
|
||||
public final class Themes {
|
||||
/// The stage property retaining the listener for native appearance updates and marking completed registration.
|
||||
private static final String NATIVE_DARK_MODE_LISTENER = "Themes.applyNativeDarkMode.listener";
|
||||
|
||||
/// The seed color extracted from the last loaded wallpaper image.
|
||||
private static final ReadOnlyObjectWrapper<@Nullable ThemeColor> wallpaperThemeColor = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
@@ -283,6 +286,9 @@ public final class Themes {
|
||||
colorScheme
|
||||
);
|
||||
|
||||
/// Whether the current color scheme brightness is inherited from system.
|
||||
private static final ReadOnlyBooleanWrapper autoBrightness = new ReadOnlyBooleanWrapper();
|
||||
|
||||
/// The current JavaFX launcher background and its node opacity.
|
||||
private static final ReadOnlyObjectWrapper<LauncherBackground> background = new ReadOnlyObjectWrapper<>(
|
||||
new LauncherBackground(
|
||||
@@ -380,6 +386,35 @@ public final class Themes {
|
||||
}
|
||||
|
||||
static {
|
||||
Supplier<@Nullable Brightness> effectiveBrightnessSupplier = () -> {
|
||||
try {
|
||||
return ThemePackManager.resolveCurrentThemeBrightness(ThemePackManager.currentResolveContext());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
InvalidationListener autoBrightnessListener = o -> {
|
||||
boolean auto;
|
||||
if (settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
|
||||
String val = settings().themeBrightnessModeProperty().get();
|
||||
auto = val == null || switch (val.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "light", "dark" -> false;
|
||||
default -> true;
|
||||
};
|
||||
} else {
|
||||
auto = effectiveBrightnessSupplier.get() == null;
|
||||
}
|
||||
autoBrightness.set(auto);
|
||||
};
|
||||
settings().selectedThemeProperty().addListener(autoBrightnessListener);
|
||||
settings().getThemeAppearanceOverrides().addListener(autoBrightnessListener);
|
||||
settings().themeBrightnessModeProperty().addListener(autoBrightnessListener);
|
||||
settings().backgroundTypeProperty().addListener(autoBrightnessListener);
|
||||
if (FXUtils.DARK_MODE != null) {
|
||||
FXUtils.DARK_MODE.addListener(autoBrightnessListener);
|
||||
}
|
||||
autoBrightnessListener.invalidated(null);
|
||||
|
||||
ChangeListener<ResolvedTheme> listener = (observable, oldValue, newValue) -> {
|
||||
if (!Objects.equals(oldValue, newValue)) {
|
||||
colorScheme.set(newValue.toColorScheme());
|
||||
@@ -433,7 +468,7 @@ public final class Themes {
|
||||
}
|
||||
|
||||
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
|
||||
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
|
||||
return switch (Objects.toString(themeBrightnessMode, "").trim().toLowerCase(Locale.ROOT)) {
|
||||
case "light" -> Brightness.LIGHT;
|
||||
case "dark" -> Brightness.DARK;
|
||||
default -> getAutomaticBrightness();
|
||||
@@ -444,24 +479,16 @@ public final class Themes {
|
||||
///
|
||||
/// @return the effective launcher brightness
|
||||
public static Brightness getCurrentBrightness() {
|
||||
Brightness contextBrightness = getThemeConditionBrightness();
|
||||
if (!settings().getThemeAppearanceOverrides().contains(LauncherSettings.THEME_APPEARANCE_BRIGHTNESS_MODE)) {
|
||||
Brightness contextBrightness = getThemeConditionBrightness();
|
||||
try {
|
||||
return Objects.requireNonNullElse(
|
||||
ThemePackManager.resolveCurrentThemeBrightness(ThemeResolveContext.current(contextBrightness)),
|
||||
contextBrightness);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return contextBrightness;
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
String themeBrightnessMode = settings().themeBrightnessModeProperty().get();
|
||||
return switch (Objects.toString(themeBrightnessMode, "").toLowerCase(Locale.ROOT).trim()) {
|
||||
case "auto" -> getAutomaticBrightness();
|
||||
case "dark" -> Brightness.DARK;
|
||||
case "light" -> Brightness.LIGHT;
|
||||
default -> getAutomaticBrightness();
|
||||
};
|
||||
return contextBrightness;
|
||||
}
|
||||
|
||||
/// Returns the brightness requested by the current system or platform settings.
|
||||
@@ -1119,8 +1146,22 @@ public final class Themes {
|
||||
return darkMode;
|
||||
}
|
||||
|
||||
/// Applies native dark-mode integration to a JavaFX stage where the platform supports it.
|
||||
/// Returns whether the current color scheme brightness is inherited from system.
|
||||
public static ReadOnlyBooleanProperty autoBrightnessProperty() {
|
||||
return autoBrightness.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Registers native dark-mode integration once per stage where the platform supports it.
|
||||
///
|
||||
/// Windows updates the stage's native frame; macOS updates the application appearance. The stage retains
|
||||
/// its listener, which observes theme changes weakly. Repeated calls for an already registered stage do
|
||||
/// nothing, including after hiding the stage or replacing its scene.
|
||||
///
|
||||
/// @param stage the stage retaining the registration, accessed on the JavaFX application thread
|
||||
public static void applyNativeDarkMode(Stage stage) {
|
||||
if (stage.getProperties().containsKey(NATIVE_DARK_MODE_LISTENER)) {
|
||||
return;
|
||||
}
|
||||
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) {
|
||||
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> {
|
||||
if (stage.isShowing()) {
|
||||
@@ -1137,7 +1178,7 @@ public final class Themes {
|
||||
});
|
||||
}
|
||||
});
|
||||
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
|
||||
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
|
||||
|
||||
if (stage.isShowing()) {
|
||||
listener.changed(null, false, Themes.darkModeProperty().get());
|
||||
@@ -1154,7 +1195,7 @@ public final class Themes {
|
||||
MacOSNativeUtils.setAppearance(darkModeProperty().get());
|
||||
|
||||
ChangeListener<Boolean> listener = FXUtils.onWeakChange(Themes.darkModeProperty(), MacOSNativeUtils::setAppearance);
|
||||
stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener);
|
||||
stage.getProperties().put(NATIVE_DARK_MODE_LISTENER, listener);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,15 @@ import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.ui.account.AccountListPage;
|
||||
@@ -63,6 +68,8 @@ import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -87,6 +94,7 @@ public final class Controllers {
|
||||
private static Lazy<RootPage> rootPage = new Lazy<>(RootPage::new);
|
||||
/// The coordinator for the main window's scene graph and navigation stack.
|
||||
private static @Nullable Decorator decorator;
|
||||
|
||||
private static DownloadPage downloadPage;
|
||||
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
|
||||
AccountListPage accountListPage = new AccountListPage();
|
||||
@@ -201,6 +209,16 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the main stage's application identity, shutdown action, and taskbar relaunch information.
|
||||
///
|
||||
/// @param stage the unshown main stage
|
||||
private static void configureMainStage(Stage stage) {
|
||||
stage.setOnCloseRequest(event -> Launcher.stopApplication());
|
||||
FXUtils.setIcon(stage);
|
||||
stage.setTitle(Metadata.FULL_TITLE);
|
||||
WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(stage);
|
||||
}
|
||||
|
||||
/// Initializes the main application stage, scene graph, and background services.
|
||||
///
|
||||
/// @param stage the primary application stage, which must not have been shown
|
||||
@@ -221,9 +239,9 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
stage.setOnCloseRequest(e -> Launcher.stopApplication());
|
||||
configureMainStage(stage);
|
||||
|
||||
decorator = new Decorator(getRootPage());
|
||||
decorator = new Decorator(getRootPage(), Controllers::configureMainStage);
|
||||
Scene mainScene = decorator.attachStage(stage);
|
||||
getRootPage().getMainPage().showUpdateProperty().bind(UpdateChecker.checkingUpdateProperty().not().and(UpdateChecker.outdatedProperty()));
|
||||
getRootPage().getMainPage().showUpdateDialogProperty().bind(
|
||||
@@ -242,9 +260,6 @@ public final class Controllers {
|
||||
|
||||
StyleSheets.init(mainScene);
|
||||
|
||||
FXUtils.setIcon(stage);
|
||||
stage.setTitle(Metadata.FULL_TITLE);
|
||||
|
||||
if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userState().platformPromptVersionProperty().get() < 1) {
|
||||
Runnable continueAction = () -> {
|
||||
UserState userState = userState();
|
||||
@@ -370,6 +385,41 @@ public final class Controllers {
|
||||
}, updateShowTips);
|
||||
}, updateShowTips);
|
||||
}
|
||||
|
||||
tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository());
|
||||
}
|
||||
|
||||
/// Offers automatic install when a package exists under `.hmcl/modpack/`.
|
||||
///
|
||||
/// Called from [Controllers#initialize] after the UI is ready. Install does not wait for repository
|
||||
/// refresh: instance paths come from the selected repository layout. The package file itself is the
|
||||
/// install signal; it is deleted after a successful install so later startups do not re-prompt.
|
||||
private static void tryInstallBundledModpack(HMCLGameRepository repository) {
|
||||
@Nullable Path modpackFile = Metadata.findBundledModpackFile();
|
||||
if (modpackFile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install");
|
||||
|
||||
Controllers.taskDialog(
|
||||
Task.composeAsync(Schedulers.io(), () -> {
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile);
|
||||
return ModpackHelper.getInstallTask(
|
||||
repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null);
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
|
||||
if (exception != null) {
|
||||
LOG.warning("Failed to install bundled modpack", exception);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(modpackFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to delete bundled modpack: " + modpackFile, e);
|
||||
}
|
||||
}), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL
|
||||
);
|
||||
}
|
||||
|
||||
public static void dialog(Region content) {
|
||||
@@ -553,7 +603,7 @@ public final class Controllers {
|
||||
break;
|
||||
case "hmcl://game/launch":
|
||||
var repository = GameDirectoryManager.getSelectedRepository();
|
||||
Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep);
|
||||
Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -561,6 +611,24 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable URI url) {
|
||||
if (url == null) return;
|
||||
openUriOrCopy(url.toString());
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable String url) {
|
||||
if (url == null) return;
|
||||
var dialog = new MessageDialogPane.Builder(
|
||||
i18n("web.open_in_browser", url),
|
||||
i18n("message.confirm"),
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
)
|
||||
.addAction(i18n("button.copy"), () -> FXUtils.copyText(url))
|
||||
.yesOrNo(() -> FXUtils.openLink(url), null)
|
||||
.build();
|
||||
dialog(dialog);
|
||||
}
|
||||
|
||||
public static boolean isStopped() {
|
||||
return decorator == null;
|
||||
}
|
||||
@@ -579,4 +647,12 @@ public final class Controllers {
|
||||
|
||||
FXUtils.shutdown();
|
||||
}
|
||||
|
||||
public static final boolean AUTO_TRIM_HEAP = !"false".equalsIgnoreCase(System.getenv("HMCL_AUTO_TRIM_HEAP"));
|
||||
|
||||
public static void trimHeap() {
|
||||
if (AUTO_TRIM_HEAP) {
|
||||
System.gc();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ public class CrashWindow extends Stage {
|
||||
FXUtils.setIcon(this);
|
||||
setTitle(i18n("message.error"));
|
||||
|
||||
FXUtils.addMacOSCloseWindowHandler(this, null);
|
||||
|
||||
setOnCloseRequest(e -> javafx.application.Platform.exit());
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ public final class DialogUtils {
|
||||
private static void showInDecorator(Decorator decorator, StackPane dialogContainer, Node content) {
|
||||
show(dialogContainer, content, dialog -> {
|
||||
JFXDialogPane pane = (JFXDialogPane) dialog.getContent();
|
||||
decorator.capableDraggingWindow(dialog);
|
||||
decorator.capableDraggingOverlay(dialog);
|
||||
decorator.forbidDraggingWindow(pane);
|
||||
dialog.setDialogContainer(dialogContainer);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.beans.value.WeakChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventDispatcher;
|
||||
@@ -44,45 +45,47 @@ import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.skin.TableViewSkin;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.*;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Callback;
|
||||
import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.download.DownloadCandidates;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.task.CacheFileTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
|
||||
import org.jackhuang.hmcl.ui.construct.MenuSeparator;
|
||||
import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
import org.jackhuang.hmcl.ui.image.ImageLoader;
|
||||
import org.jackhuang.hmcl.ui.image.ImageUtils;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.ResourceNotFoundError;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.ExtendedProperties;
|
||||
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
|
||||
import org.jackhuang.hmcl.util.javafx.Subscription;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
@@ -105,12 +108,10 @@ import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.ToIntFunction;
|
||||
import java.util.function.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -467,25 +468,31 @@ public final class FXUtils {
|
||||
private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500);
|
||||
private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000);
|
||||
|
||||
@FXThread
|
||||
public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) {
|
||||
checkFxUserThread();
|
||||
tooltip.setShowDelay(showDelay);
|
||||
tooltip.setShowDuration(showDuration);
|
||||
tooltip.setHideDelay(hideDelay);
|
||||
Tooltip.install(node, tooltip);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static void installFastTooltip(Node node, Tooltip tooltip) {
|
||||
runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static void installFastTooltip(Node node, String tooltip) {
|
||||
installFastTooltip(node, new Tooltip(tooltip));
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static void installSlowTooltip(Node node, Tooltip tooltip) {
|
||||
runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip));
|
||||
installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip);
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static void installSlowTooltip(Node node, String tooltip) {
|
||||
installSlowTooltip(node, new Tooltip(tooltip));
|
||||
}
|
||||
@@ -610,21 +617,21 @@ public final class FXUtils {
|
||||
if (link == null)
|
||||
return;
|
||||
|
||||
String uri = NetworkUtils.encodeLocation(link);
|
||||
String url = NetworkUtils.encodeLocation(link);
|
||||
thread(() -> {
|
||||
try {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri});
|
||||
Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", url});
|
||||
return;
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Runtime.getRuntime().exec(new String[]{"open", uri});
|
||||
Runtime.getRuntime().exec(new String[]{"open", url});
|
||||
return;
|
||||
} else {
|
||||
for (String browser : linuxBrowsers) {
|
||||
Path path = SystemUtils.which(browser);
|
||||
if (path != null) {
|
||||
try {
|
||||
Runtime.getRuntime().exec(new String[]{path.toString(), uri});
|
||||
Runtime.getRuntime().exec(new String[]{path.toString(), url});
|
||||
return;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
@@ -637,7 +644,7 @@ public final class FXUtils {
|
||||
}
|
||||
|
||||
try {
|
||||
java.awt.Desktop.getDesktop().browse(new URI(uri));
|
||||
java.awt.Desktop.getDesktop().browse(new URI(url));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to open link: " + link, e);
|
||||
}
|
||||
@@ -725,116 +732,6 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class EnumBidirectionalBinding<E extends Enum<E>> implements InvalidationListener, WeakListener {
|
||||
private final WeakReference<JFXComboBox<E>> comboBoxRef;
|
||||
private final WeakReference<Property<E>> propertyRef;
|
||||
private final int hashCode;
|
||||
|
||||
private boolean updating = false;
|
||||
|
||||
private EnumBidirectionalBinding(JFXComboBox<E> comboBox, Property<E> property) {
|
||||
this.comboBoxRef = new WeakReference<>(comboBox);
|
||||
this.propertyRef = new WeakReference<>(property);
|
||||
this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(property);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable sourceProperty) {
|
||||
if (!updating) {
|
||||
final JFXComboBox<E> comboBox = comboBoxRef.get();
|
||||
final Property<E> property = propertyRef.get();
|
||||
|
||||
if (comboBox == null || property == null) {
|
||||
if (comboBox != null) {
|
||||
comboBox.getSelectionModel().selectedItemProperty().removeListener(this);
|
||||
}
|
||||
|
||||
if (property != null) {
|
||||
property.removeListener(this);
|
||||
}
|
||||
} else {
|
||||
updating = true;
|
||||
try {
|
||||
if (property == sourceProperty) {
|
||||
E newValue = property.getValue();
|
||||
comboBox.getSelectionModel().select(newValue);
|
||||
} else {
|
||||
E newValue = comboBox.getSelectionModel().getSelectedItem();
|
||||
property.setValue(newValue);
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return comboBoxRef.get() == null || propertyRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof EnumBidirectionalBinding))
|
||||
return false;
|
||||
|
||||
EnumBidirectionalBinding<?> that = (EnumBidirectionalBinding<?>) o;
|
||||
|
||||
final JFXComboBox<E> comboBox = this.comboBoxRef.get();
|
||||
final Property<E> property = this.propertyRef.get();
|
||||
|
||||
final JFXComboBox<?> thatComboBox = that.comboBoxRef.get();
|
||||
final Property<?> thatProperty = that.propertyRef.get();
|
||||
|
||||
if (comboBox == null || property == null || thatComboBox == null || thatProperty == null)
|
||||
return false;
|
||||
|
||||
return comboBox == thatComboBox && property == thatProperty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind combo box selection with given enum property bidirectionally.
|
||||
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
|
||||
*
|
||||
* @param comboBox the combo box being bound with {@code property}.
|
||||
* @param property the property being bound with {@code combo box}.
|
||||
* @see #unbindEnum(JFXComboBox, Property)
|
||||
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
|
||||
*/
|
||||
public static <T extends Enum<T>> void bindEnum(JFXComboBox<T> comboBox, Property<T> property) {
|
||||
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
|
||||
|
||||
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
|
||||
property.removeListener(binding);
|
||||
|
||||
comboBox.getSelectionModel().select(property.getValue());
|
||||
comboBox.getSelectionModel().selectedItemProperty().addListener(binding);
|
||||
property.addListener(binding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind combo box selection with given enum property bidirectionally.
|
||||
* You should <b>only and always</b> use {@code bindEnum} as well as {@code unbindEnum} at the same time.
|
||||
*
|
||||
* @param comboBox the combo box being bound with the property which can be inferred by {@code bindEnum}.
|
||||
* @see #bindEnum(JFXComboBox, Property)
|
||||
* @see ExtendedProperties#selectedItemPropertyFor(ComboBox)
|
||||
*/
|
||||
public static <T extends Enum<T>> void unbindEnum(JFXComboBox<T> comboBox, Property<T> property) {
|
||||
EnumBidirectionalBinding<T> binding = new EnumBidirectionalBinding<>(comboBox, property);
|
||||
comboBox.getSelectionModel().selectedItemProperty().removeListener(binding);
|
||||
property.removeListener(binding);
|
||||
}
|
||||
|
||||
private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener {
|
||||
private final WeakReference<ColorPicker> colorPickerRef;
|
||||
private final WeakReference<Property<Paint>> propertyRef;
|
||||
@@ -929,147 +826,7 @@ public final class FXUtils {
|
||||
property.addListener(binding);
|
||||
}
|
||||
|
||||
private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener {
|
||||
private final WeakReference<JFXComboBox<String>> comboBoxRef;
|
||||
private final WeakReference<IntegerProperty> widthPropertyRef;
|
||||
private final WeakReference<IntegerProperty> heightPropertyRef;
|
||||
|
||||
private final int hashCode;
|
||||
|
||||
private boolean updating = false;
|
||||
|
||||
private WindowsSizeBidirectionalBinding(JFXComboBox<String> comboBox,
|
||||
IntegerProperty widthProperty,
|
||||
IntegerProperty heightProperty) {
|
||||
this.comboBoxRef = new WeakReference<>(comboBox);
|
||||
this.widthPropertyRef = new WeakReference<>(widthProperty);
|
||||
this.heightPropertyRef = new WeakReference<>(heightProperty);
|
||||
this.hashCode = System.identityHashCode(comboBox)
|
||||
^ System.identityHashCode(widthProperty)
|
||||
^ System.identityHashCode(heightProperty);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
if (!updating) {
|
||||
var comboBox = this.comboBoxRef.get();
|
||||
var widthProperty = this.widthPropertyRef.get();
|
||||
var heightProperty = this.heightPropertyRef.get();
|
||||
|
||||
if (comboBox == null || widthProperty == null || heightProperty == null) {
|
||||
if (comboBox != null) {
|
||||
comboBox.focusedProperty().removeListener(this);
|
||||
comboBox.sceneProperty().removeListener(this);
|
||||
}
|
||||
if (widthProperty != null)
|
||||
widthProperty.removeListener(this);
|
||||
if (heightProperty != null)
|
||||
heightProperty.removeListener(this);
|
||||
} else {
|
||||
updating = true;
|
||||
try {
|
||||
int width = widthProperty.get();
|
||||
int height = heightProperty.get();
|
||||
|
||||
if (observable instanceof ReadOnlyProperty<?>
|
||||
&& ((ReadOnlyProperty<?>) observable).getBean() == comboBox) {
|
||||
String value = comboBox.valueProperty().get();
|
||||
if (value == null)
|
||||
value = "";
|
||||
int idx = value.indexOf('x');
|
||||
if (idx < 0)
|
||||
idx = value.indexOf('*');
|
||||
|
||||
if (idx < 0) {
|
||||
LOG.warning("Bad window size: " + value);
|
||||
comboBox.setValue(width + "x" + height);
|
||||
return;
|
||||
}
|
||||
|
||||
String widthStr = value.substring(0, idx).trim();
|
||||
String heightStr = value.substring(idx + 1).trim();
|
||||
|
||||
int newWidth;
|
||||
int newHeight;
|
||||
try {
|
||||
newWidth = Integer.parseInt(widthStr);
|
||||
newHeight = Integer.parseInt(heightStr);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Bad window size: " + value);
|
||||
comboBox.setValue(width + "x" + height);
|
||||
return;
|
||||
}
|
||||
|
||||
widthProperty.set(newWidth);
|
||||
heightProperty.set(newHeight);
|
||||
} else {
|
||||
comboBox.setValue(width + "x" + height);
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return this.comboBoxRef.get() == null
|
||||
|| this.widthPropertyRef.get() == null
|
||||
|| this.heightPropertyRef.get() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj)
|
||||
return true;
|
||||
if (!(obj instanceof WindowsSizeBidirectionalBinding))
|
||||
return false;
|
||||
|
||||
var that = (WindowsSizeBidirectionalBinding) obj;
|
||||
|
||||
var comboBox = this.comboBoxRef.get();
|
||||
var widthProperty = this.widthPropertyRef.get();
|
||||
var heightProperty = this.heightPropertyRef.get();
|
||||
|
||||
var thatComboBox = that.comboBoxRef.get();
|
||||
var thatWidthProperty = that.widthPropertyRef.get();
|
||||
var thatHeightProperty = that.heightPropertyRef.get();
|
||||
|
||||
if (comboBox == null || widthProperty == null || heightProperty == null
|
||||
|| thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return comboBox == thatComboBox
|
||||
&& widthProperty == thatWidthProperty
|
||||
&& heightProperty == thatHeightProperty;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
|
||||
comboBox.setValue(widthProperty.get() + "x" + heightProperty.get());
|
||||
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
|
||||
comboBox.focusedProperty().addListener(binding);
|
||||
comboBox.sceneProperty().addListener(binding);
|
||||
widthProperty.addListener(binding);
|
||||
heightProperty.addListener(binding);
|
||||
}
|
||||
|
||||
public static void unbindWindowsSize(JFXComboBox<String> comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) {
|
||||
var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty);
|
||||
comboBox.focusedProperty().removeListener(binding);
|
||||
comboBox.sceneProperty().removeListener(binding);
|
||||
widthProperty.removeListener(binding);
|
||||
heightProperty.removeListener(binding);
|
||||
}
|
||||
|
||||
public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
|
||||
public static List<Subscription> bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) {
|
||||
int itemCount = children.length;
|
||||
int childSelectedCount = 0;
|
||||
for (BooleanProperty child : children) {
|
||||
@@ -1079,49 +836,46 @@ public final class FXUtils {
|
||||
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
|
||||
class Listener implements InvalidationListener {
|
||||
class AllListener {
|
||||
private int childSelectedCount;
|
||||
private boolean updating = false;
|
||||
private final List<Subscription> subscriptions = new ArrayList<>(children.length + 1);
|
||||
|
||||
public Listener(int childSelectedCount) {
|
||||
public AllListener(int childSelectedCount) {
|
||||
this.childSelectedCount = childSelectedCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
if (updating)
|
||||
return;
|
||||
|
||||
updating = true;
|
||||
try {
|
||||
boolean value = ((BooleanProperty) observable).get();
|
||||
|
||||
if (observable == allEnabled) {
|
||||
{
|
||||
subscriptions.add(Subscription.subscribe(allEnabled, () -> {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
try {
|
||||
boolean value = allEnabled.get();
|
||||
for (BooleanProperty child : children) {
|
||||
child.setValue(value);
|
||||
child.set(value);
|
||||
}
|
||||
childSelectedCount = value ? itemCount : 0;
|
||||
} else {
|
||||
if (value)
|
||||
childSelectedCount++;
|
||||
else
|
||||
childSelectedCount--;
|
||||
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
} finally {
|
||||
updating = false;
|
||||
}));
|
||||
for (var child : children) {
|
||||
subscriptions.add(Subscription.subscribe(child, () -> {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
try {
|
||||
if (child.get()) childSelectedCount++;
|
||||
else childSelectedCount--;
|
||||
allEnabled.set(childSelectedCount == itemCount);
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvalidationListener listener = new Listener(childSelectedCount);
|
||||
|
||||
WeakInvalidationListener weakListener = new WeakInvalidationListener(listener);
|
||||
allEnabled.addListener(listener);
|
||||
for (BooleanProperty child : children) {
|
||||
child.addListener(weakListener);
|
||||
}
|
||||
return new AllListener(childSelectedCount).subscriptions;
|
||||
}
|
||||
|
||||
public static void setIcon(Stage stage) {
|
||||
@@ -1248,8 +1002,9 @@ public final class FXUtils {
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
|
||||
public static Task<Image> getRemoteImageTask(List<URI> uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(uris)
|
||||
/// Creates a task that caches an image from HTTP(S) candidates and loads it with the requested sizing options.
|
||||
public static Task<Image> getRemoteImageTask(DownloadCandidates candidates, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
|
||||
return new CacheFileTask(candidates)
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
@@ -1390,20 +1145,6 @@ public final class FXUtils {
|
||||
};
|
||||
}
|
||||
|
||||
public static <T> Callback<ListView<T>, ListCell<T>> jfxListCellFactory(Function<T, Node> graphicBuilder) {
|
||||
return view -> new JFXListCell<T>() {
|
||||
@Override
|
||||
public void updateItem(T item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (!empty) {
|
||||
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
|
||||
setGraphic(graphicBuilder.apply(item));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ColumnConstraints getColumnFillingWidth() {
|
||||
ColumnConstraints constraint = new ColumnConstraints();
|
||||
constraint.setFillWidth(true);
|
||||
@@ -1456,6 +1197,27 @@ public final class FXUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static void onDoubleClicked(Node node, Runnable action) {
|
||||
node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
|
||||
action.run();
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void addMacOSCloseWindowHandler(Window window, @Nullable Supplier<Boolean> restriction) {
|
||||
window.addEventHandler(KeyEvent.KEY_PRESSED, event -> {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& event.isMetaDown()
|
||||
&& event.getCode() == KeyCode.W
|
||||
&& (restriction == null || restriction.get())) {
|
||||
window.fireEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSE_REQUEST));
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static <N extends Parent> N prepareNode(N node) {
|
||||
Scene dummyScene = new Scene(node);
|
||||
StyleSheets.init(dummyScene);
|
||||
@@ -1521,6 +1283,34 @@ public final class FXUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyFiles(List<Path> paths) {
|
||||
copyFiles(paths.stream().map(Path::toFile).toList(), i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyFiles(List<File> files, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putFiles(files);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyImage(Image image) {
|
||||
copyImage(image, i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyImage(Image image, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putImage(image);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Node> parseSegment(String segment, Consumer<String> hyperlinkAction) {
|
||||
if (segment.indexOf('<') < 0)
|
||||
return Collections.singletonList(new Text(segment));
|
||||
@@ -1713,4 +1503,110 @@ public final class FXUtils {
|
||||
e.consume();
|
||||
});
|
||||
}
|
||||
|
||||
public static TextFlow renderAddonChangelog(String changelogHtml, String baseUri) {
|
||||
var textFlow = new HTMLRenderer(Controllers::openUriOrCopy).appendNode(Jsoup.parse(changelogHtml, baseUri)).mergeLineBreaks().render();
|
||||
textFlow.getStyleClass().add("addon-changelog");
|
||||
return textFlow;
|
||||
}
|
||||
|
||||
/// Be cautious when dealing with large amounts of data
|
||||
public static <S> TableView<S> newAutoHeightTable(ObservableList<S> items) {
|
||||
return new AutoHeightTableView<>(items);
|
||||
}
|
||||
|
||||
private static final class AutoHeightTableView<S> extends TableView<S> {
|
||||
|
||||
public AutoHeightTableView() {
|
||||
super();
|
||||
}
|
||||
|
||||
public AutoHeightTableView(ObservableList<S> items) {
|
||||
super(items);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Skin<?> createDefaultSkin() {
|
||||
return new AutoSizeSkin<>(this);
|
||||
}
|
||||
|
||||
private static final class AutoSizeSkin<S> extends TableViewSkin<S> {
|
||||
|
||||
private final VirtualFlow<TableRow<S>> flow;
|
||||
|
||||
public AutoSizeSkin(TableView<S> control) {
|
||||
super(control);
|
||||
this.flow = getVirtualFlow();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
double fixedCellSize = getSkinnable().getFixedCellSize();
|
||||
double w = width - rightInset - leftInset;
|
||||
int itemCount = getItemCount();
|
||||
double headerHeight = getTableHeaderRow().prefHeight(w);
|
||||
|
||||
double prefHeight = topInset + bottomInset + headerHeight;
|
||||
if (fixedCellSize > 0 || itemCount == 0) return prefHeight + fixedCellSize * itemCount;
|
||||
int i = 0;
|
||||
TableRow<S> r;
|
||||
while (i < itemCount && !(r = flow.getCell(i)).isEmpty() && r.getItem() != null) {
|
||||
prefHeight += r.prefHeight(w);
|
||||
i++;
|
||||
}
|
||||
return prefHeight;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void chooseDateRange(Consumer<@NotNull Pair<@Nullable LocalDate, @Nullable LocalDate>> onConfirm) {
|
||||
Controllers.dialog(new DateRangeDialog(onConfirm));
|
||||
}
|
||||
|
||||
private static class DateRangeDialog extends JFXDialogLayout {
|
||||
|
||||
private final JFXDatePicker fromPicker, toPicker;
|
||||
|
||||
public DateRangeDialog(Consumer<Pair<LocalDate, LocalDate>> onConfirm) {
|
||||
super();
|
||||
|
||||
setHeading(new Label(i18n("button.select_date")));
|
||||
|
||||
GridPane body = new GridPane(4, 8);
|
||||
{
|
||||
Label fromLabel = new Label(i18n("button.select_date.from"));
|
||||
this.fromPicker = new JFXDatePicker(LocalDate.now());
|
||||
fromPicker.setOverLay(true);
|
||||
fromPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
|
||||
body.add(fromLabel, 0, 0);
|
||||
body.add(fromPicker, 1, 0);
|
||||
}
|
||||
{
|
||||
Label toLabel = new Label(i18n("button.select_date.to"));
|
||||
this.toPicker = new JFXDatePicker(LocalDate.now());
|
||||
toPicker.setOverLay(true);
|
||||
toPicker.setDialogParent(Controllers.getDecorator().getDialogContainer());
|
||||
body.add(toLabel, 0, 1);
|
||||
body.add(toPicker, 1, 1);
|
||||
}
|
||||
setBody(body);
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("dialog-cancel");
|
||||
cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
JFXButton okButton = new JFXButton(i18n("button.ok"));
|
||||
okButton.getStyleClass().add("dialog-accept");
|
||||
okButton.setOnAction(e -> {
|
||||
if (onConfirm != null) onConfirm.accept(Pair.pair(fromPicker.getValue(), toPicker.getValue()));
|
||||
fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
|
||||
setActions(cancelButton, okButton);
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,7 @@ import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import javafx.stage.Stage;
|
||||
import kala.encdet.EncodingDetector;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.launch.ProcessListener;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
@@ -45,10 +43,10 @@ import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
|
||||
@@ -73,17 +71,15 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class GameCrashWindow extends Stage {
|
||||
private final GameInstanceManifest manifest;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final String memory;
|
||||
private final String total_memory;
|
||||
private final String java;
|
||||
private final LibraryAnalyzer analyzer;
|
||||
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty();
|
||||
private final TextFlow feedbackTextFlow = new TextFlow();
|
||||
|
||||
private final ManagedProcess managedProcess;
|
||||
private final DefaultGameRepository repository;
|
||||
private final ProcessListener.ExitType exitType;
|
||||
private final LaunchOptions launchOptions;
|
||||
private final View view;
|
||||
@@ -91,16 +87,14 @@ public class GameCrashWindow extends Stage {
|
||||
|
||||
private final List<Log> logs;
|
||||
|
||||
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List<Log> logs) {
|
||||
public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List<Log> logs) {
|
||||
Themes.applyNativeDarkMode(this);
|
||||
|
||||
this.managedProcess = managedProcess;
|
||||
this.exitType = exitType;
|
||||
this.repository = repository;
|
||||
this.manifest = manifest;
|
||||
this.gameInstance = gameInstance;
|
||||
this.launchOptions = launchOptions;
|
||||
this.logs = logs;
|
||||
this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null));
|
||||
|
||||
memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-");
|
||||
|
||||
@@ -120,6 +114,8 @@ public class GameCrashWindow extends Stage {
|
||||
setTitle(i18n("game.crash.title"));
|
||||
FXUtils.setIcon(this);
|
||||
|
||||
FXUtils.addMacOSCloseWindowHandler(this, null);
|
||||
|
||||
analyzeCrashReport();
|
||||
}
|
||||
|
||||
@@ -142,14 +138,15 @@ public class GameCrashWindow extends Stage {
|
||||
|
||||
return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>());
|
||||
}), Task.supplyAsync(() -> {
|
||||
Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log");
|
||||
Path runDirectory = gameInstance.getRunDirectory();
|
||||
Path latestLog = runDirectory.resolve("logs/latest.log");
|
||||
if (!Files.isReadable(latestLog)) {
|
||||
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
|
||||
}
|
||||
|
||||
String log;
|
||||
try {
|
||||
log = EncodingDetector.MODERN_WEB.readString(latestLog);
|
||||
log = FileUtils.readTextMaybeNativeEncoding(latestLog);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read logs/latest.log", e);
|
||||
return pair(new HashSet<CrashReportAnalyzer.Result>(), new HashSet<String>());
|
||||
@@ -291,7 +288,7 @@ public class GameCrashWindow extends Stage {
|
||||
}
|
||||
});
|
||||
|
||||
return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs,
|
||||
return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs,
|
||||
new CommandBuilder().addAll(managedProcess.getCommands()).toString(),
|
||||
path -> {
|
||||
try {
|
||||
@@ -342,10 +339,10 @@ public class GameCrashWindow extends Stage {
|
||||
launcher.setTitle(i18n("launcher"));
|
||||
launcher.setSubtitle(Metadata.VERSION);
|
||||
|
||||
TwoLineListItem version = new TwoLineListItem();
|
||||
version.getStyleClass().setAll("two-line-item-second-large");
|
||||
version.setTitle(i18n("game.version"));
|
||||
version.setSubtitle(GameCrashWindow.this.manifest.id().toString());
|
||||
TwoLineListItem instance = new TwoLineListItem();
|
||||
instance.getStyleClass().setAll("two-line-item-second-large");
|
||||
instance.setTitle(i18n("game.version"));
|
||||
instance.setSubtitle(GameCrashWindow.this.gameInstance.getId().toString());
|
||||
|
||||
TwoLineListItem total_memory = new TwoLineListItem();
|
||||
total_memory.getStyleClass().setAll("two-line-item-second-large");
|
||||
@@ -365,14 +362,14 @@ public class GameCrashWindow extends Stage {
|
||||
TwoLineListItem os = new TwoLineListItem();
|
||||
os.getStyleClass().setAll("two-line-item-second-large");
|
||||
os.setTitle(i18n("system.operating_system"));
|
||||
os.setSubtitle(Lang.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
|
||||
os.setSubtitle(Objects.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME));
|
||||
|
||||
TwoLineListItem arch = new TwoLineListItem();
|
||||
arch.getStyleClass().setAll("two-line-item-second-large");
|
||||
arch.setTitle(i18n("system.architecture"));
|
||||
arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName());
|
||||
|
||||
infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch);
|
||||
infoPane.getChildren().setAll(launcher, instance, total_memory, memory, java, os, arch);
|
||||
}
|
||||
|
||||
HBox moddedPane = new HBox(8);
|
||||
@@ -380,15 +377,13 @@ public class GameCrashWindow extends Stage {
|
||||
moddedPane.setPadding(new Insets(8));
|
||||
moddedPane.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
|
||||
if (!type.getPatchId().isEmpty()) {
|
||||
analyzer.getVersion(type).ifPresent(ver -> {
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.getStyleClass().setAll("two-line-item-second-large");
|
||||
item.setTitle(i18n("install.installer." + type.getPatchId()));
|
||||
item.setSubtitle(ver);
|
||||
moddedPane.getChildren().add(item);
|
||||
});
|
||||
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
|
||||
if (mark.version() != null) {
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.getStyleClass().setAll("two-line-item-second-large");
|
||||
item.setTitle(i18n("install.installer." + mark.componentType().getPatchId()));
|
||||
item.setSubtitle(mark.version());
|
||||
moddedPane.getChildren().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,27 +17,41 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.control.Separator;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.nodes.Node;
|
||||
import org.jsoup.nodes.TextNode;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class HTMLRenderer {
|
||||
private static final String INDENT = " ";
|
||||
|
||||
private static URI resolveLink(Node linkNode) {
|
||||
String href = linkNode.absUrl("href");
|
||||
if (href.isEmpty())
|
||||
@@ -50,6 +64,42 @@ public final class HTMLRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// @see org.jsoup.internal.StringUtil#isWhitespace(int)
|
||||
public static boolean isWhitespace(int c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r';
|
||||
}
|
||||
|
||||
/// @see org.jsoup.internal.StringUtil#isInvisibleChar(int)
|
||||
public static boolean isInvisibleChar(int c) {
|
||||
return c == 8203 || c == 173; // zero width sp, soft hyphen
|
||||
// previously also included zw non join, zw join - but removing those breaks semantic meaning of text
|
||||
}
|
||||
|
||||
/// @see org.jsoup.internal.StringUtil#normaliseWhitespace(String)
|
||||
/// @see org.jsoup.internal.StringUtil#isActuallyWhitespace(int)
|
||||
public static String normaliseWhitespace(String str) {
|
||||
var accum = new StringBuilder();
|
||||
boolean lastWasWhite = false;
|
||||
int len = str.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
int c = str.codePointAt(i);
|
||||
if (isWhitespace(c)) { // Ignore
|
||||
if (lastWasWhite) {
|
||||
i += Character.charCount(c);
|
||||
continue;
|
||||
}
|
||||
accum.append(' ');
|
||||
lastWasWhite = true;
|
||||
} else if (!isInvisibleChar(c)) {
|
||||
accum.appendCodePoint(c);
|
||||
lastWasWhite = false;
|
||||
}
|
||||
i += Character.charCount(c);
|
||||
}
|
||||
return accum.toString();
|
||||
}
|
||||
|
||||
private final List<javafx.scene.Node> children = new ArrayList<>();
|
||||
private final List<Node> stack = new ArrayList<>();
|
||||
|
||||
@@ -58,12 +108,17 @@ public final class HTMLRenderer {
|
||||
private boolean underline;
|
||||
private boolean strike;
|
||||
private boolean highlight;
|
||||
private int indentLevel;
|
||||
private boolean preformatted;
|
||||
private boolean code;
|
||||
private int listDepth;
|
||||
private String headerLevel;
|
||||
private Node hyperlink;
|
||||
private String fxStyle;
|
||||
|
||||
private final Consumer<URI> onClickHyperlink;
|
||||
|
||||
private boolean rendered;
|
||||
|
||||
public HTMLRenderer(Consumer<URI> onClickHyperlink) {
|
||||
this.onClickHyperlink = onClickHyperlink;
|
||||
}
|
||||
@@ -74,42 +129,32 @@ public final class HTMLRenderer {
|
||||
underline = false;
|
||||
strike = false;
|
||||
highlight = false;
|
||||
preformatted = false;
|
||||
code = false;
|
||||
listDepth = 0;
|
||||
headerLevel = null;
|
||||
hyperlink = null;
|
||||
fxStyle = null;
|
||||
|
||||
var declarations = new SimpleCssDeclarations();
|
||||
for (Node node : stack) {
|
||||
String nodeName = node.nodeName();
|
||||
switch (nodeName) {
|
||||
case "b":
|
||||
case "strong":
|
||||
bold = true;
|
||||
break;
|
||||
case "i":
|
||||
case "em":
|
||||
italic = true;
|
||||
break;
|
||||
case "ins":
|
||||
underline = true;
|
||||
break;
|
||||
case "del":
|
||||
strike = true;
|
||||
break;
|
||||
case "mark":
|
||||
highlight = true;
|
||||
break;
|
||||
case "a":
|
||||
hyperlink = node;
|
||||
break;
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
headerLevel = nodeName;
|
||||
break;
|
||||
case "b", "strong" -> bold = true;
|
||||
case "i", "em" -> italic = true;
|
||||
case "ins" -> underline = true;
|
||||
case "del" -> strike = true;
|
||||
case "mark" -> highlight = true;
|
||||
case "pre" -> preformatted = true;
|
||||
case "code" -> code = true;
|
||||
case "a" -> hyperlink = node;
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6" -> headerLevel = nodeName;
|
||||
case "li" -> listDepth++;
|
||||
}
|
||||
|
||||
declarations.add(node.attr("style"));
|
||||
}
|
||||
fxStyle = declarations.asString();
|
||||
}
|
||||
|
||||
private void pushNode(Node node) {
|
||||
@@ -123,6 +168,14 @@ public final class HTMLRenderer {
|
||||
}
|
||||
|
||||
private void applyStyle(Text text) {
|
||||
if (code) {
|
||||
text.getStyleClass().add("html-code");
|
||||
text.setStyle("-fx-font-family: \"%s\";".formatted(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
|
||||
return;
|
||||
}
|
||||
|
||||
var styleBuilder = new StringBuilder();
|
||||
|
||||
if (hyperlink != null) {
|
||||
URI target = resolveLink(hyperlink);
|
||||
if (target != null) {
|
||||
@@ -146,12 +199,22 @@ public final class HTMLRenderer {
|
||||
|
||||
if (headerLevel != null)
|
||||
text.getStyleClass().add("html-" + headerLevel);
|
||||
|
||||
if (fxStyle != null)
|
||||
styleBuilder.append(fxStyle);
|
||||
text.setStyle(styleBuilder.toString());
|
||||
}
|
||||
|
||||
private void appendText(String text) {
|
||||
Text textNode = new Text(text);
|
||||
applyStyle(textNode);
|
||||
children.add(textNode);
|
||||
if (code) {
|
||||
var codeFlow = new CodeFlow(textNode);
|
||||
codeFlow.getStyleClass().add("html-code-block");
|
||||
children.add(codeFlow);
|
||||
} else {
|
||||
children.add(textNode);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendAutoLineBreak(String text) {
|
||||
@@ -185,20 +248,22 @@ public final class HTMLRenderer {
|
||||
}
|
||||
|
||||
try {
|
||||
Image image = FXUtils.getRemoteImageTask(src, width, height, true, true)
|
||||
.run();
|
||||
if (image == null)
|
||||
throw new AssertionError("Image loading task returned null");
|
||||
ImageView imageView = new ImageView();
|
||||
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(src, width, height, true, true));
|
||||
imageView.setPreserveRatio(true);
|
||||
|
||||
ImagePane imagePane = new ImagePane(imageView, alt);
|
||||
|
||||
ImageView imageView = new ImageView(image);
|
||||
if (hyperlink != null) {
|
||||
URI target = resolveLink(hyperlink);
|
||||
if (target != null) {
|
||||
FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target));
|
||||
imageView.setCursor(Cursor.HAND);
|
||||
FXUtils.onClicked(imagePane, () -> onClickHyperlink.accept(target));
|
||||
imagePane.setCursor(Cursor.HAND);
|
||||
}
|
||||
}
|
||||
children.add(imageView);
|
||||
|
||||
children.add(imagePane);
|
||||
return;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load image: " + src, e);
|
||||
@@ -209,59 +274,203 @@ public final class HTMLRenderer {
|
||||
appendText(alt);
|
||||
}
|
||||
|
||||
public void appendNode(Node node) {
|
||||
if (node instanceof TextNode) {
|
||||
appendText(((TextNode) node).text());
|
||||
private void appendTable(Node table) {
|
||||
var childElements = ((Element) table).children();
|
||||
List<Element> captions = new ArrayList<>();
|
||||
|
||||
List<Element> head = new ArrayList<>();
|
||||
List<List<Element>> body = new ArrayList<>();
|
||||
List<Element> foot = new ArrayList<>();
|
||||
|
||||
boolean hasHead = false;
|
||||
boolean hasBody = childElements.stream().map(Element::nodeName).anyMatch("tbody"::equals);
|
||||
boolean hasFoot = false;
|
||||
int columnCount = 0;
|
||||
for (Element child : childElements) {
|
||||
switch (child.nodeName()) {
|
||||
case "caption" -> captions.add(child);
|
||||
case "thead" -> {
|
||||
if (hasHead) continue;
|
||||
hasHead = true;
|
||||
for (Element e : child.children()) {
|
||||
if (e.nameIs("tr")) {
|
||||
head.clear();
|
||||
head.addAll(
|
||||
e.children().stream()
|
||||
.filter(n -> n.nameIs("th") || n.nameIs("td"))
|
||||
.toList()
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (e.nameIs("th") || e.nameIs("td")) {
|
||||
head.add(e);
|
||||
}
|
||||
}
|
||||
columnCount = Math.max(columnCount, head.size());
|
||||
}
|
||||
case "tbody" -> {
|
||||
var rows = child.children().stream()
|
||||
.filter(e -> e.nameIs("tr"))
|
||||
.map(e ->
|
||||
e.children().stream().filter(n -> n.nameIs("th") || n.nameIs("td")).toList())
|
||||
.filter(row -> !row.isEmpty())
|
||||
.toList();
|
||||
body.addAll(rows);
|
||||
columnCount = Math.max(columnCount, rows.stream().mapToInt(List::size).max().orElse(0));
|
||||
}
|
||||
case "tfoot" -> {
|
||||
if (hasFoot) continue;
|
||||
hasFoot = true;
|
||||
for (Element e : child.children()) {
|
||||
if (e.nameIs("tr")) {
|
||||
foot.clear();
|
||||
foot.addAll(
|
||||
e.children().stream()
|
||||
.filter(n -> n.nameIs("th") || n.nameIs("td"))
|
||||
.toList()
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (e.nameIs("th") || e.nameIs("td")) {
|
||||
foot.add(e);
|
||||
}
|
||||
}
|
||||
columnCount = Math.max(columnCount, foot.size());
|
||||
}
|
||||
case "tr" -> {
|
||||
if (hasBody) continue;
|
||||
List<Element> row = child.children().stream()
|
||||
.filter(n -> n.nameIs("th") || n.nameIs("td"))
|
||||
.toList();
|
||||
if (!row.isEmpty()) {
|
||||
body.add(row);
|
||||
columnCount = Math.max(columnCount, row.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
head = Lang.copyWithSize(head, columnCount, null);
|
||||
|
||||
List<List<Element>> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size());
|
||||
for (List<Element> row : body)
|
||||
rows.add(Lang.copyWithSize(row, columnCount, null));
|
||||
if (hasFoot)
|
||||
rows.add(Lang.copyWithSize(foot, columnCount, null));
|
||||
|
||||
TableView<List<Element>> tableView = FXUtils.newAutoHeightTable(FXCollections.observableList(rows));
|
||||
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
|
||||
for (int i = 0; i < columnCount; i++) {
|
||||
int finalI = i;
|
||||
TableColumn<List<Element>, Element> c = new TableColumn<>();
|
||||
Element e = head.get(i);
|
||||
if (e != null) {
|
||||
var box = new VBox(new HTMLRenderer(onClickHyperlink).appendNode(e).mergeLineBreaks().render());
|
||||
box.setAlignment(Pos.CENTER_LEFT);
|
||||
c.setGraphic(box);
|
||||
}
|
||||
c.setCellFactory(param -> new TableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(Element item, boolean empty) {
|
||||
if (item == getItem()) return;
|
||||
super.updateItem(item, empty);
|
||||
if (item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(new Pane(new HTMLRenderer(onClickHyperlink).appendNode(item).mergeLineBreaks().render()));
|
||||
}
|
||||
}
|
||||
});
|
||||
c.setCellValueFactory(param -> new SimpleObjectProperty<>(param.getValue().get(finalI)));
|
||||
tableView.getColumns().add(c);
|
||||
}
|
||||
|
||||
for (Element caption : captions) {
|
||||
appendAutoLineBreak("\n\n");
|
||||
appendChildren(caption);
|
||||
appendAutoLineBreak("\n");
|
||||
}
|
||||
|
||||
children.add(tableView);
|
||||
}
|
||||
|
||||
private void appendOrderedList(Node node) {
|
||||
pushNode(node);
|
||||
int ordinal = StringUtils.toInt(node.attr("start")).orElse(1);
|
||||
for (Node childNode : node.childNodes()) {
|
||||
if (childNode.nameIs("li")) {
|
||||
ordinal = StringUtils.toInt(childNode.attr("value")).orElse(ordinal);
|
||||
appendAutoLineBreak("\n");
|
||||
appendText(" " + " ".repeat(listDepth) + ordinal++ + ". ");
|
||||
appendChildren(childNode);
|
||||
continue;
|
||||
}
|
||||
appendNode(childNode);
|
||||
}
|
||||
popNode();
|
||||
}
|
||||
|
||||
private void appendChildren(Node node) {
|
||||
if (node.childNodeSize() > 0) {
|
||||
if (node.nameIs("table")) {
|
||||
appendTable(node);
|
||||
} else if (node.nameIs("ol")) {
|
||||
appendOrderedList(node);
|
||||
} else {
|
||||
pushNode(node);
|
||||
for (Node childNode : node.childNodes()) {
|
||||
appendNode(childNode);
|
||||
}
|
||||
popNode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HTMLRenderer appendNode(Node node) {
|
||||
if (node instanceof TextNode n) {
|
||||
appendText(preformatted ? n.getWholeText() : normaliseWhitespace(n.getWholeText()));
|
||||
}
|
||||
|
||||
String name = node.nodeName();
|
||||
switch (name) {
|
||||
case "img":
|
||||
case "img" -> {
|
||||
if (!children.isEmpty())
|
||||
appendAutoLineBreak("\n");
|
||||
appendImage(node);
|
||||
break;
|
||||
case "li":
|
||||
appendText("\n" + INDENT.repeat(indentLevel) + " \u2022 ");
|
||||
break;
|
||||
case "dt":
|
||||
appendText(" ");
|
||||
break;
|
||||
case "p":
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
case "tr":
|
||||
appendAutoLineBreak("\n");
|
||||
}
|
||||
case "li" -> {
|
||||
appendAutoLineBreak("\n");
|
||||
appendText(" " + " ".repeat(listDepth) + "\u2022 ");
|
||||
}
|
||||
case "dt" -> appendText(" ");
|
||||
case "p" -> {
|
||||
var n = node.parent();
|
||||
if (!children.isEmpty() && (n == null || !n.nameIs("li")))
|
||||
appendAutoLineBreak("\n\n");
|
||||
}
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6" -> {
|
||||
if (!children.isEmpty())
|
||||
appendAutoLineBreak("\n\n");
|
||||
break;
|
||||
}
|
||||
case "hr" -> {
|
||||
appendAutoLineBreak("\n\n");
|
||||
this.children.add(new Separator());
|
||||
}
|
||||
}
|
||||
|
||||
if (node.childNodeSize() > 0) {
|
||||
boolean isLiNode = "li".equals(name);
|
||||
if (isLiNode) indentLevel++;
|
||||
pushNode(node);
|
||||
for (Node childNode : node.childNodes()) {
|
||||
appendNode(childNode);
|
||||
}
|
||||
popNode();
|
||||
if (isLiNode) indentLevel--;
|
||||
}
|
||||
appendChildren(node);
|
||||
|
||||
switch (name) {
|
||||
case "br":
|
||||
case "dd":
|
||||
case "p":
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
appendAutoLineBreak("\n");
|
||||
break;
|
||||
case "br", "dd", "h1", "h2", "h3", "h4", "h5", "h6" -> appendAutoLineBreak("\n");
|
||||
case "p" -> {
|
||||
var n = node.parent();
|
||||
if (n == null || !n.nameIs("li"))
|
||||
appendAutoLineBreak("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static boolean isSpacing(String text) {
|
||||
@@ -276,7 +485,7 @@ public final class HTMLRenderer {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void mergeLineBreaks() {
|
||||
public HTMLRenderer mergeLineBreaks() {
|
||||
for (int i = 0; i < this.children.size(); i++) {
|
||||
javafx.scene.Node child = this.children.get(i);
|
||||
if (child instanceof AutoLineBreak) {
|
||||
@@ -287,7 +496,7 @@ public final class HTMLRenderer {
|
||||
|
||||
if (otherChild instanceof AutoLineBreak) {
|
||||
lastAutoLineBreak = j;
|
||||
} else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) {
|
||||
} else if (otherChild instanceof Text txt && isSpacing(txt.getText())) {
|
||||
// do nothing
|
||||
} else {
|
||||
break;
|
||||
@@ -303,12 +512,58 @@ public final class HTMLRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Remove empty lines at the beginning
|
||||
int size = children.size();
|
||||
for (int i = 0; i < size; i++) {
|
||||
var child = children.get(i);
|
||||
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
|
||||
// NO-OP
|
||||
} else {
|
||||
this.children.subList(0, i).clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// Remove empty lines and spaces at the end
|
||||
int size = children.size();
|
||||
for (int i = size - 1; i > -1; i--) {
|
||||
var child = children.get(i);
|
||||
if (child instanceof AutoLineBreak || child instanceof Text txt && isSpacing(txt.getText())) {
|
||||
// NO-OP
|
||||
} else {
|
||||
this.children.subList(i + 1, size).clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public TextFlow render() {
|
||||
if (rendered) throw new IllegalStateException("Should not render twice");
|
||||
rendered = true;
|
||||
TextFlow textFlow = new TextFlow();
|
||||
textFlow.getStyleClass().add("html");
|
||||
textFlow.getChildren().setAll(children);
|
||||
for (javafx.scene.Node node : children) {
|
||||
if (node instanceof ImagePane imgPane) {
|
||||
ImageView img = imgPane.imageView;
|
||||
InvalidationListener listener = __ -> img.setFitWidth(Math.min(textFlow.getWidth() * 0.9, img.getImage() == null ? 0D : img.getImage().getWidth()));
|
||||
textFlow.widthProperty().addListener(listener);
|
||||
img.imageProperty().addListener(listener);
|
||||
listener.invalidated(null);
|
||||
} else if (node instanceof TableView<?> table) {
|
||||
table.prefWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
|
||||
} else if (node instanceof CodeFlow codeFlow) {
|
||||
codeFlow.maxWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
|
||||
} else if (node instanceof Separator separator) {
|
||||
separator.prefWidthProperty().bind(textFlow.widthProperty().subtract(24));
|
||||
}
|
||||
}
|
||||
return textFlow;
|
||||
}
|
||||
|
||||
@@ -317,4 +572,62 @@ public final class HTMLRenderer {
|
||||
super(text);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ImagePane extends StackPane {
|
||||
|
||||
public final ImageView imageView;
|
||||
|
||||
public ImagePane(ImageView imageView, @Nullable String alt) {
|
||||
super();
|
||||
this.imageView = imageView;
|
||||
|
||||
var txt = new Text(alt);
|
||||
if (StringUtils.isNotBlank(alt)) getChildren().add(txt);
|
||||
getChildren().add(imageView);
|
||||
FXUtils.onChangeAndOperate(imageView.imageProperty(), img -> {
|
||||
if (img != null) getChildren().remove(txt);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class CodeFlow extends TextFlow {
|
||||
|
||||
public CodeFlow(Text text) {
|
||||
super(text);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class SimpleCssDeclarations {
|
||||
|
||||
@Unmodifiable
|
||||
private static final Map<String, String> cssPropertyMapping = Map.of(
|
||||
"color", "-fx-fill",
|
||||
"font-size", "-fx-font-size"
|
||||
);
|
||||
|
||||
private final Map<String, String> declarations = new HashMap<>();
|
||||
|
||||
public void add(String inlineCss) {
|
||||
if (StringUtils.isBlank(inlineCss)) return;
|
||||
Arrays.stream(inlineCss.split(";")).filter(StringUtils::isNotBlank).forEach(s -> {
|
||||
String[] declaration = s.split(":", 2);
|
||||
if (declaration.length != 2) return;
|
||||
declaration[0] = declaration[0].trim();
|
||||
declaration[1] = declaration[1].trim();
|
||||
String mappedKey = cssPropertyMapping.get(declaration[0].toLowerCase(Locale.ROOT));
|
||||
if (mappedKey != null) {
|
||||
declarations.put(mappedKey, declaration[1]);
|
||||
} // Ignore unsupported fields
|
||||
});
|
||||
}
|
||||
|
||||
public String asString() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
declarations.forEach((property, value) -> sb.append(property).append(": ").append(value).append(";"));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,26 +34,22 @@ import javafx.scene.control.Skin;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.ui.construct.ImageContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/**
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public class InstallerItem extends Control {
|
||||
private final String id;
|
||||
private final GameComponentType type;
|
||||
private final GameInstanceIconType iconType;
|
||||
private final Style style;
|
||||
private final ObjectProperty<InstalledState> versionProperty = new SimpleObjectProperty<>(this, "version", null);
|
||||
@@ -83,30 +79,14 @@ public class InstallerItem extends Control {
|
||||
CARD,
|
||||
}
|
||||
|
||||
public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) {
|
||||
this(id.getPatchId(), style);
|
||||
}
|
||||
|
||||
public InstallerItem(String id, Style style) {
|
||||
this.id = id;
|
||||
public InstallerItem(GameComponentType type, Style style) {
|
||||
this.type = type;
|
||||
this.style = style;
|
||||
|
||||
iconType = switch (id) {
|
||||
case "game" -> GameInstanceIconType.GRASS;
|
||||
case "fabric", "fabric-api" -> GameInstanceIconType.FABRIC;
|
||||
case "legacyfabric", "legacyfabric-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 "neoforge" -> GameInstanceIconType.NEO_FORGE;
|
||||
default -> null;
|
||||
};
|
||||
this.iconType = GameInstanceIconType.getIconType(type);
|
||||
}
|
||||
|
||||
public String getLibraryId() {
|
||||
return id;
|
||||
public GameComponentType getComponentType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public ObjectProperty<InstalledState> versionProperty() {
|
||||
@@ -149,7 +129,7 @@ public class InstallerItem extends Control {
|
||||
public final static class InstallerItemGroup {
|
||||
private final InstallerItem game;
|
||||
|
||||
private final InstallerItem[] libraries;
|
||||
private final InstallerItem[] components;
|
||||
|
||||
private Set<InstallerItem> getIncompatibles(Map<InstallerItem, Set<InstallerItem>> incompatibleMap, InstallerItem item) {
|
||||
return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>());
|
||||
@@ -175,19 +155,19 @@ public class InstallerItem extends Control {
|
||||
}
|
||||
}
|
||||
|
||||
public InstallerItemGroup(String gameVersion, Style style) {
|
||||
game = new InstallerItem(MINECRAFT, style);
|
||||
InstallerItem fabric = new InstallerItem(FABRIC, style);
|
||||
InstallerItem fabricApi = new InstallerItem(FABRIC_API, style);
|
||||
InstallerItem forge = new InstallerItem(FORGE, style);
|
||||
InstallerItem cleanroom = new InstallerItem(CLEANROOM, style);
|
||||
InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style);
|
||||
InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style);
|
||||
InstallerItem neoForge = new InstallerItem(NEO_FORGE, style);
|
||||
InstallerItem liteLoader = new InstallerItem(LITELOADER, style);
|
||||
InstallerItem optiFine = new InstallerItem(OPTIFINE, style);
|
||||
InstallerItem quilt = new InstallerItem(QUILT, style);
|
||||
InstallerItem quiltApi = new InstallerItem(QUILT_API, style);
|
||||
public InstallerItemGroup(GameVersionNumber gameVersion, Style style) {
|
||||
game = new InstallerItem(GameComponentType.GAME, style);
|
||||
InstallerItem fabric = new InstallerItem(GameComponentType.FABRIC, style);
|
||||
InstallerItem fabricApi = new InstallerItem(GameComponentType.FABRIC_API, style);
|
||||
InstallerItem forge = new InstallerItem(GameComponentType.FORGE, style);
|
||||
InstallerItem cleanroom = new InstallerItem(GameComponentType.CLEANROOM, style);
|
||||
InstallerItem legacyfabric = new InstallerItem(GameComponentType.LEGACY_FABRIC, style);
|
||||
InstallerItem legacyfabricApi = new InstallerItem(GameComponentType.LEGACY_FABRIC_API, style);
|
||||
InstallerItem neoForge = new InstallerItem(GameComponentType.NEO_FORGE, style);
|
||||
InstallerItem liteLoader = new InstallerItem(GameComponentType.LITELOADER, style);
|
||||
InstallerItem optiFine = new InstallerItem(GameComponentType.OPTIFINE, style);
|
||||
InstallerItem quilt = new InstallerItem(GameComponentType.QUILT, style);
|
||||
InstallerItem quiltApi = new InstallerItem(GameComponentType.QUILT_API, style);
|
||||
|
||||
Map<InstallerItem, Set<InstallerItem>> incompatibleMap = new HashMap<>();
|
||||
mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric);
|
||||
@@ -217,7 +197,7 @@ public class InstallerItem extends Control {
|
||||
for (InstallerItem other : incompatibleItems) {
|
||||
InstalledState otherVersion = other.versionProperty.get();
|
||||
if (otherVersion != null) {
|
||||
return new IncompatibleState(other.id, otherVersion.version);
|
||||
return new IncompatibleState(other.type.getPatchId(), otherVersion.version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +206,7 @@ public class InstallerItem extends Control {
|
||||
}
|
||||
|
||||
if (gameVersion != null) {
|
||||
game.versionProperty.set(new InstalledState(gameVersion, false, false));
|
||||
game.versionProperty.set(new InstalledState(gameVersion.toString(), false, false));
|
||||
}
|
||||
|
||||
InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom};
|
||||
@@ -235,22 +215,19 @@ public class InstallerItem extends Control {
|
||||
if (!item.resolvedStateProperty.isBound()) {
|
||||
item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> {
|
||||
InstalledState itemVersion = item.versionProperty.get();
|
||||
if (itemVersion != null) {
|
||||
return itemVersion;
|
||||
}
|
||||
return InstallableState.INSTANCE;
|
||||
return Objects.requireNonNullElse(itemVersion, InstallableState.INSTANCE);
|
||||
}, item.versionProperty));
|
||||
}
|
||||
}
|
||||
|
||||
if (gameVersion == null) {
|
||||
this.libraries = all;
|
||||
} else if (gameVersion.equals("1.12.2")) {
|
||||
this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
|
||||
} else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) {
|
||||
this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
|
||||
this.components = all;
|
||||
} else if (gameVersion.compareTo("1.12.2") == 0) {
|
||||
this.components = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine};
|
||||
} else if (gameVersion.compareTo("1.13.2") <= 0) {
|
||||
this.components = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi};
|
||||
} else {
|
||||
this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
|
||||
this.components = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +235,8 @@ public class InstallerItem extends Control {
|
||||
return game;
|
||||
}
|
||||
|
||||
public InstallerItem[] getLibraries() {
|
||||
return libraries;
|
||||
public InstallerItem[] getComponents() {
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +286,7 @@ public class InstallerItem extends Control {
|
||||
nameLabel.getStyleClass().add("installer-item-name");
|
||||
nameLabel.setMouseTransparent(true);
|
||||
pane.getChildren().add(nameLabel);
|
||||
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id);
|
||||
nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.type.getPatchId()) ? i18n("install.installer." + control.type.getPatchId()) : control.type.getPatchId());
|
||||
HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4));
|
||||
|
||||
Label statusLabel = new Label();
|
||||
@@ -355,7 +332,7 @@ public class InstallerItem extends Control {
|
||||
pane.getChildren().add(buttonsContainer);
|
||||
|
||||
JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE);
|
||||
if (control.id.equals(MINECRAFT.getPatchId())) {
|
||||
if (control.type == GameComponentType.GAME) {
|
||||
removeButton.setVisible(false);
|
||||
} else {
|
||||
removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> {
|
||||
|
||||
@@ -40,7 +40,6 @@ import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.util.CircularArrayList;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
@@ -52,10 +51,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
@@ -104,6 +100,8 @@ public final class LogWindow extends Stage {
|
||||
}
|
||||
|
||||
this.gameProcess = gameProcess;
|
||||
|
||||
FXUtils.addMacOSCloseWindowHandler(this, () -> !gameProcess.isRunning());
|
||||
}
|
||||
|
||||
public void logLine(Log log) {
|
||||
@@ -324,7 +322,7 @@ public final class LogWindow extends Stage {
|
||||
listView.scrollTo(listView.getItems().size() - 1);
|
||||
});
|
||||
|
||||
listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
|
||||
listView.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)
|
||||
+ "\"; -fx-font-size: " + settings().logFontSizeProperty().get() + "px;");
|
||||
listView.setCellFactory(x -> new ListCell<>() {
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
package org.jackhuang.hmcl.ui;
|
||||
|
||||
import com.sun.jna.Pointer;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.util.platform.macos.ObjectiveCRuntime;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -56,8 +58,30 @@ public final class MacOSNativeUtils {
|
||||
return pointer == null || Pointer.nativeValue(pointer) == 0;
|
||||
}
|
||||
|
||||
private static final ReadOnlyBooleanProperty autoBrightness = Themes.autoBrightnessProperty();
|
||||
|
||||
static {
|
||||
FXUtils.onChange(autoBrightness, b -> setAppearance(Themes.darkModeProperty().get()));
|
||||
}
|
||||
|
||||
public static void setAppearance(boolean dark) {
|
||||
setAppearance(dark, false);
|
||||
if (autoBrightness.get()) {
|
||||
clearAppearance();
|
||||
} else {
|
||||
setAppearance(dark, false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearAppearance() {
|
||||
if (nsApp == null) return;
|
||||
|
||||
try {
|
||||
var objc = ObjectiveCRuntime.INSTANCE;
|
||||
Pointer setSel = objc.sel_registerName("setAppearance:");
|
||||
objc.objc_msgSend(nsApp, setSel, Pointer.NULL);
|
||||
} catch (Throwable t) {
|
||||
LOG.warning("Failed to set macOS appearance", t);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setAppearance(boolean dark, boolean highContrast) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import javafx.scene.shape.SVGPath;
|
||||
/// All vector icons used in the launcher.
|
||||
///
|
||||
/// Unless otherwise stated,
|
||||
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.query=list&icon.set=Material+Symbols">Material Symbols</a>,
|
||||
/// these icons are from <a href="https://fonts.google.com/icons?icon.size=24&icon.color=%235f6368&icon.set=Material+Symbols">Material Symbols</a>,
|
||||
/// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
|
||||
/// The view boxes of all icons are normalized to `0 0 24 24`.
|
||||
public enum SVG {
|
||||
@@ -49,6 +49,7 @@ public enum SVG {
|
||||
CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"),
|
||||
CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"),
|
||||
CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"),
|
||||
DATE_RANGE("M7.2875 13.7125Q7 13.425 7 13t0.2875 -0.7125Q7.575 12 8 12t0.7125 0.2875Q9 12.575 9 13t-0.2875 0.7125Q8.425 14 8 14t-0.7125 -0.2875zm4 0Q11 13.425 11 13t0.2875 -0.7125Q11.575 12 12 12t0.7125 0.2875Q13 12.575 13 13t-0.2875 0.7125Q12.425 14 12 14t-0.7125 -0.2875zm4 0Q15 13.425 15 13t0.2875 -0.7125Q15.575 12 16 12t0.7125 0.2875Q17 12.575 17 13t-0.2875 0.7125Q16.425 14 16 14t-0.7125 -0.2875zM5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 14q0 0.825 -0.5875 1.4125T19 22L5 22zm0 -2l14 0l0 -10L5 10l0 10zm0 -12l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2z"),
|
||||
DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"),
|
||||
DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"),
|
||||
DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"),
|
||||
@@ -56,6 +57,7 @@ public enum SVG {
|
||||
DOWNLOAD("M12 16 7 11 8.4 9.55 11 12.15V4H13V12.15L15.6 9.55 17 11 12 16ZM6 20Q5.175 20 4.5875 19.4125T4 18V15H6V18H18V15H20V18Q20 18.825 19.4125 19.4125T18 20H6Z"),
|
||||
DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"),
|
||||
EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"),
|
||||
EDIT_CALENDAR("M5 22q-0.825 0 -1.4125 -0.5875T3 20l0 -14q0 -0.825 0.5875 -1.4125T5 4l1 0l0 -2l2 0l0 2l8 0l0 -2l2 0l0 2l1 0q0.825 0 1.4125 0.5875T21 6l0 5l-2 0l0 -1L5 10l0 10l7 0l0 2L5 22zm0 -14l14 0l0 -2L5 6l0 2zm0 0l0 -2 0 2zM14 22l0 -3.075l5.525 -5.5q0.225 -0.225 0.5 -0.325t0.55 -0.1q0.3 0 0.575 0.1125t0.5 0.3375l0.925 0.925q0.2 0.225 0.3125 0.5t0.1125 0.55q0 0.275 -0.1 0.5625T22.575 16.5L17.075 22L14 22zm7.5 -6.575 -0.925 -0.925 0.925 0.925zM15.5 20.5l0.95 0l3.025 -3.05 -0.45 -0.475 -0.475 -0.45 -3.05 3.025l0 0.95zm3.525 -3.525 -0.475 -0.45 0.925 0.925 -0.45 -0.475z"),
|
||||
ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
|
||||
EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"),
|
||||
EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"),
|
||||
@@ -109,6 +111,7 @@ public enum SVG {
|
||||
SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"),
|
||||
SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"),
|
||||
SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"),
|
||||
SCREENSHOT_MONITOR_FILL("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8Z"),
|
||||
SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material
|
||||
SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"),
|
||||
SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"),
|
||||
|
||||
@@ -23,6 +23,7 @@ import javafx.animation.Timeline;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.ui.animation.AnimationUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
|
||||
/// A lightweight wrapper for displaying [SVG] icons.
|
||||
@@ -108,7 +109,7 @@ public final class SVGContainer extends Parent {
|
||||
SVG oldIcon = this.icon;
|
||||
this.icon = newIcon;
|
||||
|
||||
if (animationDuration.equals(Duration.ZERO)) {
|
||||
if (animationDuration.equals(Duration.ZERO) || !AnimationUtils.isAnimationEnabled()) {
|
||||
path.setContent(newIcon.getPath());
|
||||
path.setOpacity(1);
|
||||
if (getChildren().size() != 1)
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXSpinner;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
|
||||
@@ -34,6 +35,7 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.nodes.Node;
|
||||
|
||||
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
|
||||
@@ -47,7 +49,9 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
maxWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
|
||||
|
||||
setHeading(new Label(i18n("update.changelog")));
|
||||
Label heading = new Label(i18n("update.changelog"));
|
||||
heading.setPadding(new Insets(0, 0, 8, 0));
|
||||
setHeading(heading);
|
||||
setBody(new JFXSpinner());
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
|
||||
@@ -62,16 +66,18 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
LOG.warning("Changelog not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
FXUtils.openLink(uri.toString());
|
||||
});
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
LOG.warning("Changelog not found");
|
||||
Element list = new Element("ul");
|
||||
list.appendElement("li").text(i18n("update.changelog.empty"));
|
||||
return renderer.appendNode(list).mergeLineBreaks().render();
|
||||
}
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName())) {
|
||||
String changelogVersion = node.attr("data-version");
|
||||
|
||||
@@ -47,11 +47,7 @@ public final class WebPage extends SpinnerPane implements DecoratorPage {
|
||||
|
||||
Task.supplyAsync(() -> {
|
||||
Document document = Jsoup.parseBodyFragment(content);
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> {
|
||||
FXUtils.openLink(uri.toString());
|
||||
}, null);
|
||||
});
|
||||
HTMLRenderer renderer = new HTMLRenderer(Controllers::openUriOrCopy);
|
||||
renderer.appendNode(document);
|
||||
renderer.mergeLineBreaks();
|
||||
return renderer.render();
|
||||
|
||||
@@ -18,13 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -47,7 +41,6 @@ import org.jackhuang.hmcl.ui.construct.ClassTitle;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
@@ -56,7 +49,6 @@ import java.util.Locale;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.userSettings;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
|
||||
@@ -173,13 +165,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
|
||||
|
||||
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
|
||||
item.titleProperty().bind(title);
|
||||
String host = "";
|
||||
try {
|
||||
host = NetworkUtils.toURI(server.getUrl()).getHost();
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e);
|
||||
}
|
||||
item.subtitleProperty().set(host);
|
||||
item.setSubtitle(server.getDisplayHostUrl());
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
|
||||
FXUtils.installFastTooltip(item, tooltip);
|
||||
|
||||
@@ -67,11 +67,10 @@ import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
import static javafx.beans.binding.Bindings.bindContent;
|
||||
import static javafx.beans.binding.Bindings.createBooleanBinding;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor;
|
||||
|
||||
public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$");
|
||||
@@ -388,20 +387,22 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
add(lblServers, 0, rowIndex);
|
||||
|
||||
cboServers = new JFXComboBox<>();
|
||||
cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl())));
|
||||
cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName));
|
||||
cboServers.setConverter(stringConverter(it -> String.format("%s (%s)", it.getName(), it.getDisplayHostUrl())));
|
||||
bindContent(cboServers.getItems(), getAuthlibInjectorServers());
|
||||
cboServers.getItems().addListener(onInvalidating(
|
||||
() -> Platform.runLater( // the selection will not be updated as expected if we call it immediately
|
||||
cboServers.getSelectionModel()::selectFirst)));
|
||||
cboServers.getSelectionModel().selectFirst();
|
||||
cboServers.setPromptText(i18n("account.injector.empty"));
|
||||
|
||||
Label noServersLabel = new Label(i18n("account.injector.empty"));
|
||||
BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems());
|
||||
classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers);
|
||||
classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not());
|
||||
|
||||
HBox.setHgrow(cboServers, Priority.ALWAYS);
|
||||
HBox.setMargin(cboServers, new Insets(0, 10, 0, 0));
|
||||
cboServers.setMaxWidth(Double.MAX_VALUE);
|
||||
HBox.setHgrow(noServersLabel, Priority.ALWAYS);
|
||||
HBox.setMargin(noServersLabel, new Insets(0, 10, 0, 0));
|
||||
noServersLabel.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
HBox linksContainer = new HBox();
|
||||
linksContainer.setAlignment(Pos.CENTER);
|
||||
@@ -419,7 +420,13 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
Controllers.dialog(new AddAuthlibInjectorServerPane());
|
||||
});
|
||||
|
||||
HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer);
|
||||
noServersLabel.visibleProperty().bind(noServers);
|
||||
noServersLabel.managedProperty().bind(noServers);
|
||||
cboServers.visibleProperty().bind(noServers.not());
|
||||
cboServers.managedProperty().bind(noServers.not());
|
||||
|
||||
HBox boxServers = new HBox(cboServers, noServersLabel, linksContainer, btnAddServer);
|
||||
boxServers.setAlignment(Pos.CENTER_LEFT);
|
||||
add(boxServers, 1, rowIndex);
|
||||
|
||||
rowIndex++;
|
||||
|
||||
@@ -29,9 +29,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.auth.AuthInfo;
|
||||
@@ -49,10 +47,10 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.QrCodeUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -141,20 +139,22 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
|
||||
setBody(rootContainer);
|
||||
rootContainer.setAlignment(Pos.TOP_CENTER);
|
||||
|
||||
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
|
||||
var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
StackPane warningPane = new StackPane();
|
||||
|
||||
if (!IntegrityChecker.isOfficial()) {
|
||||
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
unofficialHintPane.setSegment(i18n("unofficial.hint"));
|
||||
warningPane.getChildren().add(unofficialHintPane);
|
||||
} else if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
|
||||
HintPane snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot"));
|
||||
rootContainer.getChildren().add(snapshotHint);
|
||||
warningPane.getChildren().add(snapshotHint);
|
||||
btnLogin.setDisable(true);
|
||||
loginButtonSpinner.setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IntegrityChecker.isOfficial()) {
|
||||
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
|
||||
unofficialHintPane.setSegment(i18n("unofficial.hint"));
|
||||
rootContainer.getChildren().add(unofficialHintPane);
|
||||
}
|
||||
rootContainer.getChildren().add(warningPane);
|
||||
|
||||
if (currentStep instanceof Step.Init) {
|
||||
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
|
||||
@@ -198,6 +198,20 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
|
||||
} else if (currentStep instanceof Step.WaitForScanQrCode wait) {
|
||||
loginButtonSpinner.setLoading(true);
|
||||
|
||||
VBox leftVBox = new VBox(6);
|
||||
leftVBox.setAlignment(Pos.CENTER);
|
||||
VBox rightVBox = new VBox(6);
|
||||
|
||||
HBox hbox = new HBox(leftVBox, rightVBox);
|
||||
|
||||
leftVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
|
||||
rightVBox.prefWidthProperty().bind(hbox.widthProperty().divide(2));
|
||||
leftVBox.setMaxWidth(Double.MAX_VALUE);
|
||||
rightVBox.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
HBox.setHgrow(leftVBox, Priority.ALWAYS);
|
||||
HBox.setHgrow(rightVBox, Priority.ALWAYS);
|
||||
|
||||
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
|
||||
? "https://www.microsoft.com/link?otc=" + wait.userCode()
|
||||
: wait.verificationUri();
|
||||
@@ -217,7 +231,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
|
||||
|
||||
var lblCode = new Label(wait.userCode());
|
||||
lblCode.getStyleClass().add("code-label");
|
||||
lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
|
||||
lblCode.setStyle("-fx-font-family: \"" + Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
|
||||
|
||||
var codeBox = new StackPane(lblCode);
|
||||
codeBox.getStyleClass().add("code-box");
|
||||
@@ -225,7 +239,10 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
|
||||
FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode()));
|
||||
codeBox.setMaxWidth(USE_PREF_SIZE);
|
||||
|
||||
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
|
||||
leftVBox.getChildren().setAll(new Group(qrCode), codeBox);
|
||||
rightVBox.getChildren().setAll(warningPane, deviceHint);
|
||||
|
||||
rootContainer.getChildren().setAll(hbox);
|
||||
} else if (currentStep instanceof Step.DeviceLoginCompleted) {
|
||||
loginButtonSpinner.setLoading(true);
|
||||
|
||||
|
||||
@@ -19,15 +19,11 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXProgressBar;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -38,18 +34,11 @@ public class DialogPane extends JFXDialogLayout {
|
||||
protected final SpinnerPane acceptPane = new SpinnerPane();
|
||||
protected final JFXButton cancelButton = new JFXButton();
|
||||
protected final Label warningLabel = new Label();
|
||||
private final JFXProgressBar progressBar = new JFXProgressBar();
|
||||
|
||||
public DialogPane() {
|
||||
Label titleLabel = new Label();
|
||||
titleLabel.textProperty().bind(title);
|
||||
setHeading(titleLabel);
|
||||
getChildren().add(progressBar);
|
||||
|
||||
progressBar.setVisible(false);
|
||||
StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D));
|
||||
StackPane.setAlignment(progressBar, Pos.TOP_CENTER);
|
||||
progressBar.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
JFXButton acceptButton = new JFXButton(i18n("button.ok"));
|
||||
acceptButton.setOnAction(e -> onAccept());
|
||||
@@ -66,10 +55,6 @@ public class DialogPane extends JFXDialogLayout {
|
||||
setActions(warningLabel, acceptPane, cancelButton);
|
||||
}
|
||||
|
||||
protected JFXProgressBar getProgressBar() {
|
||||
return progressBar;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title.get();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class FontComboBox extends JFXComboBox<String> {
|
||||
|
||||
styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\""));
|
||||
|
||||
setCellFactory(listView -> new JFXListCell<String>() {
|
||||
setCellFactory(listView -> new JFXListCell<>() {
|
||||
@Override
|
||||
public void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
@@ -64,6 +64,33 @@ public class ImageContainer extends StackPane {
|
||||
this.getChildren().setAll(imageView);
|
||||
}
|
||||
|
||||
public ImageContainer(Image image, double maxWidth, double maxHeight) {
|
||||
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
|
||||
double width = maxWidth;
|
||||
double height = width / image.getWidth() * image.getHeight();
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height / image.getHeight() * image.getWidth();
|
||||
}
|
||||
|
||||
FXUtils.setLimitWidth(this, width);
|
||||
FXUtils.setLimitHeight(this, height);
|
||||
|
||||
imageView.setPreserveRatio(true);
|
||||
FXUtils.limitSize(imageView, width, height);
|
||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||
|
||||
clip.setWidth(width);
|
||||
clip.setHeight(height);
|
||||
updateCornerRadius(getCornerRadius());
|
||||
this.setClip(clip);
|
||||
|
||||
setImage(image);
|
||||
|
||||
this.getChildren().setAll(imageView);
|
||||
}
|
||||
|
||||
private void updateCornerRadius(double radius) {
|
||||
clip.setArcWidth(radius);
|
||||
clip.setArcHeight(radius);
|
||||
|
||||
@@ -21,15 +21,18 @@ import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public abstract class MDListCell<T> extends ListCell<T> {
|
||||
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
|
||||
private static final PseudoClass LAST = PseudoClass.getPseudoClass("last");
|
||||
|
||||
private final StackPane container = new StackPane();
|
||||
private final StackPane root = new StackPane();
|
||||
private final RipplerContainer ripplerContainer = new RipplerContainer(container);
|
||||
|
||||
public MDListCell(JFXListView<T> listView) {
|
||||
|
||||
@@ -37,7 +40,6 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
setGraphic(null);
|
||||
|
||||
root.getStyleClass().add("md-list-cell");
|
||||
RipplerContainer ripplerContainer = new RipplerContainer(container);
|
||||
root.getChildren().setAll(ripplerContainer);
|
||||
|
||||
Region clippedContainer = (Region) listView.lookup(".clipped-container");
|
||||
@@ -58,14 +60,32 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
if (oldItem == item && oldEmpty == empty) {
|
||||
updateLastPseudoClass(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
updateControl(item, empty);
|
||||
if (empty) {
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(root);
|
||||
}
|
||||
updateLastPseudoClass(empty || item == null);
|
||||
}
|
||||
|
||||
/// Updates the `:last` pseudo-class so the trailing divider can be suppressed in CSS.
|
||||
///
|
||||
/// @param empty whether this cell is empty
|
||||
private void updateLastPseudoClass(boolean empty) {
|
||||
ListView<T> listView = getListView();
|
||||
boolean last = !empty
|
||||
&& listView != null
|
||||
&& getIndex() >= 0
|
||||
&& getIndex() == listView.getItems().size() - 1;
|
||||
root.pseudoClassStateChanged(LAST, last);
|
||||
}
|
||||
|
||||
protected StackPane getContainer() {
|
||||
@@ -78,5 +98,9 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
});
|
||||
}
|
||||
|
||||
protected void onClicked(Runnable action) {
|
||||
FXUtils.onClicked(ripplerContainer, action);
|
||||
}
|
||||
|
||||
protected abstract void updateControl(T item, boolean empty);
|
||||
}
|
||||
@@ -205,6 +205,10 @@ public class RipplerContainer extends StackPane {
|
||||
ripplerFillProperty().set(ripplerFill);
|
||||
}
|
||||
|
||||
public void releaseRippleImmediately() {
|
||||
buttonRippler.releaseRippleImmediately();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
|
||||
return getClassCssMetaData();
|
||||
|
||||
@@ -27,6 +27,7 @@ import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
@@ -113,7 +114,15 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
executor.addTaskListener(new TaskListener() {
|
||||
@Override
|
||||
public void onStop(boolean success, TaskExecutor executor) {
|
||||
Platform.runLater(() -> fireEvent(new DialogCloseEvent()));
|
||||
Platform.runLater(() -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
|
||||
if (Controllers.AUTO_TRIM_HEAP) {
|
||||
// Trim heap memory when tasks finish, allowing GC to return memory to the OS faster,
|
||||
// prevent excessive peak memory from consuming system resources.
|
||||
Platform.runLater(Controllers::trimHeap);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,45 +33,16 @@ import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameInstallTask;
|
||||
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
|
||||
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
|
||||
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
|
||||
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
|
||||
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
|
||||
import org.jackhuang.hmcl.java.JavaInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Collection;
|
||||
@@ -162,58 +133,16 @@ public final class TaskListPane extends StackPane {
|
||||
if (!task.getSignificance().shouldShow() || task.getName() == null)
|
||||
return;
|
||||
|
||||
if (task instanceof GameAssetDownloadTask) {
|
||||
task.setName(i18n("assets.download_all"));
|
||||
} else if (task instanceof GameInstallTask) {
|
||||
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
|
||||
return;
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
|
||||
} else if (task instanceof CleanroomInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
|
||||
} else if (task instanceof LegacyFabricInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
|
||||
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
|
||||
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.neoforge")));
|
||||
} else if (task instanceof LiteLoaderInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.liteloader")));
|
||||
} else if (task instanceof OptiFineInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.optifine")));
|
||||
} else if (task instanceof FabricInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.fabric")));
|
||||
} else if (task instanceof FabricAPIInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api")));
|
||||
} else if (task instanceof QuiltInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.quilt")));
|
||||
} else if (task instanceof QuiltAPIInstallTask) {
|
||||
task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api")));
|
||||
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
|
||||
task.setName(i18n("modpack.completion"));
|
||||
} else if (task instanceof ModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing"));
|
||||
} else if (task instanceof ModpackUpdateTask) {
|
||||
task.setName(i18n("modpack.update"));
|
||||
} else if (task instanceof CurseInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
|
||||
} else if (task instanceof MultiMCModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
|
||||
} else if (task instanceof ModrinthInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
|
||||
} else if (task instanceof ServerModpackLocalInstallTask) {
|
||||
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
|
||||
} else if (task instanceof HMCLModpackInstallTask) {
|
||||
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
|
||||
} else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
|
||||
task.setName(i18n("modpack.export"));
|
||||
} else if (task instanceof MinecraftInstanceTask) {
|
||||
task.setName(i18n("modpack.scan"));
|
||||
} else if (task instanceof MojangJavaDownloadTask) {
|
||||
task.setName(i18n("download.java"));
|
||||
} else if (task instanceof JavaInstallTask) {
|
||||
task.setName(i18n("java.installing"));
|
||||
if (task instanceof GameInstallTask
|
||||
&& task.getInheritedStage() != null
|
||||
&& task.getInheritedStage().startsWith("hmcl.install.game")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@Nullable String localizedName = I18n.translateTaskName(task);
|
||||
if (localizedName != null)
|
||||
task.setName(localizedName);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
ProgressListNode node = new ProgressListNode(task);
|
||||
nodes.put(task, node);
|
||||
|
||||
@@ -24,12 +24,21 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.OverrunStyle;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// Displays a title with optional tags and a subtitle.
|
||||
public class TwoLineListItem extends VBox {
|
||||
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
|
||||
|
||||
@@ -39,14 +48,28 @@ public class TwoLineListItem extends VBox {
|
||||
private final Label lblTitle;
|
||||
private Label lblSubtitle;
|
||||
|
||||
/// Creates an empty item whose children do not receive mouse events.
|
||||
public TwoLineListItem() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
setMouseTransparent(true);
|
||||
|
||||
lblTitle = new Label();
|
||||
lblTitle.getStyleClass().add("title");
|
||||
lblTitle.setTextOverrun(OverrunStyle.ELLIPSIS);
|
||||
|
||||
this.firstLine = new HBox(lblTitle);
|
||||
this.firstLine = new HBox(lblTitle) {
|
||||
/// Includes the tags' natural width when requesting space from the parent.
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
double width = super.computePrefWidth(height);
|
||||
for (Node child : getManagedChildren()) {
|
||||
if (child instanceof TagsBox tagsBox) {
|
||||
width += snapSizeX(tagsBox.computeNaturalPrefWidth(height));
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
};
|
||||
firstLine.getStyleClass().add("first-line");
|
||||
firstLine.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
@@ -158,17 +181,29 @@ public class TwoLineListItem extends VBox {
|
||||
return lblSubtitle;
|
||||
}
|
||||
|
||||
private ObservableList<Label> tags;
|
||||
private static Label createTag(String tag, PseudoClass pseudoClass) {
|
||||
var tagLabel = new Label(tag);
|
||||
tagLabel.getStyleClass().add("tag");
|
||||
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
|
||||
if (pseudoClass != null)
|
||||
tagLabel.pseudoClassStateChanged(pseudoClass, true);
|
||||
return tagLabel;
|
||||
}
|
||||
|
||||
/// Stores the mutable tag list once tag support has been initialized.
|
||||
private @Nullable ObservableList<Label> tags;
|
||||
|
||||
/// Returns the mutable list of tags displayed after the title.
|
||||
public ObservableList<Label> getTags() {
|
||||
if (tags == null) {
|
||||
tags = FXCollections.observableArrayList();
|
||||
|
||||
var tagsBox = new HBox(8);
|
||||
var tagsBox = new TagsBox();
|
||||
tagsBox.getStyleClass().add("tags");
|
||||
tagsBox.setAlignment(Pos.CENTER_LEFT);
|
||||
tagsBox.setMinWidth(0);
|
||||
HBox.setHgrow(tagsBox, Priority.ALWAYS);
|
||||
|
||||
HBox.setHgrow(tagsBox, Priority.SOMETIMES);
|
||||
|
||||
Bindings.bindContent(tagsBox.getChildren(), tags);
|
||||
var isNotEmpty = Bindings.isNotEmpty(tags);
|
||||
tagsBox.managedProperty().bind(isNotEmpty);
|
||||
@@ -176,25 +211,53 @@ public class TwoLineListItem extends VBox {
|
||||
|
||||
FXUtils.setOverflowHidden(tagsBox);
|
||||
|
||||
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
|
||||
HBox.setHgrow(lblTitle, Priority.ALWAYS);
|
||||
lblTitle.setMinWidth(0);
|
||||
firstLine.getChildren().setAll(lblTitle, tagsBox);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// Reports its natural width separately from the width used to allocate space within the title row.
|
||||
@NotNullByDefault
|
||||
private static final class TagsBox extends HBox {
|
||||
|
||||
/// Creates a tag container with zero minimum and preferred allocation widths.
|
||||
private TagsBox() {
|
||||
super(8);
|
||||
// Start at zero during HBox allocation so tags do not compete with the title for space.
|
||||
setPrefWidth(0);
|
||||
setMinWidth(0);
|
||||
}
|
||||
|
||||
/// Returns the computed preferred width without applying the zero preferred-width override.
|
||||
///
|
||||
/// @param height the available height, or -1 if unspecified
|
||||
/// @return the natural preferred width of the tags and their container
|
||||
private double computeNaturalPrefWidth(double height) {
|
||||
return super.computePrefWidth(height);
|
||||
}
|
||||
}
|
||||
|
||||
public void addTag(String tag, PseudoClass pseudoClass) {
|
||||
var tagLabel = new Label(tag);
|
||||
tagLabel.getStyleClass().add("tag");
|
||||
tagLabel.setMinWidth(Label.USE_PREF_SIZE);
|
||||
if (pseudoClass != null)
|
||||
tagLabel.pseudoClassStateChanged(pseudoClass, true);
|
||||
getTags().add(tagLabel);
|
||||
getTags().add(createTag(tag, pseudoClass));
|
||||
}
|
||||
|
||||
public void addTag(String tag) {
|
||||
addTag(tag, null);
|
||||
}
|
||||
|
||||
public void addTags(Collection<String> tags) {
|
||||
getTags().addAll(tags.stream().map(tag -> createTag(tag, null)).toList());
|
||||
}
|
||||
|
||||
public void addTagsIfNotExist(Collection<String> tags) {
|
||||
var current = getTags().stream().map(Label::getText).collect(Collectors.toSet());
|
||||
var target = new LinkedHashSet<>(tags);
|
||||
target.removeAll(current);
|
||||
addTags(target);
|
||||
}
|
||||
|
||||
private static final PseudoClass WARNING_PSEUDO_CLASS = PseudoClass.getPseudoClass("warning");
|
||||
|
||||
public void addTagWarning(String tag) {
|
||||
|
||||
@@ -19,10 +19,13 @@ package org.jackhuang.hmcl.ui.decorator;
|
||||
|
||||
import com.jfoenix.controls.JFXSnackbar;
|
||||
import com.jfoenix.controls.JFXSnackbarLayout;
|
||||
import com.jfoenix.skins.JFXDatePickerContent;
|
||||
import com.jfoenix.skins.JFXDatePickerSkin;
|
||||
import javafx.animation.Interpolator;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
@@ -61,6 +64,7 @@ import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.DialogUtils;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -78,6 +82,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Stack;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
|
||||
/// Coordinates the main window's visual tree, navigation, dialogs, and native window behavior.
|
||||
@@ -129,6 +137,29 @@ public final class Decorator {
|
||||
/// The navigation stack rendered in the main window.
|
||||
private final Navigator navigator = new Navigator();
|
||||
|
||||
/// Reflective access to system decoration, or `null` on unsupported runtimes.
|
||||
private final @Nullable NativeWindowDecoration nativeDecoration = NativeWindowDecoration.create();
|
||||
|
||||
/// Whether the attached stage delegates window decoration and gestures to the platform.
|
||||
private boolean nativeDecorationEnabled;
|
||||
|
||||
/// Installs application-specific identity and lifecycle integration on replacement stages before attachment.
|
||||
private final Consumer<Stage> stageInitializer;
|
||||
|
||||
/// Whether an appearance change has already queued a window-style check.
|
||||
private boolean windowStyleUpdateQueued;
|
||||
|
||||
/// Coalesces appearance changes so stage replacement runs after theme bindings have updated.
|
||||
private final ChangeListener<Boolean> windowStyleListener = (observable, oldValue, newValue) -> {
|
||||
if (!windowStyleUpdateQueued) {
|
||||
windowStyleUpdateQueued = true;
|
||||
Platform.runLater(() -> {
|
||||
windowStyleUpdateQueued = false;
|
||||
updateWindowStyle();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/// The clipped main-window content hosted inside [#shadowContainer].
|
||||
private final MainWindowPane mainWindowPane;
|
||||
|
||||
@@ -141,15 +172,18 @@ public final class Decorator {
|
||||
/// Whether the navigation close action should use the home icon.
|
||||
private final BooleanProperty showCloseAsHome = new SimpleBooleanProperty(this, "showCloseAsHome");
|
||||
|
||||
/// The width of the visible window content, excluding custom decoration insets.
|
||||
/// The width of the visible window content, excluding native borders and custom shadow padding.
|
||||
private final DoubleProperty contentWidth = new SimpleDoubleProperty(this, "contentWidth");
|
||||
|
||||
/// The height of the visible window content, excluding custom decoration insets.
|
||||
/// The height of the visible window content, excluding native borders and custom shadow padding.
|
||||
private final DoubleProperty contentHeight = new SimpleDoubleProperty(this, "contentHeight");
|
||||
|
||||
/// The stage currently controlled by this decorator, or `null` while detached.
|
||||
private @Nullable Stage stage;
|
||||
|
||||
/// Tracks the attached stage's content geometry, or `null` while detached.
|
||||
private @Nullable WindowBounds windowBounds;
|
||||
|
||||
/// The title-bar state supplied by the current page, or `null` before navigation is initialized.
|
||||
private final ObjectProperty<DecoratorPage.@Nullable State> state = new SimpleObjectProperty<>(this, "state");
|
||||
|
||||
@@ -198,49 +232,7 @@ public final class Decorator {
|
||||
private final InvalidationListener stageDecorationListener = observable -> {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage != null) {
|
||||
updateWindowDecoration(currentStage.isMaximized() || currentStage.isFullScreen());
|
||||
}
|
||||
};
|
||||
|
||||
/// Updates the changed visible-content bound and persists normal, non-iconified stage bounds.
|
||||
private final InvalidationListener stageBoundsListener = observable -> {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Insets insets = getWindowInsets();
|
||||
boolean saveBounds = !currentStage.isIconified()
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
|| !currentStage.isFullScreen() && !currentStage.isMaximized());
|
||||
|
||||
if (observable == currentStage.xProperty()) {
|
||||
if (saveBounds) {
|
||||
double currentContentX = currentStage.getX() + insets.getLeft();
|
||||
SettingsManager.state().setX(currentContentX / PRIMARY_SCREEN_BOUNDS.getWidth());
|
||||
}
|
||||
} else if (observable == currentStage.yProperty()) {
|
||||
if (saveBounds) {
|
||||
double currentContentY = currentStage.getY() + insets.getTop();
|
||||
SettingsManager.state().setY(currentContentY / PRIMARY_SCREEN_BOUNDS.getHeight());
|
||||
}
|
||||
} else if (observable == currentStage.widthProperty()) {
|
||||
double currentContentWidth = Math.max(
|
||||
MIN_CONTENT_WIDTH,
|
||||
currentStage.getWidth() - insets.getLeft() - insets.getRight());
|
||||
contentWidth.set(currentContentWidth);
|
||||
if (saveBounds) {
|
||||
SettingsManager.state().setWidth(currentContentWidth);
|
||||
}
|
||||
} else if (observable == currentStage.heightProperty()) {
|
||||
double currentContentHeight = Math.max(
|
||||
MIN_CONTENT_HEIGHT,
|
||||
currentStage.getHeight() - insets.getTop() - insets.getBottom());
|
||||
contentHeight.set(currentContentHeight);
|
||||
if (saveBounds) {
|
||||
SettingsManager.state().setHeight(currentContentHeight);
|
||||
}
|
||||
updateWindowDecoration(WindowState.isMaximized(currentStage) || currentStage.isFullScreen());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,9 +243,13 @@ public final class Decorator {
|
||||
///
|
||||
/// The decorator initially enables its custom window shadow. No scene is created until
|
||||
/// [#attachStage(Stage)] is called or [#getRoot()] is otherwise installed in a scene.
|
||||
/// Appearance changes replace the attached stage when required, preserving its retained scene and window state.
|
||||
///
|
||||
/// @param mainPage the permanent root page of the navigation stack
|
||||
public Decorator(Node mainPage) {
|
||||
/// @param stageInitializer configures each replacement stage on the JavaFX application thread before attachment;
|
||||
/// it must not show the stage and is not called for stages supplied to [#attachStage(Stage)]
|
||||
public Decorator(Node mainPage, Consumer<Stage> stageInitializer) {
|
||||
this.stageInitializer = Objects.requireNonNull(stageInitializer);
|
||||
navigator.setOnNavigated(this::onNavigated);
|
||||
|
||||
mainWindowPane = new MainWindowPane(this);
|
||||
@@ -285,21 +281,21 @@ public final class Decorator {
|
||||
return root;
|
||||
}
|
||||
|
||||
/// Returns the insets reserved outside the main-window content.
|
||||
/// Returns the space reserved inside the scene for custom window shadows.
|
||||
///
|
||||
/// @return the root's shadow insets for a normal window, or [Insets#EMPTY] while maximized or full-screen
|
||||
/// @return the custom shadow insets, or [Insets#EMPTY] with native decoration, supported maximization, or full-screen
|
||||
public Insets getWindowInsets() {
|
||||
return root.getPadding();
|
||||
}
|
||||
|
||||
/// Returns the visible window-content width property, excluding custom decoration insets.
|
||||
/// Returns the visible window-content width property, excluding native borders and custom shadow padding.
|
||||
///
|
||||
/// @return the current content-width property
|
||||
public ReadOnlyDoubleProperty contentWidthProperty() {
|
||||
return contentWidth;
|
||||
}
|
||||
|
||||
/// Returns the visible window-content height property, excluding custom decoration insets.
|
||||
/// Returns the visible window-content height property, excluding native borders and custom shadow padding.
|
||||
///
|
||||
/// @return the current content-height property
|
||||
public ReadOnlyDoubleProperty contentHeightProperty() {
|
||||
@@ -316,11 +312,12 @@ public final class Decorator {
|
||||
|
||||
/// Updates the shadow, outer insets, and content corner shape for an edge-to-edge window state.
|
||||
///
|
||||
/// @param edgeToEdge whether the attached stage is maximized or full-screen
|
||||
/// @param edgeToEdge whether the attached stage is maximized under [WindowState]'s policy or full-screen
|
||||
private void updateWindowDecoration(boolean edgeToEdge) {
|
||||
root.setPadding(edgeToEdge ? Insets.EMPTY : SHADOW_INSETS);
|
||||
shadowContainer.setEffect(edgeToEdge ? null : windowShadow);
|
||||
mainWindowPane.setWindowEdgeToEdge(edgeToEdge);
|
||||
boolean customDecoration = !nativeDecorationEnabled && !edgeToEdge;
|
||||
root.setPadding(customDecoration ? SHADOW_INSETS : Insets.EMPTY);
|
||||
shadowContainer.setEffect(customDecoration ? windowShadow : null);
|
||||
mainWindowPane.setWindowCornersRounded(customDecoration);
|
||||
}
|
||||
|
||||
/// Returns the pane on which application dialogs are stacked.
|
||||
@@ -405,7 +402,14 @@ public final class Decorator {
|
||||
///
|
||||
/// @param node the drag-enabled node
|
||||
public void capableDraggingWindow(Node node) {
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> allowMove = true);
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDraggable(node, true);
|
||||
}
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
|
||||
if (!nativeDecorationEnabled) {
|
||||
allowMove = true;
|
||||
}
|
||||
});
|
||||
node.addEventHandler(MouseEvent.MOUSE_EXITED, event -> {
|
||||
if (!dragging) {
|
||||
allowMove = false;
|
||||
@@ -413,6 +417,19 @@ public final class Decorator {
|
||||
});
|
||||
}
|
||||
|
||||
/// Configures window dragging through an overlay while retaining ordinary mouse picking.
|
||||
///
|
||||
/// Native decoration permits dragging only over underlying draggable header areas. Custom decoration
|
||||
/// permits dragging from the overlay itself. Interactive content must be excluded with [#forbidDraggingWindow(Node)].
|
||||
///
|
||||
/// @param overlay the overlay above the window content and title bar
|
||||
public void capableDraggingOverlay(Node overlay) {
|
||||
capableDraggingWindow(overlay);
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDragTransparent(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers `node` as the title-bar area used to restore and maximize the attached stage.
|
||||
///
|
||||
/// @param node the title-bar node
|
||||
@@ -425,9 +442,14 @@ public final class Decorator {
|
||||
///
|
||||
/// @param node the node that must consume drag eligibility
|
||||
public void forbidDraggingWindow(Node node) {
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setDraggable(node, false);
|
||||
}
|
||||
node.addEventHandler(MouseEvent.MOUSE_MOVED, event -> {
|
||||
allowMove = false;
|
||||
event.consume();
|
||||
if (!nativeDecorationEnabled) {
|
||||
allowMove = false;
|
||||
event.consume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,14 +458,15 @@ public final class Decorator {
|
||||
/// @param event the title-bar click event
|
||||
private void onTitleBarDoubleClick(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
if (nativeDecorationEnabled
|
||||
|| currentStage == null
|
||||
|| !WindowState.supportsMaximization(currentStage)
|
||||
|| event.getButton() != MouseButton.PRIMARY
|
||||
|| event.getClickCount() != 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStage.setMaximized(!currentStage.isMaximized());
|
||||
currentStage.setMaximized(!WindowState.isMaximized(currentStage));
|
||||
event.consume();
|
||||
}
|
||||
|
||||
@@ -452,7 +475,7 @@ public final class Decorator {
|
||||
/// @param event the title-bar drag event
|
||||
private void onTitleBarDragged(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null || dragging || !currentStage.isMaximized()) {
|
||||
if (nativeDecorationEnabled || currentStage == null || dragging || !WindowState.isMaximized(currentStage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -531,10 +554,10 @@ public final class Decorator {
|
||||
/// @param event the pointer movement event
|
||||
private void onMouseMoved(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null
|
||||
if (nativeDecorationEnabled || currentStage == null
|
||||
|| currentStage.isIconified()
|
||||
|| currentStage.isFullScreen()
|
||||
|| currentStage.isMaximized()
|
||||
|| WindowState.isMaximized(currentStage)
|
||||
|| !currentStage.isResizable()) {
|
||||
root.setCursor(Cursor.DEFAULT);
|
||||
return;
|
||||
@@ -597,10 +620,10 @@ public final class Decorator {
|
||||
/// @param event the primary-button drag event
|
||||
private void onMouseDragged(MouseEvent event) {
|
||||
@Nullable Stage currentStage = stage;
|
||||
if (currentStage == null
|
||||
if (nativeDecorationEnabled || currentStage == null
|
||||
|| currentStage.isIconified()
|
||||
|| currentStage.isFullScreen()
|
||||
|| currentStage.isMaximized()
|
||||
|| WindowState.isMaximized(currentStage)
|
||||
|| !event.isPrimaryButtonDown()
|
||||
|| event.isStillSincePress()) {
|
||||
return;
|
||||
@@ -673,13 +696,12 @@ public final class Decorator {
|
||||
return root.getScene();
|
||||
}
|
||||
|
||||
/// Applies the persisted content bounds and current decoration insets to `targetStage`.
|
||||
/// Returns the persisted normal content rectangle without native borders or custom shadow padding.
|
||||
///
|
||||
/// An off-screen persisted position is replaced with a position centered on the primary screen.
|
||||
///
|
||||
/// @param targetStage the stage receiving the initial outer bounds and minimum dimensions
|
||||
private void initializeStageBounds(Stage targetStage) {
|
||||
Insets insets = getWindowInsets();
|
||||
/// @return the initial content bounds in JavaFX screen coordinates
|
||||
private Rectangle2D initialContentBounds() {
|
||||
double initialContentWidth = Math.max(MIN_CONTENT_WIDTH, SettingsManager.state().getWidth());
|
||||
double initialContentHeight = Math.max(MIN_CONTENT_HEIGHT, SettingsManager.state().getHeight());
|
||||
double initialContentX = SettingsManager.state().getX() * PRIMARY_SCREEN_BOUNDS.getWidth();
|
||||
@@ -702,14 +724,68 @@ public final class Decorator {
|
||||
initialContentY = (PRIMARY_SCREEN_BOUNDS.getHeight() - initialContentHeight) / 2;
|
||||
}
|
||||
|
||||
targetStage.setX(initialContentX - insets.getLeft());
|
||||
targetStage.setY(initialContentY - insets.getTop());
|
||||
targetStage.setWidth(initialContentWidth + insets.getLeft() + insets.getRight());
|
||||
targetStage.setHeight(initialContentHeight + insets.getTop() + insets.getBottom());
|
||||
targetStage.setMinWidth(MIN_CONTENT_WIDTH + insets.getLeft() + insets.getRight());
|
||||
targetStage.setMinHeight(MIN_CONTENT_HEIGHT + insets.getTop() + insets.getBottom());
|
||||
contentWidth.set(initialContentWidth);
|
||||
contentHeight.set(initialContentHeight);
|
||||
return new Rectangle2D(initialContentX, initialContentY, initialContentWidth, initialContentHeight);
|
||||
}
|
||||
|
||||
/// Persists a normal content rectangle independently of the window's decoration style.
|
||||
///
|
||||
/// @param bounds the content bounds in JavaFX screen coordinates
|
||||
private void saveContentBounds(Rectangle2D bounds) {
|
||||
SettingsManager.state().setX(bounds.getMinX() / PRIMARY_SCREEN_BOUNDS.getWidth());
|
||||
SettingsManager.state().setY(bounds.getMinY() / PRIMARY_SCREEN_BOUNDS.getHeight());
|
||||
SettingsManager.state().setWidth(bounds.getWidth());
|
||||
SettingsManager.state().setHeight(bounds.getHeight());
|
||||
}
|
||||
|
||||
/// Returns the preferred style for the configured native-decoration policy and current theme.
|
||||
///
|
||||
/// @return the system style when available and permitted by the policy; otherwise the custom transparent style
|
||||
private StageStyle preferredStageStyle() {
|
||||
return nativeDecoration != null && nativeDecoration.isPreferred(
|
||||
Themes.windowTransparentProperty().get(), Themes.darkModeProperty().get())
|
||||
? nativeDecoration.style : StageStyle.TRANSPARENT;
|
||||
}
|
||||
|
||||
/// Replaces the attached stage when transparency or theme brightness requires a different decoration style.
|
||||
///
|
||||
/// Preserves the scene, navigation, dialogs, normal bounds, supported window states, and focus owner.
|
||||
/// Hidden windows remain hidden. Does nothing while detached or when the current style is still suitable.
|
||||
/// Must run on the JavaFX application thread after theme bindings have updated.
|
||||
private void updateWindowStyle() {
|
||||
FXUtils.checkFxUserThread();
|
||||
@Nullable Stage previousStage = stage;
|
||||
if (previousStage == null || previousStage.getStyle() == preferredStageStyle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean showing = previousStage.isShowing();
|
||||
boolean maximized = WindowState.isMaximized(previousStage);
|
||||
boolean fullScreen = previousStage.isFullScreen();
|
||||
boolean iconified = previousStage.isIconified();
|
||||
@Nullable Node focusOwner = previousStage.getScene().getFocusOwner();
|
||||
|
||||
Stage replacement = new Stage();
|
||||
stageInitializer.accept(replacement);
|
||||
replacement.setTitle(previousStage.getTitle());
|
||||
replacement.getIcons().setAll(previousStage.getIcons());
|
||||
replacement.setOnCloseRequest(previousStage.getOnCloseRequest());
|
||||
replacement.setResizable(previousStage.isResizable());
|
||||
replacement.setAlwaysOnTop(previousStage.isAlwaysOnTop());
|
||||
replacement.setFullScreenExitHint(previousStage.getFullScreenExitHint());
|
||||
replacement.setFullScreenExitKeyCombination(previousStage.getFullScreenExitKeyCombination());
|
||||
|
||||
detachStage();
|
||||
previousStage.hide();
|
||||
attachStage(replacement);
|
||||
replacement.setMaximized(maximized && WindowState.supportsMaximization(replacement));
|
||||
replacement.setFullScreen(fullScreen);
|
||||
replacement.setIconified(iconified);
|
||||
if (showing) {
|
||||
replacement.show();
|
||||
}
|
||||
if (focusOwner != null) {
|
||||
focusOwner.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the retained scene and native window behavior to `newStage`.
|
||||
@@ -717,23 +793,40 @@ public final class Decorator {
|
||||
/// If the root has no scene, this method reuses `newStage`'s scene and replaces its root, or creates a
|
||||
/// transparent scene when the stage has none. If the retained scene belongs to another stage, it is detached
|
||||
/// from that stage before being installed on `newStage`. A newly attached stage receives the persisted normal
|
||||
/// content bounds and minimum size adjusted for the normal decoration insets. The stage and retained scene use
|
||||
/// transparent styles required by the custom decoration. Any active window animation is cancelled and reset.
|
||||
/// When window animations are enabled, each subsequent showing starts the opening animation. This method does
|
||||
/// not show the stage.
|
||||
/// content bounds. Native frame offsets and minimum size are resolved when the stage is shown.
|
||||
/// The native-decoration policy selects the system or custom transparent decoration. Automatic mode uses
|
||||
/// system decoration for supported opaque windows, except for dark windows on Windows; explicit enablement
|
||||
/// bypasses the brightness exclusion. Transparent windows always use custom decoration.
|
||||
/// Any active window animation is cancelled and reset. Custom window
|
||||
/// animations run only with custom decoration. Window-style listeners remain active until [#detachStage()].
|
||||
/// On macOS, native application appearance updates are installed once per stage and retained across detachment.
|
||||
/// This method does not show the stage or invoke the replacement-stage initializer.
|
||||
///
|
||||
/// @param newStage the stage to attach, which must be accessed on the JavaFX application thread
|
||||
/// @return the scene installed on `newStage`
|
||||
/// @throws IllegalStateException if `newStage` has already been shown with a non-transparent style, or if the
|
||||
/// @throws IllegalStateException if `newStage` has already been shown with a different style, or if the
|
||||
/// retained root or scene cannot be transferred from its current owner
|
||||
public Scene attachStage(Stage newStage) {
|
||||
FXUtils.checkFxUserThread();
|
||||
stopWindowAnimation();
|
||||
|
||||
if (newStage.getStyle() != StageStyle.TRANSPARENT) {
|
||||
newStage.initStyle(StageStyle.TRANSPARENT);
|
||||
if (stage != null && stage != newStage) {
|
||||
detachStage();
|
||||
}
|
||||
|
||||
StageStyle style = preferredStageStyle();
|
||||
if (newStage.getStyle() != style) {
|
||||
newStage.initStyle(style);
|
||||
}
|
||||
nativeDecorationEnabled = nativeDecoration != null && style == nativeDecoration.style;
|
||||
if (nativeDecoration != null) {
|
||||
nativeDecoration.setContent(null);
|
||||
if (nativeDecorationEnabled) {
|
||||
nativeDecoration.configureStage(newStage);
|
||||
}
|
||||
}
|
||||
mainWindowPane.setNativeDecoration(nativeDecorationEnabled ? nativeDecoration : null);
|
||||
|
||||
@Nullable Scene retainedScene = root.getScene();
|
||||
Scene scene;
|
||||
if (retainedScene == null) {
|
||||
@@ -759,37 +852,37 @@ public final class Decorator {
|
||||
}
|
||||
}
|
||||
|
||||
scene.setFill(Color.TRANSPARENT);
|
||||
if (nativeDecorationEnabled) {
|
||||
scene.fillProperty().bind(Themes.colorSchemeProperty().getSurfaceContainer());
|
||||
} else {
|
||||
scene.fillProperty().unbind();
|
||||
scene.setFill(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (newStage.getScene() != scene) {
|
||||
newStage.setScene(scene);
|
||||
}
|
||||
|
||||
if (stage != newStage) {
|
||||
playRestoreMinimizeAnimation = false;
|
||||
if (stage != null) {
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
stage.iconifiedProperty().removeListener(iconifiedListener);
|
||||
stage.maximizedProperty().removeListener(stageDecorationListener);
|
||||
stage.fullScreenProperty().removeListener(stageDecorationListener);
|
||||
stage.xProperty().removeListener(stageBoundsListener);
|
||||
stage.yProperty().removeListener(stageBoundsListener);
|
||||
stage.widthProperty().removeListener(stageBoundsListener);
|
||||
stage.heightProperty().removeListener(stageBoundsListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
Themes.applyNativeDarkMode(newStage);
|
||||
}
|
||||
|
||||
playRestoreMinimizeAnimation = false;
|
||||
updateWindowDecoration(false);
|
||||
initializeStageBounds(newStage);
|
||||
stage = newStage;
|
||||
updateWindowDecoration(newStage.isMaximized() || newStage.isFullScreen());
|
||||
newStage.addEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
newStage.iconifiedProperty().addListener(iconifiedListener);
|
||||
newStage.maximizedProperty().addListener(stageDecorationListener);
|
||||
newStage.fullScreenProperty().addListener(stageDecorationListener);
|
||||
newStage.xProperty().addListener(stageBoundsListener);
|
||||
newStage.yProperty().addListener(stageBoundsListener);
|
||||
newStage.widthProperty().addListener(stageBoundsListener);
|
||||
newStage.heightProperty().addListener(stageBoundsListener);
|
||||
windowBounds = new WindowBounds(newStage, root, initialContentBounds(),
|
||||
MIN_CONTENT_WIDTH, MIN_CONTENT_HEIGHT, this::saveContentBounds);
|
||||
contentWidth.bind(windowBounds.contentWidthProperty());
|
||||
contentHeight.bind(windowBounds.contentHeightProperty());
|
||||
updateWindowDecoration(WindowState.isMaximized(newStage) || newStage.isFullScreen());
|
||||
Themes.windowTransparentProperty().addListener(windowStyleListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Themes.darkModeProperty().addListener(windowStyleListener);
|
||||
}
|
||||
}
|
||||
root.setCursor(Cursor.DEFAULT);
|
||||
dragging = false;
|
||||
@@ -801,20 +894,27 @@ public final class Decorator {
|
||||
///
|
||||
/// The root, scene, navigation state, dialogs, and snackbar remain owned by this decorator and may subsequently
|
||||
/// be attached to another stage. Calling this method while detached has no effect beyond resetting root transforms.
|
||||
/// Removes window-style listeners; a queued style check does nothing unless another stage has been attached.
|
||||
/// Hiding a stage that will later be shown again does not require detachment.
|
||||
public void detachStage() {
|
||||
FXUtils.checkFxUserThread();
|
||||
stopWindowAnimation();
|
||||
|
||||
if (windowBounds != null) {
|
||||
windowBounds.close();
|
||||
contentWidth.unbind();
|
||||
contentHeight.unbind();
|
||||
windowBounds = null;
|
||||
}
|
||||
if (stage != null) {
|
||||
Themes.windowTransparentProperty().removeListener(windowStyleListener);
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
Themes.darkModeProperty().removeListener(windowStyleListener);
|
||||
}
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, windowShowingHandler);
|
||||
stage.iconifiedProperty().removeListener(iconifiedListener);
|
||||
stage.maximizedProperty().removeListener(stageDecorationListener);
|
||||
stage.fullScreenProperty().removeListener(stageDecorationListener);
|
||||
stage.xProperty().removeListener(stageBoundsListener);
|
||||
stage.yProperty().removeListener(stageBoundsListener);
|
||||
stage.widthProperty().removeListener(stageBoundsListener);
|
||||
stage.heightProperty().removeListener(stageBoundsListener);
|
||||
if (stage.getScene() == root.getScene()) {
|
||||
stage.setScene(null);
|
||||
}
|
||||
@@ -880,8 +980,9 @@ public final class Decorator {
|
||||
/// Calling this method replaces any active minimize, restore, close, or earlier opening animation. If window
|
||||
/// animations are disabled, it restores the stable root transform without starting an animation.
|
||||
private void playOpenAnimation() {
|
||||
if (!AnimationUtils.playWindowAnimation()) {
|
||||
if (nativeDecorationEnabled || !AnimationUtils.playWindowAnimation()) {
|
||||
stopWindowAnimation();
|
||||
Controllers.trimHeap();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -896,7 +997,13 @@ public final class Decorator {
|
||||
new KeyValue(root.scaleXProperty(), 1, Motion.EASE),
|
||||
new KeyValue(root.scaleYProperty(), 1, Motion.EASE),
|
||||
new KeyValue(root.scaleZProperty(), 1, Motion.EASE)));
|
||||
playWindowAnimation(timeline, this::resetRootTransform);
|
||||
playWindowAnimation(timeline, () -> {
|
||||
resetRootTransform();
|
||||
|
||||
// Proactively triggering GC after the window open animation typically
|
||||
// reduces physical memory usage after HMCL startup.
|
||||
Controllers.trimHeap();
|
||||
});
|
||||
// Apply the initial frame before WINDOW_SHOWING returns so the window cannot flash at full opacity.
|
||||
timeline.jumpTo(Duration.ZERO);
|
||||
}
|
||||
@@ -908,7 +1015,7 @@ public final class Decorator {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation() && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
|
||||
@@ -939,7 +1046,7 @@ public final class Decorator {
|
||||
|
||||
/// Closes the application, using the configured window animation when enabled.
|
||||
void closeWindow() {
|
||||
if (AnimationUtils.playWindowAnimation()) {
|
||||
if (!nativeDecorationEnabled && AnimationUtils.playWindowAnimation()) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 1, Motion.EASE),
|
||||
@@ -1018,14 +1125,31 @@ public final class Decorator {
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
&& event.getEventType() == KeyEvent.KEY_PRESSED
|
||||
&& event.isMetaDown()
|
||||
&& event.getCode() == KeyCode.Q) {
|
||||
return;
|
||||
}
|
||||
|
||||
Node newTarget;
|
||||
@Nullable JFXDialogPane currentDialogPane =
|
||||
(JFXDialogPane) getDialogContainer().getProperties()
|
||||
.get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
|
||||
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
|
||||
newTarget = currentDialogPane.peek().orElseThrow();
|
||||
} else {
|
||||
newTarget = navigator.getCurrentPage();
|
||||
{
|
||||
//noinspection unchecked
|
||||
@Nullable Stack<JFXDatePickerContent> stack =
|
||||
(Stack<JFXDatePickerContent>) getDialogContainer().getProperties()
|
||||
.get(JFXDatePickerSkin.PROPERTY_DATE_PICKER_CONTENTS);
|
||||
if (stack != null && !stack.isEmpty()) {
|
||||
newTarget = stack.peek();
|
||||
} else {
|
||||
@Nullable JFXDialogPane currentDialogPane =
|
||||
(JFXDialogPane) getDialogContainer().getProperties()
|
||||
.get(DialogUtils.PROPERTY_DIALOG_PANE_INSTANCE);
|
||||
if (currentDialogPane != null && currentDialogPane.peek().isPresent()) {
|
||||
newTarget = currentDialogPane.peek().orElseThrow();
|
||||
} else {
|
||||
newTarget = navigator.getCurrentPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (@Nullable Node node = target; node != null; node = node.getParent()) {
|
||||
@@ -1060,6 +1184,11 @@ public final class Decorator {
|
||||
|
||||
/// Restores the root node's transform after an animated minimization.
|
||||
private void playRestoreAnimation() {
|
||||
if (nativeDecorationEnabled) {
|
||||
stopWindowAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO,
|
||||
new KeyValue(root.opacityProperty(), 0, Motion.EASE),
|
||||
|
||||
@@ -63,7 +63,7 @@ final class MainWindowPane extends StackPane {
|
||||
/// The decorator whose state and actions are represented by this pane.
|
||||
private final Decorator decorator;
|
||||
|
||||
/// The clip that rounds normal window corners and becomes square while the window fills the screen.
|
||||
/// The content clip, rounded only for normal windows with custom decoration.
|
||||
private final Rectangle clip = new Rectangle();
|
||||
|
||||
/// The frame containing the title bar and current navigation page.
|
||||
@@ -72,6 +72,9 @@ final class MainWindowPane extends StackPane {
|
||||
/// The title bar containing page navigation state.
|
||||
private final BorderPane titleBar;
|
||||
|
||||
/// The custom help, minimize, and close buttons shared by both decoration styles.
|
||||
private final HBox windowButtons;
|
||||
|
||||
/// The transition container used when the title-bar state changes.
|
||||
private final TransitionPane navBarPane;
|
||||
|
||||
@@ -112,14 +115,15 @@ final class MainWindowPane extends StackPane {
|
||||
center.getChildren().setAll(decorator.getNavigator());
|
||||
frame.setCenter(center);
|
||||
|
||||
HBox rightButtonsContainer = createWindowButtons();
|
||||
windowButtons = createWindowButtons();
|
||||
titleBar = new BorderPane();
|
||||
titleBar.setPickOnBounds(false);
|
||||
titleBar.getStyleClass().add("jfx-tool-bar");
|
||||
titleBar.setRight(rightButtonsContainer);
|
||||
titleBar.setRight(windowButtons);
|
||||
|
||||
navBarPane = new TransitionPane();
|
||||
titleBar.setCenter(navBarPane);
|
||||
decorator.capableDraggingWindow(navBarPane);
|
||||
frame.setTop(titleBar);
|
||||
|
||||
updateTitleBarBackground();
|
||||
@@ -136,11 +140,27 @@ final class MainWindowPane extends StackPane {
|
||||
getChildren().setAll(backgroundNode, frame);
|
||||
}
|
||||
|
||||
/// Updates the content-corner shape for an edge-to-edge window state.
|
||||
/// Moves the custom title bar into or out of the native dragging header.
|
||||
///
|
||||
/// @param edgeToEdge whether the attached window is maximized or full-screen
|
||||
void setWindowEdgeToEdge(boolean edgeToEdge) {
|
||||
double arc = edgeToEdge ? 0.0 : ARC;
|
||||
/// @param decoration the native header support, or `null` for custom decoration
|
||||
void setNativeDecoration(@Nullable NativeWindowDecoration decoration) {
|
||||
frame.setTop(null);
|
||||
if (decoration == null) {
|
||||
frame.setTop(titleBar);
|
||||
} else {
|
||||
decoration.setContent(titleBar);
|
||||
decoration.headerBar.backgroundProperty().bind(titleBar.backgroundProperty());
|
||||
frame.setTop(decoration.headerBar);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables custom rounding of the content corners.
|
||||
///
|
||||
/// Native decoration uses square content bounds so the platform can shape the window outline.
|
||||
///
|
||||
/// @param rounded whether to round the content corners
|
||||
void setWindowCornersRounded(boolean rounded) {
|
||||
double arc = rounded ? ARC : 0.0;
|
||||
clip.setArcWidth(arc);
|
||||
clip.setArcHeight(arc);
|
||||
}
|
||||
@@ -171,6 +191,7 @@ final class MainWindowPane extends StackPane {
|
||||
private HBox createWindowButtons() {
|
||||
HBox buttons = new HBox();
|
||||
buttons.setAlignment(Pos.TOP_RIGHT);
|
||||
decorator.capableDraggingWindow(buttons);
|
||||
buttons.setMaxSize(Region.USE_PREF_SIZE, 40);
|
||||
|
||||
JFXButton helpButton = new JFXButton();
|
||||
@@ -247,6 +268,7 @@ final class MainWindowPane extends StackPane {
|
||||
private Node createNavBar(DecoratorPage.State state) {
|
||||
HBox navBar = new HBox();
|
||||
navBar.setAlignment(Pos.CENTER_LEFT);
|
||||
decorator.capableDraggingWindow(navBar);
|
||||
|
||||
// Left navigation buttons
|
||||
if (state.backable()) {
|
||||
@@ -275,6 +297,7 @@ final class MainWindowPane extends StackPane {
|
||||
StackPane titleArea = new StackPane();
|
||||
titleArea.setAlignment(Pos.CENTER_LEFT);
|
||||
titleArea.setMinWidth(0);
|
||||
decorator.capableDraggingWindow(titleArea);
|
||||
HBox.setHgrow(titleArea, Priority.ALWAYS);
|
||||
if (state.titleNode() != null) {
|
||||
titleArea.getChildren().setAll(state.titleNode());
|
||||
@@ -284,6 +307,7 @@ final class MainWindowPane extends StackPane {
|
||||
titleLabel.textFillProperty().bind(Themes.titleFillProperty());
|
||||
titleLabel.getStyleClass().add("jfx-decorator-title");
|
||||
titleLabel.setMinWidth(0);
|
||||
titleLabel.setMouseTransparent(true);
|
||||
titleArea.getChildren().setAll(titleLabel);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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.ui.decorator;
|
||||
|
||||
import javafx.application.ConditionalFeature;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Accesses the JavaFX 27 extended-window APIs without linking them on older runtimes.
|
||||
@NotNullByDefault
|
||||
final class NativeWindowDecoration {
|
||||
/// Marks nodes whose title-content descendants are already observed for native hit testing.
|
||||
private static final Object DRAG_CONFIGURED = new Object();
|
||||
|
||||
/// Whether the user explicitly enabled native decoration regardless of automatic platform or brightness exclusions.
|
||||
private final boolean forced;
|
||||
|
||||
/// The platform-supported extended stage style.
|
||||
final StageStyle style;
|
||||
|
||||
/// The header bar that supplies native dragging around the custom title content.
|
||||
final Region headerBar;
|
||||
|
||||
/// Assigns the header bar's center content.
|
||||
private final MethodHandle setCenter;
|
||||
|
||||
/// Assigns a node's native header hit-test behavior.
|
||||
private final MethodHandle setDragType;
|
||||
|
||||
/// Reads explicit native drag exclusions on title nodes.
|
||||
private final MethodHandle getDragType;
|
||||
|
||||
/// Sets the system button height to zero to hide the platform-provided buttons.
|
||||
private final MethodHandle setSystemButtonHeight;
|
||||
|
||||
/// Marks only the specified node as draggable, preserving child control interaction.
|
||||
private final Object draggable;
|
||||
|
||||
/// Excludes a node and its descendants from native dragging.
|
||||
private final Object notDraggable;
|
||||
|
||||
/// Ignores an overlay and its descendants when testing underlying native header areas.
|
||||
private final Object transparentSubtree;
|
||||
|
||||
/// Resolves all required public APIs and creates a detached header bar.
|
||||
///
|
||||
/// @param forced whether to bypass the automatic theme brightness exclusion
|
||||
/// @throws Throwable if a required API cannot be resolved or invoked
|
||||
private NativeWindowDecoration(boolean forced) throws Throwable {
|
||||
this.forced = forced;
|
||||
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
|
||||
style = (StageStyle) lookup.findStaticGetter(StageStyle.class, "EXTENDED", StageStyle.class).invokeExact();
|
||||
Class<?> headerClass = Class.forName("javafx.scene.layout.HeaderBar");
|
||||
Class<?> dragClass = Class.forName("javafx.scene.layout.HeaderDragType");
|
||||
// Adapt runtime-only types once so cached handles can be invoked with exact, statically known signatures.
|
||||
setCenter = lookup.findVirtual(headerClass, "setCenter", MethodType.methodType(void.class, Node.class))
|
||||
.asType(MethodType.methodType(void.class, Region.class, Node.class));
|
||||
setDragType = lookup.findStatic(headerClass, "setDragType", MethodType.methodType(void.class, Node.class, dragClass))
|
||||
.asType(MethodType.methodType(void.class, Node.class, Object.class));
|
||||
getDragType = lookup.findStatic(headerClass, "getDragType", MethodType.methodType(dragClass, Node.class))
|
||||
.asType(MethodType.methodType(Object.class, Node.class));
|
||||
setSystemButtonHeight = lookup.findStatic(headerClass, "setSystemButtonHeight",
|
||||
MethodType.methodType(void.class, Stage.class, double.class));
|
||||
draggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "DRAGGABLE", dragClass).invoke());
|
||||
notDraggable = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "NONE", dragClass).invoke());
|
||||
transparentSubtree = Objects.requireNonNull(lookup.findStaticGetter(dragClass, "TRANSPARENT_SUBTREE", dragClass).invoke());
|
||||
headerBar = (Region) lookup.findConstructor(headerClass, MethodType.methodType(void.class)).invoke();
|
||||
}
|
||||
|
||||
/// Creates native decoration support according to the configured preference and runtime capabilities.
|
||||
///
|
||||
/// The `hmcl.nativeDecoration` system property takes precedence over `HMCL_NATIVE_DECORATION`.
|
||||
/// Values are case-insensitive and trimmed: `false` disables native decoration, `true` enables it on
|
||||
/// any supported platform, and `auto` enables it only on macOS or Windows 11 and later. Missing or
|
||||
/// unrecognized values use `auto`. All modes require JavaFX 27 or later and extended-window support.
|
||||
/// The preference is read when this method is called and retained by the returned instance.
|
||||
///
|
||||
/// @return the resolved support, or `null` when disabled by policy or unavailable on the runtime
|
||||
static @Nullable NativeWindowDecoration create() {
|
||||
@Nullable String preference = System.getProperty("hmcl.nativeDecoration", System.getenv("HMCL_NATIVE_DECORATION"));
|
||||
if (preference != null) {
|
||||
preference = preference.trim();
|
||||
}
|
||||
if ("false".equalsIgnoreCase(preference)) {
|
||||
return null;
|
||||
}
|
||||
boolean forced = "true".equalsIgnoreCase(preference);
|
||||
if (!forced && preference != null && !"auto".equalsIgnoreCase(preference)) {
|
||||
LOG.warning("Invalid native decoration preference: " + preference + "; using auto");
|
||||
}
|
||||
if (!forced && OperatingSystem.CURRENT_OS != OperatingSystem.MACOS
|
||||
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int version = Integer.parseInt(System.getProperty("javafx.version", "0").split("[.\\-+]")[0]);
|
||||
if (version < 27 || !Platform.isSupported(ConditionalFeature.valueOf("EXTENDED_WINDOW"))) {
|
||||
return null;
|
||||
}
|
||||
return new NativeWindowDecoration(forced);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Native window decoration is unavailable; using custom decoration", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether to use native decoration with the current transparency and theme brightness.
|
||||
///
|
||||
/// Transparent windows always use custom decoration. Automatic mode also excludes dark windows on Windows;
|
||||
/// explicitly enabling native decoration bypasses only that brightness exclusion.
|
||||
///
|
||||
/// @param transparent whether the theme requests window transparency
|
||||
/// @param dark whether the current theme is dark
|
||||
/// @return whether this instance permits native decoration for the supplied theme settings
|
||||
boolean isPreferred(boolean transparent, boolean dark) {
|
||||
return !transparent && (forced || !(OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && dark));
|
||||
}
|
||||
|
||||
/// Sets or removes the header content; the caller must first detach it from any other parent.
|
||||
///
|
||||
/// Configures native dragging on non-interactive descendants and observes later child additions.
|
||||
/// Controls other than labels and nodes explicitly excluded from dragging retain ordinary input handling.
|
||||
///
|
||||
/// @param content the title-bar content, or `null` to detach it
|
||||
void setContent(@Nullable Node content) {
|
||||
try {
|
||||
if (content != null) {
|
||||
configureTitleDragging(content);
|
||||
}
|
||||
setCenter.invokeExact(headerBar, content);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot update native header content", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a node as draggable or explicitly non-draggable during native header hit testing.
|
||||
///
|
||||
/// Draggability does not extend to descendants, so interactive title content retains its behavior.
|
||||
///
|
||||
/// @param node the node to configure
|
||||
/// @param enabled whether the node itself may move the window
|
||||
void setDraggable(Node node, boolean enabled) {
|
||||
try {
|
||||
setDragType.invokeExact(node, enabled ? draggable : notDraggable);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot configure native header dragging", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets native header hit testing pass through an overlay and its descendants.
|
||||
///
|
||||
/// Ordinary mouse picking is unchanged. This does not create draggable areas outside the underlying
|
||||
/// header. Descendants excluded with [#setDraggable(Node, boolean)] block native dragging instead.
|
||||
///
|
||||
/// @param overlay the overlay above the header
|
||||
void setDragTransparent(Node overlay) {
|
||||
try {
|
||||
setDragType.invokeExact(overlay, transparentSubtree);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot configure native header overlay", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures native hit testing for title content, including subsequently added descendants.
|
||||
///
|
||||
/// Containers and label graphics may move the window. Other controls and nodes explicitly excluded with
|
||||
/// [#setDraggable(Node, boolean)] retain ordinary input handling, including all of their descendants.
|
||||
///
|
||||
/// @param node the title content to configure
|
||||
private void configureTitleDragging(Node node) {
|
||||
if (node.getProperties().putIfAbsent(DRAG_CONFIGURED, Boolean.TRUE) != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if ((Object) getDragType.invokeExact(node) == notDraggable) {
|
||||
return;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot read native header dragging", e);
|
||||
}
|
||||
if (node instanceof Control && !(node instanceof Label)) {
|
||||
setDraggable(node, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Native hit testing starts at the deepest picked node; marking only its parent is insufficient.
|
||||
setDraggable(node, true);
|
||||
if (node instanceof Parent parent) {
|
||||
parent.getChildrenUnmodifiable().addListener((ListChangeListener<Node>) change -> {
|
||||
while (change.next()) {
|
||||
for (Node child : change.getAddedSubList()) {
|
||||
configureTitleDragging(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
configureTitleDragging(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides platform-provided window buttons.
|
||||
///
|
||||
/// @param stage the extended stage to configure
|
||||
void configureStage(Stage stage) {
|
||||
try {
|
||||
setSystemButtonHeight.invokeExact(stage, 0.0);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Cannot hide native window buttons", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* 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.ui.decorator;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import javafx.stage.WindowEvent;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/// Tracks content bounds independently of the stage's native frame and the root's shadow padding.
|
||||
///
|
||||
/// All access must occur on the JavaFX application thread. The scene and root must remain attached to the stage
|
||||
/// until [#close()] is called. Normal bounds are sampled after layout, and once more before hiding or detaching.
|
||||
/// Maximized bounds under [WindowState]'s policy, full-screen bounds, and iconified bounds are not reported
|
||||
/// as normal bounds. Transparent macOS windows retain normal bounds despite native zoom notifications.
|
||||
/// Minimum sizes are configured separately after showing or a display-scale or resizability change;
|
||||
/// ordinary geometry tracking does not write window constraints.
|
||||
@NotNullByDefault
|
||||
final class WindowBounds implements AutoCloseable {
|
||||
/// The stage whose outer geometry is managed.
|
||||
private final Stage stage;
|
||||
|
||||
/// The scene providing client dimensions and offsets within the native window.
|
||||
private final Scene scene;
|
||||
|
||||
/// The root whose padding reserves space for custom shadows.
|
||||
private final Region root;
|
||||
|
||||
/// The padding used in the normal window state, even when maximized padding is empty.
|
||||
private final Insets normalInsets;
|
||||
|
||||
/// The minimum content width in JavaFX screen coordinates.
|
||||
private final double minWidth;
|
||||
|
||||
/// The minimum content height in JavaFX screen coordinates.
|
||||
private final double minHeight;
|
||||
|
||||
/// Receives normal content bounds in JavaFX screen coordinates.
|
||||
private final Consumer<Rectangle2D> saveBounds;
|
||||
|
||||
/// The latest normal content rectangle, retained through hiding and non-normal window states.
|
||||
private Rectangle2D normalBounds;
|
||||
|
||||
/// The content width excluding both native borders and custom shadow padding.
|
||||
private final ReadOnlyDoubleWrapper contentWidth = new ReadOnlyDoubleWrapper();
|
||||
|
||||
/// The content height excluding both native borders and custom shadow padding.
|
||||
private final ReadOnlyDoubleWrapper contentHeight = new ReadOnlyDoubleWrapper();
|
||||
|
||||
/// Whether the current show operation is still resolving native client offsets.
|
||||
private boolean restoringPosition;
|
||||
|
||||
/// Whether native geometry can be sampled, including during WINDOW_HIDING.
|
||||
private boolean shown;
|
||||
|
||||
/// Whether geometry must be sampled after the next layout pulse.
|
||||
private boolean dirty;
|
||||
|
||||
/// Whether the native minimum needs configuration once normal geometry has stopped changing between pulses.
|
||||
private boolean minimumSizePending;
|
||||
|
||||
/// Whether this tracker has released its listeners.
|
||||
private boolean closed;
|
||||
|
||||
/// Marks geometry for sampling after layout without assuming an order for stage and scene notifications.
|
||||
private final ChangeListener<Object> boundsListener = (observable, oldValue, newValue) -> invalidate();
|
||||
|
||||
/// Compensates for native offsets discovered while creating the window peer.
|
||||
private final InvalidationListener offsetListener = observable -> restorePosition();
|
||||
|
||||
/// Requests new native minimum dimensions when the display scale or resizability changes.
|
||||
private final ChangeListener<Object> minimumSizeListener = (observable, oldValue, newValue) -> invalidateMinimumSize();
|
||||
|
||||
/// Establishes the normal restore rectangle before the native peer becomes visible.
|
||||
private final EventHandler<WindowEvent> showingHandler = event -> restoreBounds();
|
||||
|
||||
/// Completes initial positioning and enables geometry sampling.
|
||||
private final EventHandler<WindowEvent> shownHandler = event -> {
|
||||
restorePosition();
|
||||
restoringPosition = false;
|
||||
shown = true;
|
||||
invalidateMinimumSize();
|
||||
};
|
||||
|
||||
/// Samples geometry before JavaFX clears the hidden scene's native offsets.
|
||||
private final EventHandler<WindowEvent> hidingHandler = event -> {
|
||||
updateBounds();
|
||||
shown = false;
|
||||
restoringPosition = false;
|
||||
};
|
||||
|
||||
/// Samples geometry and separately processes pending minimum-size configuration after layout.
|
||||
private final Runnable pulseListener = this::onPulse;
|
||||
|
||||
/// The properties observed for geometry, state, and padding changes.
|
||||
private final ObservableValue<?> @Unmodifiable [] observed;
|
||||
|
||||
/// The properties that can change the normal native frame dimensions.
|
||||
private final ObservableValue<?> @Unmodifiable [] minimumSizeObserved;
|
||||
|
||||
/// Initializes client sizing and installs listeners on an attached stage and scene.
|
||||
///
|
||||
/// The root's current padding must be its normal-state shadow padding. Initial bounds exclude that padding
|
||||
/// and native decoration. [Stage#sizeToScene()] lets JavaFX determine the required native outer size.
|
||||
///
|
||||
/// @param stage the stage with `root` installed as its scene root
|
||||
/// @param root the scene root, whose minimum and preferred sizes are managed by this tracker
|
||||
/// @param initialBounds the normal content rectangle to restore
|
||||
/// @param minWidth the minimum content width
|
||||
/// @param minHeight the minimum content height
|
||||
/// @param saveBounds the callback receiving sampled normal content bounds
|
||||
WindowBounds(Stage stage, Region root, Rectangle2D initialBounds,
|
||||
double minWidth, double minHeight, Consumer<Rectangle2D> saveBounds) {
|
||||
this.stage = stage;
|
||||
this.scene = Objects.requireNonNull(stage.getScene());
|
||||
this.root = root;
|
||||
this.normalInsets = root.getPadding();
|
||||
this.normalBounds = initialBounds;
|
||||
this.minWidth = minWidth;
|
||||
this.minHeight = minHeight;
|
||||
this.saveBounds = saveBounds;
|
||||
// Enforce minimum content size on the stage, independently of the current page's layout minimum.
|
||||
root.setMinSize(0, 0);
|
||||
contentWidth.set(initialBounds.getWidth());
|
||||
contentHeight.set(initialBounds.getHeight());
|
||||
observed = new ObservableValue<?>[] {
|
||||
stage.xProperty(), stage.yProperty(), stage.widthProperty(), stage.heightProperty(),
|
||||
stage.maximizedProperty(), stage.fullScreenProperty(), stage.iconifiedProperty(),
|
||||
scene.xProperty(), scene.yProperty(), scene.widthProperty(), scene.heightProperty(),
|
||||
root.paddingProperty()
|
||||
};
|
||||
for (ObservableValue<?> observable : observed) {
|
||||
observable.addListener(boundsListener);
|
||||
}
|
||||
scene.xProperty().addListener(offsetListener);
|
||||
scene.yProperty().addListener(offsetListener);
|
||||
minimumSizeObserved = new ObservableValue<?>[] {
|
||||
stage.outputScaleXProperty(), stage.outputScaleYProperty(), stage.resizableProperty()
|
||||
};
|
||||
for (ObservableValue<?> observable : minimumSizeObserved) {
|
||||
observable.addListener(minimumSizeListener);
|
||||
}
|
||||
scene.addPostLayoutPulseListener(pulseListener);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
|
||||
stage.addEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
|
||||
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight());
|
||||
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom());
|
||||
restoreBounds();
|
||||
if (stage.isShowing()) {
|
||||
restoringPosition = false;
|
||||
shown = true;
|
||||
invalidateMinimumSize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the content width, excluding native borders and the root's current padding.
|
||||
ReadOnlyDoubleProperty contentWidthProperty() {
|
||||
return contentWidth.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Returns the content height, excluding native borders and the root's current padding.
|
||||
ReadOnlyDoubleProperty contentHeightProperty() {
|
||||
return contentHeight.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/// Requests one layout pulse to coalesce geometry notifications.
|
||||
private void invalidate() {
|
||||
dirty = true;
|
||||
Platform.requestNextPulse();
|
||||
}
|
||||
|
||||
/// Schedules native minimum-size configuration independently of ordinary geometry sampling.
|
||||
private void invalidateMinimumSize() {
|
||||
minimumSizePending = true;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/// Samples dirty geometry, then configures pending minimums on a subsequent quiet layout pulse.
|
||||
private void onPulse() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (dirty) {
|
||||
updateBounds();
|
||||
if (minimumSizePending && isNormal()) {
|
||||
Platform.requestNextPulse();
|
||||
}
|
||||
} else if (minimumSizePending && isNormal()) {
|
||||
updateMinimumSize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the visible stage can supply normal window geometry.
|
||||
private boolean isNormal() {
|
||||
return shown && !restoringPosition && !stage.isIconified()
|
||||
&& !WindowState.isMaximized(stage) && !stage.isFullScreen();
|
||||
}
|
||||
|
||||
/// Requests the normal client size and starts compensating for native client offsets.
|
||||
private void restoreBounds() {
|
||||
restoringPosition = true;
|
||||
root.setPrefSize(normalBounds.getWidth() + normalInsets.getLeft() + normalInsets.getRight(),
|
||||
normalBounds.getHeight() + normalInsets.getTop() + normalInsets.getBottom());
|
||||
restorePosition();
|
||||
stage.sizeToScene();
|
||||
}
|
||||
|
||||
/// Aligns the content origin while the native peer is being initialized.
|
||||
private void restorePosition() {
|
||||
if (restoringPosition) {
|
||||
stage.setX(normalBounds.getMinX() - scene.getX() - normalInsets.getLeft());
|
||||
stage.setY(normalBounds.getMinY() - scene.getY() - normalInsets.getTop());
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates content dimensions and saves normal bounds when native geometry is available.
|
||||
private void updateBounds() {
|
||||
dirty = false;
|
||||
if (!shown || restoringPosition || stage.isIconified()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Insets insets = root.getPadding();
|
||||
double width = scene.getWidth() - insets.getLeft() - insets.getRight();
|
||||
double height = scene.getHeight() - insets.getTop() - insets.getBottom();
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
contentWidth.set(width);
|
||||
contentHeight.set(height);
|
||||
if (WindowState.isMaximized(stage) || stage.isFullScreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalBounds = new Rectangle2D(
|
||||
stage.getX() + scene.getX() + insets.getLeft(),
|
||||
stage.getY() + scene.getY() + insets.getTop(),
|
||||
Math.max(minWidth, width), Math.max(minHeight, height));
|
||||
saveBounds.accept(normalBounds);
|
||||
}
|
||||
|
||||
/// Measures the normal native frame and applies content minimums including frame and shadow padding.
|
||||
private void updateMinimumSize() {
|
||||
// Stage and Scene sizes arrive separately. Only measure after pending geometry notifications
|
||||
// have been processed, never from an individual size listener or an ordinary resize pulse.
|
||||
boolean hasFrame = stage.getStyle() != StageStyle.TRANSPARENT && stage.getStyle() != StageStyle.UNDECORATED;
|
||||
double frameWidth = hasFrame ? Math.max(0, stage.getWidth() - scene.getWidth()) : 0;
|
||||
double frameHeight = hasFrame ? Math.max(0, stage.getHeight() - scene.getHeight()) : 0;
|
||||
minimumSizePending = false;
|
||||
stage.setMinWidth(minWidth + normalInsets.getLeft() + normalInsets.getRight() + frameWidth);
|
||||
stage.setMinHeight(minHeight + normalInsets.getTop() + normalInsets.getBottom() + frameHeight);
|
||||
}
|
||||
|
||||
/// Saves the last available bounds and removes all listeners without hiding or detaching the stage.
|
||||
/// Repeated calls have no effect.
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
updateBounds();
|
||||
closed = true;
|
||||
for (ObservableValue<?> observable : observed) {
|
||||
observable.removeListener(boundsListener);
|
||||
}
|
||||
scene.xProperty().removeListener(offsetListener);
|
||||
scene.yProperty().removeListener(offsetListener);
|
||||
for (ObservableValue<?> observable : minimumSizeObserved) {
|
||||
observable.removeListener(minimumSizeListener);
|
||||
}
|
||||
scene.removePostLayoutPulseListener(pulseListener);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWING, showingHandler);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_SHOWN, shownHandler);
|
||||
stage.removeEventHandler(WindowEvent.WINDOW_HIDING, hidingHandler);
|
||||
}
|
||||
}
|
||||
@@ -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.ui.decorator;
|
||||
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
/// Applies the main window's platform-specific maximization policy.
|
||||
/// All methods must be called on the JavaFX application thread.
|
||||
@NotNullByDefault
|
||||
public final class WindowState {
|
||||
/// Prevents instantiation of this utility class.
|
||||
private WindowState() {
|
||||
}
|
||||
|
||||
/// Returns whether the launcher honors maximization for the stage's platform and style.
|
||||
///
|
||||
/// Transparent windows on macOS do not support maximization in the launcher: native zoom notifications
|
||||
/// can mark an ordinary borderless window as maximized, including during initialization.
|
||||
/// This policy does not suppress full-screen or iconified states.
|
||||
///
|
||||
/// @param stage the main stage to inspect
|
||||
/// @return `false` for transparent stages on macOS; `true` otherwise
|
||||
public static boolean supportsMaximization(Stage stage) {
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4290
|
||||
return OperatingSystem.CURRENT_OS != OperatingSystem.MACOS || stage.getStyle() != StageStyle.TRANSPARENT;
|
||||
}
|
||||
|
||||
/// Returns whether the stage is maximized under the launcher's platform policy.
|
||||
///
|
||||
/// @param stage the main stage to inspect
|
||||
/// @return `true` if maximization is supported and the stage reports that it is maximized
|
||||
public static boolean isMaximized(Stage stage) {
|
||||
return supportsMaximization(stage) && stage.isMaximized();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,10 @@ import com.jfoenix.controls.JFXTextField;
|
||||
import com.jfoenix.validation.RequiredFieldValidator;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
@@ -40,6 +43,7 @@ import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.PortablePath;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
@@ -117,10 +121,12 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
setMessage(i18n("game_directory.already_exists"));
|
||||
}
|
||||
|
||||
/// Rejects names used by other game directories, excluding the entry being edited.
|
||||
@Override
|
||||
protected void eval() {
|
||||
JFXTextField control = (JFXTextField) this.getSrcControl();
|
||||
hasErrors.set(GameDirectoryManager.getGameDirectories().stream()
|
||||
.filter(existingGameDirectory -> existingGameDirectory != gameDirectory)
|
||||
.anyMatch(existingGameDirectory -> Objects.equals(
|
||||
GameDirectoryManager.getGameDirectoryCustomName(existingGameDirectory), control.getText())));
|
||||
}
|
||||
@@ -162,46 +168,56 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
|
||||
saveButton.setPrefSize(100, 40);
|
||||
saveButton.setOnAction(e -> onSave());
|
||||
String initialLocation = getLocation();
|
||||
boolean initialUseRelativePath = toggleUseRelativePath.isSelected();
|
||||
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
|
||||
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation()),
|
||||
txtGameDirectoryName.textProperty(), location));
|
||||
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|
||||
|| (gameDirectory != null
|
||||
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
|
||||
&& Objects.equals(getLocation(), initialLocation)
|
||||
&& toggleUseRelativePath.isSelected() == initialUseRelativePath),
|
||||
txtGameDirectoryName.textProperty(), location, toggleUseRelativePath.selectedProperty()));
|
||||
}
|
||||
|
||||
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
|
||||
Path newPath;
|
||||
try {
|
||||
newPath = FileUtils.toAbsolute(Path.of(newValue));
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!".minecraft".equals(FileUtils.getName(newPath)))
|
||||
return;
|
||||
|
||||
Path parent = newPath.getParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
String suggestedName = FileUtils.getName(parent);
|
||||
if (!suggestedName.isBlank()) {
|
||||
txtGameDirectoryName.setText(suggestedName);
|
||||
}
|
||||
};
|
||||
locationProperty().addListener(locationChangeListener);
|
||||
|
||||
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if (txtGameDirectoryName.isFocused()) {
|
||||
txtGameDirectoryName.textProperty().removeListener(this);
|
||||
locationProperty().removeListener(locationChangeListener);
|
||||
if (gameDirectory == null) {
|
||||
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
|
||||
Path newPath;
|
||||
try {
|
||||
newPath = FileUtils.toAbsolute(Path.of(newValue));
|
||||
} catch (InvalidPathException ignored) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!".minecraft".equals(FileUtils.getName(newPath)))
|
||||
return;
|
||||
|
||||
@Nullable Path parent = newPath.getParent();
|
||||
if (parent == null)
|
||||
return;
|
||||
|
||||
String suggestedName = FileUtils.getName(parent);
|
||||
if (!suggestedName.isBlank()) {
|
||||
txtGameDirectoryName.setText(suggestedName);
|
||||
}
|
||||
};
|
||||
locationChangeListener.changed(locationProperty(), this.location.get(), this.location.get());
|
||||
locationProperty().addListener(locationChangeListener);
|
||||
|
||||
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
|
||||
/// Stops automatic name suggestions after the user edits the name.
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if (txtGameDirectoryName.isFocused()) {
|
||||
txtGameDirectoryName.textProperty().removeListener(this);
|
||||
locationProperty().removeListener(locationChangeListener);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
|
||||
private void onSave() {
|
||||
@FXThread
|
||||
private void doSave() {
|
||||
if (gameDirectory != null) {
|
||||
LocalizedText name = LocalizedText.plain(txtGameDirectoryName.getText());
|
||||
PortablePath path = StringUtils.isNotBlank(getLocation()) ? createPortableLocation() : gameDirectory.getPath();
|
||||
@@ -228,27 +244,39 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
|
||||
SettingsManager.forceOverwriteUserGameDirectories();
|
||||
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
fireEvent(new PageCloseEvent());
|
||||
});
|
||||
return;
|
||||
}
|
||||
GameDirectoryManager.addUserGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
} else {
|
||||
if (SettingsManager.isLocalGameDirectoriesReadOnly()) {
|
||||
Controllers.confirmBackupAndOverwrite(i18n("settings.game_directories.read_only"), () -> {
|
||||
SettingsManager.forceOverwriteLocalGameDirectories();
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
fireEvent(new PageCloseEvent());
|
||||
});
|
||||
return;
|
||||
}
|
||||
GameDirectoryManager.addLocalGameDirectory(newGameDirectory);
|
||||
GameDirectoryManager.setSelectedGameDirectory(newGameDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent(new PageCloseEvent());
|
||||
}
|
||||
|
||||
/// Saves the edited game directory or adds a new entry to the appropriate game directory store.
|
||||
@FXThread
|
||||
private void onSave() {
|
||||
if (Objects.equals(Path.of(getLocation()).getRoot(), Path.of(getLocation()))) {
|
||||
Controllers.confirm(i18n("game_directory.root"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, this::doSave, null);
|
||||
} else doSave();
|
||||
}
|
||||
|
||||
/// Creates the portable path for the current location according to the relative-path toggle.
|
||||
private PortablePath createPortableLocation() {
|
||||
if (toggleUseRelativePath.isSelected()) {
|
||||
|
||||
@@ -28,9 +28,8 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -41,6 +40,7 @@ import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -56,39 +56,39 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
|
||||
|
||||
protected BooleanProperty installable = new SimpleBooleanProperty();
|
||||
|
||||
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
|
||||
public AbstractInstallersPage(WizardController controller, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
|
||||
this.controller = controller;
|
||||
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
|
||||
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId();
|
||||
if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue;
|
||||
library.setOnInstall(() -> {
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
GameComponentType type = component.getComponentType();
|
||||
if (type == GameComponentType.GAME) continue;
|
||||
component.setOnInstall(() -> {
|
||||
if (!Boolean.TRUE.equals(state().getShownTips().get(FABRIC_QUILT_API_TIP))
|
||||
&& (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId)
|
||||
|| LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId)
|
||||
|| LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) {
|
||||
&& (type == GameComponentType.FABRIC_API
|
||||
|| type == GameComponentType.QUILT_API
|
||||
|| type == GameComponentType.LEGACY_FABRIC_API)) {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)),
|
||||
i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + type.getPatchId())),
|
||||
i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.WARNING
|
||||
).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build());
|
||||
}
|
||||
|
||||
if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
|
||||
if (!(component.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState))
|
||||
controller.onNext(
|
||||
new VersionsPage(
|
||||
controller,
|
||||
i18n("install.installer.choose", i18n("install.installer." + libraryId)),
|
||||
i18n("install.installer.choose", i18n("install.installer." + type.getPatchId())),
|
||||
gameVersion,
|
||||
downloadProvider,
|
||||
libraryId,
|
||||
type,
|
||||
() -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS)
|
||||
), Navigation.NavigationDirection.NEXT
|
||||
);
|
||||
});
|
||||
library.setOnRemove(() -> {
|
||||
controller.getSettings().remove(libraryId);
|
||||
component.setOnRemove(() -> {
|
||||
controller.getSettings().remove(type.getPatchId());
|
||||
reload();
|
||||
});
|
||||
}
|
||||
@@ -166,16 +166,16 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
|
||||
}
|
||||
|
||||
{
|
||||
InstallerItem[] libraries = control.group.getLibraries();
|
||||
InstallerItem[] components = control.group.getComponents();
|
||||
|
||||
FlowPane libraryPane = new FlowPane(16, 16, libraries);
|
||||
FlowPane libraryPane = new FlowPane(16, 16, components);
|
||||
ScrollPane scrollPane = new ScrollPane(libraryPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0));
|
||||
root.setCenter(scrollPane);
|
||||
|
||||
if (libraries.length <= 8)
|
||||
if (components.length <= 8)
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,46 +20,47 @@ package org.jackhuang.hmcl.ui.download;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.ui.InstallerItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
class AdditionalInstallersPage extends AbstractInstallersPage {
|
||||
protected final BooleanProperty compatible = new SimpleBooleanProperty();
|
||||
protected final HMCLGameRepository repository;
|
||||
protected final String gameVersion;
|
||||
protected final GameVersionNumber gameVersion;
|
||||
protected final GameInstanceManifest manifest;
|
||||
protected final HMCLGameInstance instance;
|
||||
|
||||
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
|
||||
public AdditionalInstallersPage(HMCLGameInstance instance, GameVersionNumber gameVersion, WizardController controller, DownloadProvider downloadProvider) {
|
||||
super(controller, gameVersion, downloadProvider);
|
||||
this.instance = instance;
|
||||
this.gameVersion = gameVersion;
|
||||
this.manifest = manifest;
|
||||
this.repository = repository;
|
||||
this.manifest = instance.getManifest();
|
||||
|
||||
txtName.setText(manifest.id().toString());
|
||||
txtName.setText(instance.getId().id());
|
||||
txtName.setEditable(false);
|
||||
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId();
|
||||
if (libraryId.equals("game")) continue;
|
||||
library.setOnRemove(() -> {
|
||||
controller.getSettings().put(libraryId, new UpdateInstallerWizardProvider.RemoveVersionAction(libraryId));
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
if (component.getComponentType() == GameComponentType.GAME) continue;
|
||||
component.setOnRemove(() -> {
|
||||
controller.getSettings().put(
|
||||
component.getComponentType().getPatchId(),
|
||||
new UpdateInstallerWizardProvider.RemoveComponentAction(component.getComponentType()));
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
installable.bind(Bindings.createBooleanBinding(() -> compatible.get() && txtName.validate(), txtName.textProperty(), compatible));
|
||||
installable.bind(Bindings.createBooleanBinding(() -> txtName.validate(), txtName.textProperty()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -69,42 +70,25 @@ class AdditionalInstallersPage extends AbstractInstallersPage {
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return i18n("settings.tabs.installers");
|
||||
return i18n("install.change_version.title", instance.getId().id());
|
||||
}
|
||||
|
||||
private String getVersion(String id) {
|
||||
return Optional.ofNullable(controller.getSettings().get(id))
|
||||
.flatMap(it -> Lang.tryCast(it, RemoteVersion.class))
|
||||
.map(RemoteVersion::getSelfVersion).orElse(null);
|
||||
private String getVersion(GameComponentType type) {
|
||||
return Optional.ofNullable(controller.getSettings().get(type.getPatchId()))
|
||||
.flatMap(it -> Lang.tryCast(it, ComponentRemoteVersion.class))
|
||||
.map(ComponentRemoteVersion::getSelfVersion).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null));
|
||||
String game = analyzer.getVersion(MINECRAFT).orElse(null);
|
||||
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
|
||||
|
||||
boolean compatible = true;
|
||||
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId();
|
||||
String version = analyzer.getVersion(libraryId).orElse(null);
|
||||
String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version);
|
||||
boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction);
|
||||
if (!"game".equals(libraryId) && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) {
|
||||
// For third-party libraries, if game version is being changed, and the library is not being reinstalled,
|
||||
// warns the user that we should update the library.
|
||||
library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true));
|
||||
compatible = false;
|
||||
} else if (alreadyInstalled || getVersion(libraryId) != null) {
|
||||
library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
GameComponentType componentType = component.getComponentType();
|
||||
if (controller.getSettings().containsKey(componentType.getPatchId())) {
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(componentType), false, false));
|
||||
} else {
|
||||
library.versionProperty().set(null);
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
}
|
||||
|
||||
this.compatible.set(compatible);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -25,7 +25,11 @@ import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLDependencyManager;
|
||||
import org.jackhuang.hmcl.game.HMCLGameBuilder;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
@@ -45,24 +49,22 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.instances.DownloadListPage;
|
||||
import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameInstancePage;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
|
||||
import org.jackhuang.hmcl.util.FileNameSet;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -94,8 +96,8 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
}
|
||||
|
||||
public DownloadPage(GameInstanceID uploadInstance) {
|
||||
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(),
|
||||
"game", versionPageNavigator::onGameSelected)));
|
||||
newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), GameVersionNumber.unknown(), DownloadProviders.getDownloadProvider(),
|
||||
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
|
||||
modpackTab.setNodeSupplier(loadVersionFor(() -> {
|
||||
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
|
||||
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
|
||||
@@ -110,7 +112,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)));
|
||||
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
|
||||
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
|
||||
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteAddonRepository.WORLDS)));
|
||||
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())));
|
||||
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
|
||||
|
||||
GameDirectoryManager.registerVersionsListener(this::loadVersions);
|
||||
@@ -135,53 +137,47 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
private static <T extends Node> Supplier<T> loadVersionFor(Supplier<T> nodeSupplier) {
|
||||
return () -> {
|
||||
T node = nodeSupplier.get();
|
||||
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
|
||||
loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null);
|
||||
if (node instanceof DownloadListPage page) {
|
||||
page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()));
|
||||
}
|
||||
return node;
|
||||
};
|
||||
}
|
||||
|
||||
public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) {
|
||||
if (instanceId == null) {
|
||||
instanceId = repository.getSelectedInstance();
|
||||
@Nullable HMCLGameInstance instance = instanceId != null
|
||||
? repository.findInstance(instanceId)
|
||||
: repository.getSelectedInstance();
|
||||
Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory();
|
||||
|
||||
var targetPath = runDirectory.resolve(subdirectoryName);
|
||||
|
||||
FileNameSet existingPaths;
|
||||
try {
|
||||
existingPaths = FileNameSet.list(targetPath, null);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to list folders in " + targetPath, e);
|
||||
existingPaths = new FileNameSet(false);
|
||||
}
|
||||
|
||||
Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory();
|
||||
|
||||
Set<String> existingFiles;
|
||||
|
||||
try (var list = Files.list(runDirectory.resolve(subdirectoryName))) {
|
||||
existingFiles = list.map(Path::getFileName)
|
||||
.map(Path::toString)
|
||||
.collect(Collectors.toSet());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to list files in " + runDirectory.resolve(subdirectoryName), e);
|
||||
existingFiles = Set.of();
|
||||
}
|
||||
|
||||
Set<String> finalExistingFiles = existingFiles;
|
||||
|
||||
Controllers.prompt(i18n("archive.file.name"), (result, handler) -> {
|
||||
Path dest = runDirectory.resolve(subdirectoryName).resolve(result);
|
||||
Path dest = targetPath.resolve(result);
|
||||
|
||||
Controllers.taskDialog(Task.composeAsync(() -> {
|
||||
var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest);
|
||||
var task = new FileDownloadTask(downloadProvider.getDownloadCandidates(file.file().url()), dest);
|
||||
task.setName(file.name());
|
||||
return task;
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
if (exception instanceof CancellationException) {
|
||||
Controllers.showToast(i18n("message.cancelled"));
|
||||
} else {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
if (!(exception instanceof CancellationException)) {
|
||||
Controllers.dialog(I18n.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
} else {
|
||||
Controllers.showToast(i18n("install.success"));
|
||||
}
|
||||
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
handler.resolve();
|
||||
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), (it) -> !finalExistingFiles.contains(it)));
|
||||
}, file.file().filename(), new Validator(i18n("install.new_game.malformed"), FileUtils::isNameValid), new Validator(i18n("game_directory.already_exists"), existingPaths::notContains));
|
||||
|
||||
}
|
||||
|
||||
@@ -191,19 +187,19 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
if (repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory()) {
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), version -> {
|
||||
if (modTab.isInitialized()) {
|
||||
modTab.getNode().loadInstance(repository, null);
|
||||
modTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
}
|
||||
if (modpackTab.isInitialized()) {
|
||||
modpackTab.getNode().loadInstance(repository, null);
|
||||
modpackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
}
|
||||
if (resourcePackTab.isInitialized()) {
|
||||
resourcePackTab.getNode().loadInstance(repository, null);
|
||||
resourcePackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
}
|
||||
if (shaderTab.isInitialized()) {
|
||||
shaderTab.getNode().loadInstance(repository, null);
|
||||
shaderTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
}
|
||||
if (worldTab.isInitialized()) {
|
||||
worldTab.getNode().loadInstance(repository, null);
|
||||
worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -290,7 +286,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
|
||||
private static class VanillaInstallWizardProvider implements WizardProvider {
|
||||
private final HMCLGameRepository repository;
|
||||
private final DefaultDependencyManager dependencyManager;
|
||||
private final HMCLDependencyManager dependencyManager;
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final GameRemoteVersion gameVersion;
|
||||
|
||||
@@ -305,27 +301,44 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
public void start(SettingsMap settings) {
|
||||
settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory());
|
||||
settings.put(ModpackPage.REPOSITORY, repository);
|
||||
settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion);
|
||||
settings.put(GameComponentType.GAME.getPatchId(), gameVersion);
|
||||
}
|
||||
|
||||
private Task<Void> finishVersionDownloadingAsync(SettingsMap settings) {
|
||||
GameBuilder builder = dependencyManager.newGameBuilder();
|
||||
/// Builds the selected instance and selects it after successful completion.
|
||||
///
|
||||
/// @param settings the installer selections and target instance id
|
||||
/// @return the builder task with its stage hints preserved as the outermost task wrapper
|
||||
private Task<?> finishVersionDownloadingAsync(SettingsMap settings) {
|
||||
@Nullable GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
|
||||
if (instanceId == null) {
|
||||
throw new IllegalStateException("Instance ID is not set");
|
||||
}
|
||||
|
||||
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
|
||||
builder.name(instanceId);
|
||||
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
|
||||
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
|
||||
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion().toString());
|
||||
|
||||
settings.asStringMap().forEach((key, value) -> {
|
||||
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
|
||||
&& value instanceof RemoteVersion remoteVersion)
|
||||
builder.version(remoteVersion);
|
||||
});
|
||||
settings.asStringMap().forEach((key, value) -> {
|
||||
if (!GameComponentType.GAME.getPatchId().equals(key)
|
||||
&& value instanceof ComponentRemoteVersion remoteVersion)
|
||||
builder.component(remoteVersion);
|
||||
});
|
||||
|
||||
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
|
||||
return builder.buildAsync().whenComplete(any -> {
|
||||
repository.refresh();
|
||||
repository.applyDefaultIsolationSetting(instanceId);
|
||||
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
|
||||
boolean modded = GameComponentType.MOD_LOADERS.stream()
|
||||
.anyMatch(componentType ->
|
||||
settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion);
|
||||
if (repository.shouldIsolateNewInstance(modded)) {
|
||||
builder.enableIsolation();
|
||||
}
|
||||
|
||||
Task<?> buildTask = builder.buildAsync();
|
||||
settings.put(TaskCleanup.KEY, builder::abort);
|
||||
buildTask.onDone().register(event -> {
|
||||
if (!event.isFailed()) {
|
||||
runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
}
|
||||
});
|
||||
return buildTask;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -341,7 +354,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
public Node createPage(WizardController controller, int step, SettingsMap settings) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return new InstallersPage(controller, repository, ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
|
||||
return new InstallersPage(controller, repository, ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider);
|
||||
default:
|
||||
throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages());
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import static javafx.beans.binding.Bindings.createBooleanBinding;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -38,7 +39,7 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
private boolean isNameModifiedByUser = false;
|
||||
|
||||
public InstallersPage(WizardController controller, HMCLGameRepository repository, String gameVersion, DownloadProvider downloadProvider) {
|
||||
public InstallersPage(WizardController controller, HMCLGameRepository repository, GameVersionNumber gameVersion, DownloadProvider downloadProvider) {
|
||||
super(controller, gameVersion, downloadProvider);
|
||||
|
||||
txtName.getValidators().addAll(
|
||||
@@ -52,20 +53,20 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return ((RemoteVersion) controller.getSettings().get("game")).getGameVersion();
|
||||
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion().toString();
|
||||
}
|
||||
|
||||
private String getVersion(String id) {
|
||||
return I18n.getDisplayVersion((RemoteVersion) controller.getSettings().get(id));
|
||||
return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id));
|
||||
}
|
||||
|
||||
protected void reload() {
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId();
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
String libraryId = component.getComponentType().getPatchId();
|
||||
if (controller.getSettings().containsKey(libraryId)) {
|
||||
library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false));
|
||||
} else {
|
||||
library.versionProperty().set(null);
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
}
|
||||
if (!isNameModifiedByUser) {
|
||||
@@ -115,30 +116,25 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
private void setTxtNameWithLoaders() {
|
||||
StringBuilder nameBuilder = new StringBuilder(getTitle());
|
||||
|
||||
for (InstallerItem library : group.getLibraries()) {
|
||||
String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), "");
|
||||
if (!controller.getSettings().containsKey(libraryId)) {
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
if (component.getComponentType() == GameComponentType.GAME
|
||||
|| !controller.getSettings().containsKey(component.getComponentType().getPatchId()))
|
||||
continue;
|
||||
}
|
||||
|
||||
LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId);
|
||||
String loaderName = switch (component.getComponentType()) {
|
||||
case FORGE -> "Forge";
|
||||
case NEO_FORGE -> "NeoForge";
|
||||
case CLEANROOM -> "Cleanroom";
|
||||
case LEGACY_FABRIC -> "LegacyFabric";
|
||||
case FABRIC -> "Fabric";
|
||||
case LITELOADER -> "LiteLoader";
|
||||
case QUILT -> "Quilt";
|
||||
case OPTIFINE -> "OptiFine";
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (libraryType != null) {
|
||||
String loaderName = switch (libraryType) {
|
||||
case FORGE -> "Forge";
|
||||
case NEO_FORGE -> "NeoForge";
|
||||
case CLEANROOM -> "Cleanroom";
|
||||
case LEGACY_FABRIC -> "LegacyFabric";
|
||||
case FABRIC -> "Fabric";
|
||||
case LITELOADER -> "LiteLoader";
|
||||
case QUILT -> "Quilt";
|
||||
case OPTIFINE -> "OptiFine";
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (loaderName != null)
|
||||
nameBuilder.append('-').append(loaderName);
|
||||
}
|
||||
if (loaderName != null)
|
||||
nameBuilder.append('-').append(loaderName);
|
||||
}
|
||||
|
||||
txtName.setText(nameBuilder.toString());
|
||||
|
||||
@@ -20,11 +20,18 @@ package org.jackhuang.hmcl.ui.download;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackFile;
|
||||
import org.jackhuang.hmcl.modpack.ModpackManifest;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -37,24 +44,36 @@ import org.jackhuang.hmcl.ui.construct.Validator;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class LocalModpackPage extends ModpackPage {
|
||||
|
||||
private final BooleanProperty installAsVersion = new SimpleBooleanProperty(true);
|
||||
private Modpack manifest = null;
|
||||
private Charset charset;
|
||||
private final ObservableList<ModpackFile> allFiles = FXCollections.observableList(new ArrayList<>());
|
||||
private final ObservableSet<String> excludedFiles = FXCollections.observableSet(new HashSet<>());
|
||||
private final BooleanProperty loadingOptionalFiles = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty loadedOptionalFiles = new SimpleBooleanProperty(true);
|
||||
|
||||
public LocalModpackPage(WizardController controller) {
|
||||
super(controller);
|
||||
|
||||
btnOptionalFiles.setOnAction(ev -> controller.onNext(new OptionalFilesPage(
|
||||
this::onInstall,
|
||||
this::loadOptionalFiles,
|
||||
loadingOptionalFiles,
|
||||
loadedOptionalFiles,
|
||||
new FilteredList<>(allFiles, ModpackFile::optional),
|
||||
excludedFiles)));
|
||||
|
||||
HMCLGameRepository repository = controller.getSettings().get(ModpackPage.REPOSITORY);
|
||||
|
||||
String name = controller.getSettings().get(MODPACK_NAME);
|
||||
@@ -73,8 +92,8 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str)
|
||||
&& GameDirectoryManager.getGameDirectories().stream()
|
||||
.noneMatch(existingProfile ->
|
||||
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
|
||||
.noneMatch(existingProfile ->
|
||||
str.equals(GameDirectoryManager.getGameDirectoryCustomName(existingProfile)))),
|
||||
new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId));
|
||||
}
|
||||
});
|
||||
@@ -100,12 +119,7 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
}
|
||||
|
||||
showSpinner();
|
||||
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile))
|
||||
.thenApplyAsync(encoding -> {
|
||||
charset = encoding;
|
||||
manifest = ModpackHelper.readModpackManifest(selectedFile, encoding);
|
||||
return manifest;
|
||||
})
|
||||
Task.supplyAsync(() -> ModpackHelper.readModpackManifest(selectedFile))
|
||||
.whenComplete(Schedulers.javafx(), (manifest, exception) -> {
|
||||
if (exception instanceof ManuallyCreatedModpackException) {
|
||||
hideSpinner();
|
||||
@@ -118,7 +132,8 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
}
|
||||
|
||||
Controllers.confirm(i18n("modpack.type.manual.warning"), i18n("install.modpack"), MessageDialogPane.MessageType.WARNING,
|
||||
() -> {},
|
||||
() -> {
|
||||
},
|
||||
controller::onEnd);
|
||||
|
||||
controller.getSettings().put(MODPACK_MANUALLY_CREATED, true);
|
||||
@@ -126,7 +141,11 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
LOG.warning("Failed to read modpack manifest", exception);
|
||||
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
} else if (manifest == null) {
|
||||
Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
} else {
|
||||
this.manifest = manifest;
|
||||
hideSpinner();
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest);
|
||||
nameProperty.set(manifest.getName());
|
||||
@@ -135,10 +154,37 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
|
||||
if (name == null) {
|
||||
// trim: https://github.com/HMCL-dev/HMCL/issues/962
|
||||
txtModpackName.setText(manifest.getName().trim());
|
||||
txtModpackName.setText(processModpackName(manifest.getName()));
|
||||
}
|
||||
|
||||
btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription()));
|
||||
|
||||
if (manifest.getManifest() instanceof ModpackManifest.SupportOptional supportOptional) {
|
||||
allFiles.setAll(supportOptional.getFiles());
|
||||
if (allFiles.stream().anyMatch(ModpackFile::optional)) {
|
||||
loadOptionalFiles();
|
||||
btnOptionalFiles.setVisible(true);
|
||||
btnOptionalFiles.setManaged(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadOptionalFiles() {
|
||||
Objects.requireNonNull(manifest);
|
||||
loadingOptionalFiles.set(true);
|
||||
loadedOptionalFiles.set(false);
|
||||
Task.supplyAsync(() -> manifest.getManifest().getProvider().loadFiles(DownloadProviders.getDownloadProvider(), manifest.getManifest()))
|
||||
.whenComplete(Schedulers.javafx(), (manifest1, exception) -> {
|
||||
loadingOptionalFiles.set(false);
|
||||
List<? extends ModpackFile> files = ((ModpackManifest.SupportOptional) manifest1).getFiles();
|
||||
manifest.setManifest(manifest1);
|
||||
allFiles.setAll(files);
|
||||
if (files.stream().anyMatch(s -> s.optional() && (!s.addonQueried() || s.fileName() == null))) {
|
||||
LOG.warning("Failed to load optional files");
|
||||
} else {
|
||||
loadedOptionalFiles.set(true);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
@@ -157,30 +203,47 @@ public final class LocalModpackPage extends ModpackPage {
|
||||
i18n("install.name.invalid"),
|
||||
i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.QUESTION)
|
||||
.yesOrNo(() -> {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.onFinish();
|
||||
}, () -> {
|
||||
.yesOrNo(() -> finishInstall(name), () -> {
|
||||
// The user selects Cancel and does nothing.
|
||||
})
|
||||
.build());
|
||||
} else {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_CHARSET, charset);
|
||||
controller.onFinish();
|
||||
finishInstall(name);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishInstall(String name) {
|
||||
controller.getSettings().put(MODPACK_NAME, name);
|
||||
controller.getSettings().put(MODPACK_EXCLUDED_FILES, getExcludedFiles());
|
||||
controller.onFinish();
|
||||
}
|
||||
|
||||
private @Nullable Set<String> getExcludedFiles() {
|
||||
if (allFiles.isEmpty())
|
||||
return null;
|
||||
return Set.copyOf(excludedFiles);
|
||||
}
|
||||
|
||||
protected void onDescribe() {
|
||||
if (manifest != null)
|
||||
Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription()));
|
||||
}
|
||||
|
||||
@Contract("null->null;!null->!null")
|
||||
public static @Nullable String processModpackName(@Nullable String name) {
|
||||
if (name == null)
|
||||
return null;
|
||||
|
||||
return name.trim()
|
||||
.replace(':', '_')
|
||||
.replace('/', '_')
|
||||
.replace('\\', '_');
|
||||
}
|
||||
|
||||
public static final SettingsMap.Key<Path> MODPACK_FILE = new SettingsMap.Key<>("MODPACK_FILE");
|
||||
public static final SettingsMap.Key<String> MODPACK_NAME = new SettingsMap.Key<>("MODPACK_NAME");
|
||||
public static final SettingsMap.Key<Modpack> MODPACK_MANIFEST = new SettingsMap.Key<>("MODPACK_MANIFEST");
|
||||
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
|
||||
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
|
||||
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
|
||||
public static final SettingsMap.Key<Set<String>> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES");
|
||||
}
|
||||
+8
-12
@@ -18,10 +18,7 @@
|
||||
package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.ManuallyCreatedModpackException;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException;
|
||||
import org.jackhuang.hmcl.modpack.Modpack;
|
||||
import org.jackhuang.hmcl.modpack.ModpackCompletionException;
|
||||
@@ -38,7 +35,6 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -91,11 +87,11 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
Modpack modpack = settings.get(LocalModpackPage.MODPACK_MANIFEST);
|
||||
String name = settings.get(LocalModpackPage.MODPACK_NAME);
|
||||
String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL);
|
||||
Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET);
|
||||
boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false);
|
||||
var excludedFiles = settings.get(LocalModpackPage.MODPACK_EXCLUDED_FILES);
|
||||
|
||||
if (isManuallyCreated) {
|
||||
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name, charset);
|
||||
return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name);
|
||||
}
|
||||
|
||||
if ((selected == null && serverModpackManifest == null) || modpack == null || name == null) return null;
|
||||
@@ -109,9 +105,9 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
}
|
||||
try {
|
||||
if (serverModpackManifest != null) {
|
||||
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
|
||||
return ModpackHelper.getUpdateTask(repository, serverModpackManifest, instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
} else {
|
||||
return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId)));
|
||||
return ModpackHelper.getUpdateTask(repository, selected, instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles);
|
||||
}
|
||||
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
|
||||
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
|
||||
@@ -124,10 +120,10 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
|
||||
} else {
|
||||
if (serverModpackManifest != null) {
|
||||
return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack)
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
} else {
|
||||
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl)
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
|
||||
return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl, excludedFiles)
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
@@ -49,6 +50,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
protected final JFXTextField txtModpackName;
|
||||
protected final JFXButton btnInstall;
|
||||
protected final JFXButton btnDescription;
|
||||
protected final JFXButton btnOptionalFiles;
|
||||
|
||||
protected ModpackPage(WizardController controller) {
|
||||
this.controller = controller;
|
||||
@@ -66,7 +68,6 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
|
||||
txtModpackName = new JFXTextField();
|
||||
txtModpackName.setPrefWidth(300);
|
||||
// BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32));
|
||||
BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT);
|
||||
archiveNamePane.setRight(txtModpackName);
|
||||
}
|
||||
@@ -95,10 +96,19 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
btnDescription.setOnAction(e -> onDescribe());
|
||||
descriptionPane.setLeft(btnDescription);
|
||||
|
||||
var installHBox = new HBox(8);
|
||||
btnOptionalFiles = FXUtils.newRaisedButton(i18n("modpack.optional_files"));
|
||||
btnOptionalFiles.setVisible(false);
|
||||
btnOptionalFiles.setManaged(false);
|
||||
installHBox.getChildren().add(btnOptionalFiles);
|
||||
|
||||
btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
|
||||
btnInstall.setOnAction(e -> onInstall());
|
||||
btnInstall.disableProperty().bind(createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty()));
|
||||
descriptionPane.setRight(btnInstall);
|
||||
var nameInvalid = createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty());
|
||||
btnInstall.disableProperty().bind(nameInvalid);
|
||||
btnOptionalFiles.disableProperty().bind(nameInvalid);
|
||||
installHBox.getChildren().add(btnInstall);
|
||||
descriptionPane.setRight(installHBox);
|
||||
}
|
||||
|
||||
componentList.getContent().setAll(
|
||||
|
||||
@@ -27,6 +27,7 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.SVGPath;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackManifest;
|
||||
@@ -125,7 +126,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
try {
|
||||
if (url.endsWith("server-manifest.json")) {
|
||||
// if urlString ends with .json, we assume that the url is server-manifest.json
|
||||
Controllers.taskDialog(new GetTask(url).whenComplete(Schedulers.javafx(), (result, e) -> {
|
||||
Controllers.taskDialog(new GetTask(WebURL.parse(url)).whenComplete(Schedulers.javafx(), (result, e) -> {
|
||||
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(result, ServerModpackManifest.class);
|
||||
if (manifest == null) {
|
||||
handler.reject(i18n("modpack.type.server.malformed"));
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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.ui.download;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableBooleanValue;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.ObservableSet;
|
||||
import javafx.collections.SetChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.modpack.ModpackFile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
|
||||
import org.jackhuang.hmcl.ui.construct.MDListCell;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Wizard page that lets the user choose which optional modpack files to install.
|
||||
@NotNullByDefault
|
||||
public class OptionalFilesPage extends SpinnerPane implements WizardPage {
|
||||
|
||||
/// Fixed row height used to size [#body] to its items.
|
||||
private static final double CELL_HEIGHT = 48;
|
||||
|
||||
/// Maximum list viewport height before scrolling.
|
||||
private static final double MAX_LIST_HEIGHT = 320;
|
||||
|
||||
/// Keys of files the user unchecked; empty means all optional files will be installed.
|
||||
private final ObservableSet<String> excludedFiles;
|
||||
|
||||
/// Per-file selected state mirrored from [#excludedFiles] (`true` means install / not excluded).
|
||||
private final Map<String, BooleanProperty> selectedByKey = new HashMap<>();
|
||||
|
||||
/// Whether [#selectedByKey] is being updated from an [#excludedFiles] set change.
|
||||
private boolean updatingFromSet;
|
||||
|
||||
/// List view showing optional files.
|
||||
private final JFXListView<ModpackFile> body = new JFXListView<>();
|
||||
|
||||
/// Creates the optional-files selection page.
|
||||
///
|
||||
/// @param install callback that finishes the install wizard
|
||||
/// @param retry callback that reloads optional-file metadata
|
||||
/// @param loading whether remote metadata is still loading
|
||||
/// @param successful whether remote metadata loaded successfully
|
||||
/// @param optionalFiles optional files to display
|
||||
/// @param excludedFiles mutable set of unchecked optional file keys
|
||||
public OptionalFilesPage(
|
||||
Runnable install,
|
||||
Runnable retry,
|
||||
ObservableBooleanValue loading,
|
||||
ObservableBooleanValue successful,
|
||||
ObservableList<ModpackFile> optionalFiles,
|
||||
ObservableSet<String> excludedFiles) {
|
||||
this.excludedFiles = excludedFiles;
|
||||
excludedFiles.addListener((SetChangeListener<String>) change -> {
|
||||
updatingFromSet = true;
|
||||
try {
|
||||
if (change.wasRemoved()) {
|
||||
BooleanProperty selected = selectedByKey.get(change.getElementRemoved());
|
||||
if (selected != null)
|
||||
selected.set(true);
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
BooleanProperty selected = selectedByKey.get(change.getElementAdded());
|
||||
if (selected != null)
|
||||
selected.set(false);
|
||||
}
|
||||
} finally {
|
||||
updatingFromSet = false;
|
||||
}
|
||||
});
|
||||
|
||||
VBox borderPane = new VBox();
|
||||
borderPane.setAlignment(Pos.CENTER);
|
||||
borderPane.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
FXUtils.setLimitWidth(borderPane, 500);
|
||||
|
||||
ComponentList componentList = new ComponentList();
|
||||
|
||||
Label lblRetry = new Label(i18n("modpack.retry_optional_files"));
|
||||
lblRetry.setOnMouseClicked(e -> retry.run());
|
||||
|
||||
VBox tail = new VBox();
|
||||
{
|
||||
var descPane = new BorderPane();
|
||||
HBox selectionButtons = new HBox(8);
|
||||
JFXButton btnSelectAll = FXUtils.newBorderButton(i18n("button.select_all"));
|
||||
btnSelectAll.setOnAction(e -> excludedFiles.clear());
|
||||
JFXButton btnClear = FXUtils.newBorderButton(i18n("button.clear"));
|
||||
btnClear.setOnAction(e -> {
|
||||
excludedFiles.clear();
|
||||
for (ModpackFile file : optionalFiles) {
|
||||
excludedFiles.add(file.key());
|
||||
}
|
||||
});
|
||||
selectionButtons.getChildren().setAll(btnSelectAll, btnClear);
|
||||
descPane.setLeft(selectionButtons);
|
||||
|
||||
var btnInstall = FXUtils.newRaisedButton(i18n("button.install"));
|
||||
descPane.setRight(btnInstall);
|
||||
btnInstall.setOnAction(e -> install.run());
|
||||
tail.getChildren().add(descPane);
|
||||
}
|
||||
|
||||
// ListView defaults to a large preferred height (~400) regardless of item count, which
|
||||
// leaves empty viewport space when few optional files exist. Size exactly to content —
|
||||
// do not add extra pixels, or a blank strip appears under the last row.
|
||||
body.setFixedCellSize(CELL_HEIGHT);
|
||||
body.prefHeightProperty().bind(Bindings.createDoubleBinding(
|
||||
() -> Math.min(MAX_LIST_HEIGHT, optionalFiles.size() * CELL_HEIGHT + 2),
|
||||
optionalFiles));
|
||||
body.setMaxHeight(Region.USE_PREF_SIZE);
|
||||
body.setCellFactory(it -> new OptionalFileEntry(body));
|
||||
body.setItems(optionalFiles);
|
||||
|
||||
Runnable refreshContent = () -> {
|
||||
if (successful.get()) {
|
||||
componentList.getContent().setAll(body, tail);
|
||||
} else {
|
||||
componentList.getContent().setAll(lblRetry, body, tail);
|
||||
}
|
||||
};
|
||||
refreshContent.run();
|
||||
successful.addListener((obs, oldVal, newVal) -> refreshContent.run());
|
||||
|
||||
borderPane.getChildren().setAll(componentList);
|
||||
setContent(borderPane);
|
||||
loadingProperty().bind(loading);
|
||||
}
|
||||
|
||||
/// Returns the selected property for `key`, creating and wiring it on first use.
|
||||
///
|
||||
/// @param key the file exclusion key
|
||||
/// @return a property that is `true` when the file is selected for install
|
||||
private BooleanProperty selectedProperty(String key) {
|
||||
return selectedByKey.computeIfAbsent(key, k -> {
|
||||
BooleanProperty selected = new SimpleBooleanProperty(!excludedFiles.contains(k));
|
||||
selected.addListener((obs, wasSelected, isSelected) -> {
|
||||
if (updatingFromSet)
|
||||
return;
|
||||
if (isSelected) {
|
||||
excludedFiles.remove(k);
|
||||
} else {
|
||||
excludedFiles.add(k);
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
/// List cell for one optional file with checkbox and detail button.
|
||||
private final class OptionalFileEntry extends MDListCell<ModpackFile> {
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton infoButton = new JFXButton();
|
||||
private final HBox container = new HBox(8);
|
||||
|
||||
/// Currently bidirectionally bound selected property, or `null` when unbound.
|
||||
private @Nullable BooleanProperty boundSelected = null;
|
||||
|
||||
/// Creates a cell bound to `listView`.
|
||||
///
|
||||
/// @param listView the owning list view
|
||||
public OptionalFileEntry(JFXListView<ModpackFile> listView) {
|
||||
super(listView);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
container.getChildren().setAll(checkBox, content);
|
||||
|
||||
infoButton.getStyleClass().add("toggle-icon4");
|
||||
infoButton.setGraphic(SVG.INFO.createIcon());
|
||||
container.getChildren().add(infoButton);
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModpackFile item, boolean empty) {
|
||||
if (empty) {
|
||||
if (boundSelected != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(boundSelected);
|
||||
boundSelected = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
String name = item.fileName();
|
||||
if (name != null) {
|
||||
content.setTitle(name);
|
||||
} else {
|
||||
content.setTitle(i18n("modpack.unknown_optional_file"));
|
||||
}
|
||||
RemoteAddon addon = item.remoteAddon();
|
||||
if (addon != null) {
|
||||
content.setSubtitle(addon.title());
|
||||
infoButton.setOnMouseClicked(e -> Controllers.dialog(new ModInfo(addon)));
|
||||
infoButton.setManaged(true);
|
||||
infoButton.setVisible(true);
|
||||
} else {
|
||||
content.setSubtitle("");
|
||||
infoButton.setOnMouseClicked(null);
|
||||
infoButton.setManaged(false);
|
||||
infoButton.setVisible(false);
|
||||
}
|
||||
BooleanProperty selected = OptionalFilesPage.this.selectedProperty(item.key());
|
||||
if (boundSelected != selected) {
|
||||
if (boundSelected != null)
|
||||
checkBox.selectedProperty().unbindBidirectional(boundSelected);
|
||||
checkBox.selectedProperty().bindBidirectional(selected);
|
||||
boundSelected = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog showing remote addon details for an optional file.
|
||||
private static final class ModInfo extends JFXDialogLayout {
|
||||
/// Creates a detail dialog for `addon`.
|
||||
///
|
||||
/// @param addon the remote addon
|
||||
public ModInfo(RemoteAddon addon) {
|
||||
HBox container = new HBox(8);
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setFitHeight(32);
|
||||
imageView.setFitWidth(32);
|
||||
spinnerPane.setContent(imageView);
|
||||
spinnerPane.setPrefSize(32, 32);
|
||||
spinnerPane.setLoading(true);
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return FXUtils.getRemoteImageTask(addon.iconUrl(), 32, 32, true, true).run();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, Schedulers.io()).thenAcceptAsync((image) -> {
|
||||
imageView.setImage(image);
|
||||
spinnerPane.setLoading(false);
|
||||
}, Schedulers.javafx());
|
||||
container.getChildren().add(spinnerPane);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(addon.title());
|
||||
title.setSubtitle(addon.author());
|
||||
container.getChildren().add(title);
|
||||
setHeading(container);
|
||||
|
||||
Label description = new Label(addon.description());
|
||||
description.setWrapText(true);
|
||||
description.setPadding(new Insets(8, 0, 0, 0));
|
||||
setBody(description);
|
||||
|
||||
JFXHyperlink pageButton = new JFXHyperlink(i18n("mods.url"));
|
||||
pageButton.setOnAction(e -> FXUtils.openLink(addon.pageUrl()));
|
||||
getActions().add(pageButton);
|
||||
|
||||
JFXButton okButton = new JFXButton();
|
||||
okButton.getStyleClass().add("dialog-accept");
|
||||
okButton.setText(i18n("button.ok"));
|
||||
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
getActions().add(okButton);
|
||||
|
||||
onEscPressed(this, okButton::fire);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return i18n("modpack.optional_files");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(SettingsMap settings) {
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public final class RemoteModpackPage extends ModpackPage {
|
||||
throw new IllegalStateException("MODPACK_SERVER_MANIFEST should exist");
|
||||
|
||||
try {
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack(null));
|
||||
controller.getSettings().put(MODPACK_MANIFEST, manifest.toModpack());
|
||||
} catch (IOException e) {
|
||||
Controllers.dialog(i18n("modpack.type.server.malformed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
Platform.runLater(controller::onEnd);
|
||||
@@ -62,8 +62,7 @@ public final class RemoteModpackPage extends ModpackPage {
|
||||
txtModpackName.setText(name);
|
||||
txtModpackName.setDisable(true);
|
||||
} else {
|
||||
// trim: https://github.com/HMCL-dev/HMCL/issues/962
|
||||
txtModpackName.setText(manifest.getName().trim());
|
||||
txtModpackName.setText(LocalModpackPage.processModpackName(manifest.getName()));
|
||||
txtModpackName.getValidators().addAll(
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("install.new_game.already_exists"), str -> !repository.instanceIdConflicts(str)),
|
||||
|
||||
+54
-55
@@ -20,9 +20,11 @@ package org.jackhuang.hmcl.ui.download;
|
||||
import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.LibraryDownloadException;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -34,11 +36,11 @@ import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.zip.ZipException;
|
||||
@@ -46,66 +48,71 @@ import java.util.zip.ZipException;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
private final HMCLGameRepository repository;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final DefaultDependencyManager dependencyManager;
|
||||
private final String gameVersion;
|
||||
private final GameInstanceManifest manifest;
|
||||
private final String libraryId;
|
||||
private final GameComponentType componentType;
|
||||
private final String oldLibraryVersion;
|
||||
private final DownloadProvider downloadProvider;
|
||||
|
||||
public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) {
|
||||
this.repository = repository;
|
||||
this.gameVersion = gameVersion;
|
||||
this.manifest = manifest;
|
||||
this.libraryId = libraryId;
|
||||
public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull GameComponentType componentType, @Nullable String oldLibraryVersion) {
|
||||
this.gameInstance = gameInstance;
|
||||
this.componentType = componentType;
|
||||
this.oldLibraryVersion = oldLibraryVersion;
|
||||
this.downloadProvider = DownloadProviders.getDownloadProvider();
|
||||
this.dependencyManager = repository.getDependency(downloadProvider);
|
||||
this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(SettingsMap settings) {
|
||||
}
|
||||
|
||||
/// Creates a task that updates components and synchronizes the client JAR with the resulting manifest.
|
||||
@Override
|
||||
public Object finish(SettingsMap settings) {
|
||||
settings.put("title", i18n("install.change_version.process"));
|
||||
settings.put("success_message", i18n("install.success"));
|
||||
settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next));
|
||||
|
||||
// We remove library but not save it,
|
||||
// so if installation failed will not break down current version.
|
||||
Task<GameInstanceManifest> ret = Task.supplyAsync(() -> manifest);
|
||||
var hints = new ArrayList<Task.StagesHint>();
|
||||
for (Object value : settings.asStringMap().values()) {
|
||||
if (value instanceof RemoteVersion remoteVersion) {
|
||||
ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion));
|
||||
hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion())));
|
||||
if ("game".equals(remoteVersion.getLibraryId())) {
|
||||
hints.add(new Task.StagesHint("hmcl.install.libraries"));
|
||||
if (value instanceof ComponentRemoteVersion remoteVersion) {
|
||||
hints.add(new Task.StagesHint("hmcl.install.libraries"));
|
||||
|
||||
hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getComponentType().getPatchId(), remoteVersion.getSelfVersion())));
|
||||
if (remoteVersion.getComponentType() == GameComponentType.GAME) {
|
||||
hints.add(new Task.StagesHint("hmcl.install.assets"));
|
||||
}
|
||||
} else if (value instanceof RemoveVersionAction removeVersionAction) {
|
||||
ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId));
|
||||
}
|
||||
}
|
||||
|
||||
return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints);
|
||||
return dependencyManager.updateInstanceAsync(gameInstance.getId(), publishedInstance -> {
|
||||
Task<GameInstanceManifest> update = Task.supplyAsync(publishedInstance::getManifest);
|
||||
for (Object value : settings.asStringMap().values()) {
|
||||
if (value instanceof ComponentRemoteVersion remoteVersion) {
|
||||
update = update.thenComposeAsync(manifest ->
|
||||
dependencyManager.installComponentRemoteAsync(publishedInstance, manifest, remoteVersion));
|
||||
} else if (value instanceof RemoveComponentAction removeComponentAction) {
|
||||
update = update.thenApplyAsync(manifest ->
|
||||
manifest.removeComponent(removeComponentAction.componentType));
|
||||
|
||||
}
|
||||
}
|
||||
return update;
|
||||
}).withStagesHints(hints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node createPage(WizardController controller, int step, SettingsMap settings) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> {
|
||||
return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType.getPatchId())), gameInstance.getVersion(), downloadProvider, componentType, () -> {
|
||||
if (oldLibraryVersion == null) {
|
||||
controller.onFinish();
|
||||
} else if ("game".equals(libraryId)) {
|
||||
String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion();
|
||||
controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider));
|
||||
} else if (componentType == GameComponentType.GAME) {
|
||||
GameVersionNumber newGameVersion = ((GameRemoteVersion) settings.get(componentType.getPatchId())).getGameVersion();
|
||||
controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider));
|
||||
} else {
|
||||
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()),
|
||||
Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()),
|
||||
i18n("install.change_version"), controller::onFinish, controller::onCancel);
|
||||
}
|
||||
});
|
||||
@@ -129,40 +136,38 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
public static void alertFailureMessage(Exception exception, Runnable next) {
|
||||
if (exception instanceof LibraryDownloadException) {
|
||||
String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n";
|
||||
if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) exception.getCause();
|
||||
if (exception.getCause() instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
String url = rce.getUrl();
|
||||
if (responseCode == 404)
|
||||
message += i18n("download.code.404", uri);
|
||||
message += i18n("download.code.404", url);
|
||||
else
|
||||
message += i18n("download.failed", uri, responseCode);
|
||||
message += i18n("download.failed", url, responseCode);
|
||||
} else {
|
||||
message += StringUtils.getStackTrace(exception.getCause());
|
||||
}
|
||||
Controllers.dialog(message, i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof DownloadException) {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
} else if (exception instanceof DownloadException de) {
|
||||
String url = de.getUrl();
|
||||
if (exception.getCause() instanceof SocketTimeoutException) {
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause();
|
||||
Controllers.dialog(i18n("install.failed.downloading.timeout", url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), url), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", url) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next);
|
||||
}
|
||||
} else if (exception instanceof UnsupportedInstallationException) {
|
||||
switch (((UnsupportedInstallationException) exception).getReason()) {
|
||||
case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2:
|
||||
} else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) {
|
||||
switch (unsupportedInstallationException.getReason()) {
|
||||
case UnsupportedInstallationException.CLEANROOM_NOT_COMPATIBLE_WITH_FORGE ->
|
||||
Controllers.dialog(i18n("install.failed.cleanroom_not_compatible_with_forge"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2 ->
|
||||
Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
break;
|
||||
default:
|
||||
default ->
|
||||
Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
break;
|
||||
}
|
||||
} else if (exception instanceof DefaultDependencyManager.UnsupportedLibraryInstallerException) {
|
||||
Controllers.dialog(i18n("install.failed.install_online"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
@@ -170,8 +175,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
Controllers.dialog(i18n("install.failed.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) {
|
||||
Controllers.dialog(i18n("assets.index.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof VersionMismatchException) {
|
||||
VersionMismatchException e = ((VersionMismatchException) exception);
|
||||
} else if (exception instanceof VersionMismatchException e) {
|
||||
Controllers.dialog(i18n("install.failed.version_mismatch", e.getExpect(), e.getActual()), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next);
|
||||
} else if (exception instanceof CancellationException) {
|
||||
// Ignore cancel
|
||||
@@ -180,11 +184,6 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public static class RemoveVersionAction {
|
||||
private final String libraryId;
|
||||
|
||||
public RemoveVersionAction(String libraryId) {
|
||||
this.libraryId = libraryId;
|
||||
}
|
||||
public record RemoveComponentAction(GameComponentType componentType) {
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.VersionList;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
@@ -45,6 +44,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
|
||||
import org.jackhuang.hmcl.game.GameComponentType;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -65,6 +65,7 @@ import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -76,31 +77,29 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class VersionsPage extends Control implements WizardPage, Refreshable {
|
||||
private final String gameVersion;
|
||||
private final String libraryId;
|
||||
private final @Nullable GameVersionNumber gameVersion;
|
||||
private final GameComponentType componentType;
|
||||
private final String title;
|
||||
private final Navigation navigation;
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final VersionList<?> versionList;
|
||||
private final Runnable callback;
|
||||
|
||||
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
|
||||
private final ObservableList<ComponentRemoteVersion> versions = FXCollections.observableArrayList();
|
||||
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
|
||||
|
||||
public VersionsPage(Navigation navigation,
|
||||
String title, String gameVersion,
|
||||
String title, GameVersionNumber gameVersion,
|
||||
DownloadProvider downloadProvider,
|
||||
String libraryId,
|
||||
GameComponentType componentType,
|
||||
Runnable callback) {
|
||||
this.title = title;
|
||||
this.gameVersion = gameVersion;
|
||||
this.libraryId = libraryId;
|
||||
this.gameVersion = componentType == GameComponentType.GAME ? null : gameVersion;
|
||||
this.componentType = componentType;
|
||||
this.navigation = navigation;
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.versionList = downloadProvider.getVersionListById(libraryId);
|
||||
this.callback = callback;
|
||||
|
||||
refresh();
|
||||
load(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,9 +109,12 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|
||||
@Override
|
||||
public void refresh() {
|
||||
load(true);
|
||||
}
|
||||
|
||||
private void load(boolean refresh) {
|
||||
status.set(Status.LOADING);
|
||||
Task<?> task = versionList.refreshAsync(gameVersion)
|
||||
.thenSupplyAsync(() -> versionList.getVersions(gameVersion).stream().sorted().collect(Collectors.toList()))
|
||||
Task<?> task = downloadProvider.getVersionsAsync(componentType, gameVersion, refresh)
|
||||
.whenComplete(Schedulers.javafx(), (items, exception) -> {
|
||||
if (exception == null) {
|
||||
versions.setAll(items);
|
||||
@@ -152,12 +154,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
OLD
|
||||
}
|
||||
|
||||
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
|
||||
private static class RemoteVersionListCell extends ListCell<ComponentRemoteVersion> {
|
||||
private final VersionsPage control;
|
||||
|
||||
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final StackPane pane = new StackPane();
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
RemoteVersionListCell(VersionsPage control) {
|
||||
this.control = control;
|
||||
@@ -172,7 +175,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
HBox actions = new HBox(8);
|
||||
actions.setAlignment(Pos.CENTER);
|
||||
{
|
||||
if ("game".equals(control.libraryId)) {
|
||||
if (control.componentType == GameComponentType.GAME) {
|
||||
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
|
||||
wikiButton.setOnAction(event -> onOpenWiki());
|
||||
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
|
||||
@@ -188,22 +191,22 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|
||||
pane.getStyleClass().add("md-list-cell");
|
||||
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
|
||||
pane.getChildren().setAll(new RipplerContainer(hbox));
|
||||
pane.getChildren().setAll(ripplerContainer = new RipplerContainer(hbox));
|
||||
|
||||
FXUtils.onClicked(this, this::onAction);
|
||||
}
|
||||
|
||||
private void onAction() {
|
||||
RemoteVersion item = getItem();
|
||||
ComponentRemoteVersion item = getItem();
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
control.navigation.getSettings().put(control.libraryId, item);
|
||||
control.navigation.getSettings().put(control.componentType.getPatchId(), item);
|
||||
control.callback.run();
|
||||
}
|
||||
|
||||
private void onOpenWiki() {
|
||||
RemoteVersion item = getItem();
|
||||
ComponentRemoteVersion item = getItem();
|
||||
if (!(item instanceof GameRemoteVersion))
|
||||
return;
|
||||
|
||||
@@ -211,19 +214,22 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(RemoteVersion remoteVersion, boolean empty) {
|
||||
RemoteVersion oldRemoteVersion = getItem();
|
||||
public void updateItem(ComponentRemoteVersion remoteVersion, boolean empty) {
|
||||
ComponentRemoteVersion oldRemoteVersion = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(remoteVersion, empty);
|
||||
|
||||
if (oldRemoteVersion == remoteVersion && oldEmpty == empty) return;
|
||||
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
if (empty) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
setGraphic(pane);
|
||||
|
||||
if (oldRemoteVersion == remoteVersion) return;
|
||||
|
||||
twoLineListItem.setTitle(I18n.getDisplayVersion(remoteVersion));
|
||||
if (remoteVersion.getReleaseDate() != null) {
|
||||
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
|
||||
@@ -234,8 +240,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
twoLineListItem.getTags().clear();
|
||||
|
||||
if (remoteVersion instanceof GameRemoteVersion) {
|
||||
RemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
|
||||
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
GameVersionNumber gameVersion = remoteVersion.getGameVersion();
|
||||
|
||||
switch (versionType) {
|
||||
case RELEASE -> {
|
||||
@@ -243,8 +249,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
|
||||
}
|
||||
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
|
||||
if (versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
|
||||
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && gameVersion.isAprilFools()) {
|
||||
twoLineListItem.addTag(i18n("instance.game.april_fools"));
|
||||
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
|
||||
} else {
|
||||
@@ -284,7 +289,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
iconType = GameInstanceIconType.COMMAND;
|
||||
|
||||
imageView.setImage(iconType.getIcon());
|
||||
String displayGameVersion = I18n.getDisplayVersion(GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()));
|
||||
String displayGameVersion = I18n.getDisplayVersion(remoteVersion.getGameVersion());
|
||||
|
||||
if (twoLineListItem.getSubtitle() == null)
|
||||
twoLineListItem.setSubtitle(displayGameVersion);
|
||||
@@ -295,7 +300,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
}
|
||||
|
||||
private static final class VersionsPageSkin extends SkinBase<VersionsPage> {
|
||||
private final JFXListView<RemoteVersion> list;
|
||||
private final JFXListView<ComponentRemoteVersion> list;
|
||||
|
||||
private final TransitionPane transitionPane;
|
||||
private final JFXSpinner spinner;
|
||||
@@ -321,7 +326,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
column2.setMaxWidth(150);
|
||||
ColumnConstraints column3 = new ColumnConstraints();
|
||||
|
||||
if (control.versionList.hasType())
|
||||
if (control.downloadProvider.hasType(control.componentType))
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, nameColumn, column2, column3);
|
||||
else
|
||||
searchPane.getColumnConstraints().setAll(nameColumn, column1, column3);
|
||||
@@ -337,7 +342,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
nameField.setPromptText(i18n("instance.search.prompt"));
|
||||
nameField.textProperty().addListener(o -> updateList());
|
||||
|
||||
if ("game".equals(control.libraryId)) {
|
||||
if (control.componentType == GameComponentType.GAME) {
|
||||
categoryField.getItems().setAll(
|
||||
VersionTypeFilter.ALL,
|
||||
VersionTypeFilter.RELEASE,
|
||||
@@ -360,7 +365,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
|
||||
refreshButton.setOnAction(event -> control.onRefresh());
|
||||
|
||||
if (control.versionList.hasType()) {
|
||||
if (control.downloadProvider.hasType(control.componentType)) {
|
||||
searchPane.addRow(rowIndex++,
|
||||
new Label(i18n("instance.search")), nameField,
|
||||
new Label(i18n("instance.game.type")), categoryField,
|
||||
@@ -453,20 +458,20 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
}
|
||||
|
||||
private void updateList() {
|
||||
Stream<RemoteVersion> versions = getSkinnable().versions.stream();
|
||||
Stream<ComponentRemoteVersion> versions = getSkinnable().versions.stream();
|
||||
|
||||
VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem();
|
||||
if (filter != null)
|
||||
versions = versions.filter(it -> {
|
||||
RemoteVersion.Type versionType = it.getVersionType();
|
||||
ComponentRemoteVersion.Type versionType = it.getVersionType();
|
||||
return switch (filter) {
|
||||
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
|
||||
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|
||||
|| versionType == RemoteVersion.Type.PENDING
|
||||
|| versionType == RemoteVersion.Type.UNOBFUSCATED;
|
||||
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
case OLD -> versionType == RemoteVersion.Type.OLD;
|
||||
case RELEASE -> versionType == ComponentRemoteVersion.Type.RELEASE;
|
||||
case SNAPSHOTS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
|
||||
|| versionType == ComponentRemoteVersion.Type.PENDING
|
||||
|| versionType == ComponentRemoteVersion.Type.UNOBFUSCATED;
|
||||
case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT
|
||||
&& it.getGameVersion().isAprilFools();
|
||||
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
|
||||
// case ALL,
|
||||
default -> true;
|
||||
};
|
||||
|
||||
@@ -19,24 +19,21 @@ package org.jackhuang.hmcl.ui.export;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.modpack.ModAdviser;
|
||||
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCInstanceConfiguration;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardProvider;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -47,12 +44,10 @@ import java.util.List;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.settings;
|
||||
|
||||
public final class ExportWizardProvider implements WizardProvider {
|
||||
private final HMCLGameRepository repository;
|
||||
private final GameInstanceID instanceId;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
|
||||
public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
public ExportWizardProvider(HMCLGameInstance gameInstance) {
|
||||
this.gameInstance = gameInstance;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -71,11 +66,11 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
}
|
||||
|
||||
private Task<?> exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
Path launcherJar = JarUtils.thisJarPath();
|
||||
@Nullable Path launcherJar = JarUtils.thisJarPath();
|
||||
boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null;
|
||||
return new Task<>() {
|
||||
Path tempModpack;
|
||||
Task<?> exportTask;
|
||||
@Nullable Path tempModpack;
|
||||
@Nullable Task<?> exportTask;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
@@ -99,9 +94,6 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
case ModpackTypeSelectionPage.MODPACK_TYPE_MCBBS:
|
||||
exportTask = exportAsMcbbs(exportInfo, dest);
|
||||
break;
|
||||
case ModpackTypeSelectionPage.MODPACK_TYPE_MULTIMC:
|
||||
exportTask = exportAsMultiMC(exportInfo, dest);
|
||||
break;
|
||||
case ModpackTypeSelectionPage.MODPACK_TYPE_SERVER:
|
||||
exportTask = exportAsServer(exportInfo, dest);
|
||||
break;
|
||||
@@ -136,9 +128,12 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
zip.putTextFile(
|
||||
JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class),
|
||||
".hmcl/config/authlib-injector-servers.json");
|
||||
zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
|
||||
|
||||
// Bundled package under .hmcl/modpack/ (not the process workdir).
|
||||
String packageName = ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType)
|
||||
? "modpack.mrpack"
|
||||
: "modpack.zip");
|
||||
: "modpack.zip";
|
||||
zip.putFile(tempModpack, ".hmcl/" + Metadata.BUNDLED_MODPACK_DIRECTORY_NAME + "/" + packageName);
|
||||
|
||||
for (String extension : FontManager.FONT_EXTENSIONS) {
|
||||
String fileName = "font." + extension;
|
||||
@@ -150,6 +145,13 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
}
|
||||
|
||||
zip.putFile(launcherJar, launcherJar.getFileName().toString());
|
||||
} finally {
|
||||
if (tempModpack != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempModpack);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -157,7 +159,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
private Task<?> exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency = null;
|
||||
@Nullable Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
@@ -165,55 +167,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependencies() {
|
||||
return Collections.singleton(dependency);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
|
||||
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
|
||||
new MultiMCInstanceConfiguration(
|
||||
"OneSix",
|
||||
exportInfo.getName() + "-" + exportInfo.getVersion(),
|
||||
null,
|
||||
Lang.toIntOrNull(setting.getInheritable(GameSettings::permSizeProperty)),
|
||||
setting.getInheritable(GameSettings::commandWrapperProperty),
|
||||
setting.getInheritable(GameSettings::preLaunchCommandProperty),
|
||||
null,
|
||||
exportInfo.getDescription(),
|
||||
null,
|
||||
exportInfo.getJavaArguments(),
|
||||
setting.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN,
|
||||
setting.getWidth(),
|
||||
setting.getHeight(),
|
||||
null,
|
||||
exportInfo.getMinMemory(),
|
||||
setting.getInheritable(GameSettings::showLogsProperty),
|
||||
/* showConsoleOnError */ true,
|
||||
/* autoCloseConsole */ false,
|
||||
/* overrideMemory */ true,
|
||||
/* overrideJavaLocation */ false,
|
||||
/* overrideJavaArgs */ true,
|
||||
/* overrideConsole */ true,
|
||||
/* overrideCommands */ true,
|
||||
/* overrideWindow */ true,
|
||||
/* iconKey */ null // TODO
|
||||
), modpackFile);
|
||||
dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -225,7 +179,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
private Task<?> exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
@Nullable Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
@@ -233,7 +187,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile);
|
||||
dependency = new ServerModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -245,7 +199,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
private Task<?> exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
@Nullable Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
@@ -254,8 +208,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new ModrinthModpackExportTask(
|
||||
repository,
|
||||
instanceId,
|
||||
resolveCurrentGameInstance(),
|
||||
exportInfo,
|
||||
modpackFile
|
||||
);
|
||||
@@ -268,12 +221,19 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the current registered snapshot for the instance selected by this wizard.
|
||||
///
|
||||
/// @return the current registered instance
|
||||
private HMCLGameInstance resolveCurrentGameInstance() {
|
||||
return gameInstance.getRepository().getInstance(gameInstance.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node createPage(WizardController controller, int step, SettingsMap settings) {
|
||||
return switch (step) {
|
||||
case 0 -> new ModpackTypeSelectionPage(controller);
|
||||
case 1 -> new ModpackInfoPage(controller, repository, instanceId);
|
||||
case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod);
|
||||
case 1 -> new ModpackInfoPage(controller, gameInstance);
|
||||
case 2 -> new ModpackFileSelectionPage(controller, gameInstance, ModAdviser::suggestMod);
|
||||
default -> throw new IllegalArgumentException("step");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,18 +18,18 @@
|
||||
package org.jackhuang.hmcl.ui.export;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXCheckTreeCell;
|
||||
import com.jfoenix.controls.JFXTreeView;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.CheckBoxTreeItem;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.modpack.ModAdviser;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -41,6 +41,7 @@ import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -62,16 +63,18 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
*/
|
||||
public final class ModpackFileSelectionPage extends BorderPane implements WizardPage {
|
||||
private final WizardController controller;
|
||||
private final GameInstanceID instanceId;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final ModAdviser adviser;
|
||||
private @Nullable ModpackFileTreeItem rootNode;
|
||||
|
||||
public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) {
|
||||
/// Creates a file-selection page and starts loading the instance's exportable files.
|
||||
public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) {
|
||||
this.controller = controller;
|
||||
this.instanceId = instanceId;
|
||||
this.gameInstance = gameInstance;
|
||||
this.adviser = adviser;
|
||||
|
||||
JFXTreeView<String> treeView = new JFXTreeView<>();
|
||||
treeView.setCellFactory(__ -> new ModpackFileTreeCell());
|
||||
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
onEscPressed(treeView, () -> controller.onPrev(true));
|
||||
|
||||
@@ -97,17 +100,17 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
|
||||
btnNext.setOnAction(e -> onNext());
|
||||
nextPane.getChildren().setAll(btnNext);
|
||||
|
||||
loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext);
|
||||
spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext));
|
||||
loadRoot(treeView, placeholderPane, spinnerPane, btnNext);
|
||||
spinnerPane.setOnFailedAction((__) -> loadRoot(treeView, placeholderPane, spinnerPane, btnNext));
|
||||
|
||||
this.setBottom(nextPane);
|
||||
}
|
||||
|
||||
private void loadRoot(HMCLGameRepository repository, JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
|
||||
private void loadRoot(JFXTreeView<String> treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) {
|
||||
spinnerPane.setLoading(true);
|
||||
btnNext.setDisable(true);
|
||||
CompletableFuture
|
||||
.supplyAsync(() -> getTreeItem(repository.getRunDirectory(instanceId), "minecraft", 0), Schedulers.io())
|
||||
.supplyAsync(() -> getTreeItem(gameInstance.getRunDirectory(), "minecraft", 0), Schedulers.io())
|
||||
.whenCompleteAsync((root, throwable) -> {
|
||||
if (throwable == null) {
|
||||
if (root != null) {
|
||||
@@ -145,12 +148,12 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
|
||||
}
|
||||
if (fileName.startsWith("._")) // macOS system file
|
||||
state = ModAdviser.ModSuggestion.HIDDEN;
|
||||
if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString()))
|
||||
if (FileUtils.getNameWithoutExtension(file).equals(gameInstance.getId().toString()))
|
||||
state = ModAdviser.ModSuggestion.HIDDEN;
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
if (fileName.equals(instanceId + "-natives")) { // Ignore <version>-natives
|
||||
if (fileName.equals(gameInstance.getId() + "-natives")) { // Ignore <version>-natives
|
||||
state = ModAdviser.ModSuggestion.HIDDEN;
|
||||
}
|
||||
if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch
|
||||
@@ -161,7 +164,7 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
|
||||
return null;
|
||||
}
|
||||
|
||||
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
|
||||
ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? gameInstance.getId().toString() : StringUtils.substringAfterLast(basePath, '/'), basePath);
|
||||
if (state == ModAdviser.ModSuggestion.SUGGESTED)
|
||||
node.setSelected(true);
|
||||
|
||||
@@ -195,14 +198,16 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
|
||||
return node;
|
||||
}
|
||||
|
||||
private void getFilesNeeded(ModpackFileTreeItem node, String basePath, List<String> list) {
|
||||
/// Appends selected paths relative to `minecraft/`, traversing partially selected directories.
|
||||
/// A `null` node contributes no paths.
|
||||
private void getFilesNeeded(@Nullable ModpackFileTreeItem node, String basePath, List<String> list) {
|
||||
if (node == null) return;
|
||||
if (node.isSelected() || node.isIndeterminate()) {
|
||||
if (basePath.length() > "minecraft/".length())
|
||||
list.add(StringUtils.substringAfter(basePath, "minecraft/"));
|
||||
for (TreeItem<String> child : node.getChildren()) {
|
||||
if (child instanceof ModpackFileTreeItem mChild) {
|
||||
getFilesNeeded(mChild, basePath + "/" + mChild.getFileName(), list);
|
||||
getFilesNeeded(mChild, basePath + "/" + mChild.getValue(), list);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,39 +248,69 @@ public final class ModpackFileSelectionPage extends BorderPane implements Wizard
|
||||
pair("minecraft/scripts", i18n("modpack.files.scripts"))
|
||||
);
|
||||
|
||||
/// Stores a file name, optional explanation, and hierarchical selection state.
|
||||
@NotNullByDefault
|
||||
private static final class ModpackFileTreeItem extends CheckBoxTreeItem<String> {
|
||||
|
||||
private final String fileName;
|
||||
/// The localized explanation for a recognized path, or `null` if none exists.
|
||||
private final @Nullable String comment;
|
||||
|
||||
/// Creates an item whose value is the file name; only the `minecraft` root starts expanded.
|
||||
public ModpackFileTreeItem(String fileName, String basePath) {
|
||||
this.fileName = fileName;
|
||||
|
||||
HBox graphic = new HBox();
|
||||
JFXCheckBox checkBox = new JFXCheckBox();
|
||||
checkBox.selectedProperty().bindBidirectional(this.selectedProperty());
|
||||
checkBox.indeterminateProperty().bindBidirectional(this.indeterminateProperty());
|
||||
graphic.getChildren().add(checkBox);
|
||||
|
||||
{
|
||||
Label text = new Label(fileName);
|
||||
text.setMouseTransparent(true);
|
||||
graphic.getChildren().add(text);
|
||||
}
|
||||
if (TRANSLATION.containsKey(basePath)) {
|
||||
Label comment = new Label(TRANSLATION.get(basePath));
|
||||
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
|
||||
comment.setMouseTransparent(true);
|
||||
graphic.getChildren().add(comment);
|
||||
}
|
||||
graphic.setPickOnBounds(false);
|
||||
super(fileName);
|
||||
this.comment = TRANSLATION.get(basePath);
|
||||
this.setExpanded("minecraft".equals(basePath));
|
||||
this.setValue(""); // To disable the default display of text
|
||||
this.setGraphic(graphic);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders file names and localized explanations alongside the bound checkbox.
|
||||
@NotNullByDefault
|
||||
private static final class ModpackFileTreeCell extends JFXCheckTreeCell<String> {
|
||||
|
||||
/// Contains the inherited checkbox graphic followed by the file labels.
|
||||
private final HBox content = new HBox();
|
||||
|
||||
/// Displays the current file name.
|
||||
private final Label fileName = new Label();
|
||||
|
||||
/// Displays the current path's optional explanation.
|
||||
private final Label comment = new Label();
|
||||
|
||||
/// Creates reusable labels whose mouse events pass through to the cell.
|
||||
private ModpackFileTreeCell() {
|
||||
fileName.setMouseTransparent(true);
|
||||
comment.setStyle("-fx-text-fill: -monet-on-surface-variant;");
|
||||
comment.setMouseTransparent(true);
|
||||
content.setAlignment(Pos.CENTER_LEFT);
|
||||
content.setPickOnBounds(false);
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
/// Refreshes the file labels and removes content when the cell is cleared.
|
||||
@Override
|
||||
protected void updateDisplay(@Nullable String item, boolean empty) {
|
||||
content.getChildren().clear();
|
||||
super.updateDisplay(item, empty);
|
||||
|
||||
fileName.setText(null);
|
||||
comment.setText(null);
|
||||
if (empty || item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Updating the text makes the skin reattach the current graphic to the cell.
|
||||
// Clear it before moving the checkbox graphic into the content container.
|
||||
setText(null);
|
||||
@Nullable Node graphic = getGraphic();
|
||||
if (graphic != null) {
|
||||
content.getChildren().add(graphic);
|
||||
}
|
||||
fileName.setText(item);
|
||||
content.getChildren().add(fileName);
|
||||
if (getTreeItem() instanceof ModpackFileTreeItem treeItem && treeItem.comment != null) {
|
||||
comment.setText(treeItem.comment);
|
||||
content.getChildren().add(comment);
|
||||
}
|
||||
setGraphic(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@ import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
@@ -65,9 +64,8 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
private final WizardController controller;
|
||||
private final HMCLGameRepository repository;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final ModpackExportInfo.Options options;
|
||||
private final GameInstanceID instanceId;
|
||||
private final boolean canIncludeLauncher;
|
||||
|
||||
private final ModpackExportInfo exportInfo = new ModpackExportInfo();
|
||||
@@ -88,19 +86,18 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty();
|
||||
private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty();
|
||||
|
||||
public ModpackInfoPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstance) {
|
||||
this.controller = controller;
|
||||
this.repository = repository;
|
||||
this.gameInstance = gameInstance;
|
||||
this.options = controller.getSettings().get(MODPACK_INFO_OPTION);
|
||||
this.instanceId = instanceId;
|
||||
|
||||
if (this.options == null)
|
||||
throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required");
|
||||
|
||||
name.set(instanceId.toString());
|
||||
name.set(gameInstance.getId().toString());
|
||||
author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse(""));
|
||||
|
||||
GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId);
|
||||
GameSettings.Effective versionSetting = gameInstance.getEffectiveSettings();
|
||||
minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0));
|
||||
launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty));
|
||||
javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty));
|
||||
@@ -213,7 +210,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
|
||||
var instanceNamePane = new LineTextPane();
|
||||
{
|
||||
instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version"));
|
||||
instanceNamePane.setText(skinnable.instanceId.toString());
|
||||
instanceNamePane.setText(skinnable.gameInstance.getId().toString());
|
||||
|
||||
list.getContent().add(instanceNamePane);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.modpack.ModpackExportInfo;
|
||||
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
|
||||
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardController;
|
||||
@@ -55,9 +54,8 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
|
||||
this.getChildren().setAll(
|
||||
title,
|
||||
createButton(MODPACK_TYPE_MCBBS, McbbsModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_MULTIMC, MultiMCModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION)
|
||||
createButton(MODPACK_TYPE_MODRINTH, ModrinthModpackExportTask.OPTION),
|
||||
createButton(MODPACK_TYPE_SERVER, ServerModpackExportTask.OPTION)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +96,6 @@ public final class ModpackTypeSelectionPage extends VBox implements WizardPage {
|
||||
public static final SettingsMap.Key<String> MODPACK_TYPE = new SettingsMap.Key<>("modpack.type");
|
||||
|
||||
public static final String MODPACK_TYPE_MCBBS = "mcbbs";
|
||||
public static final String MODPACK_TYPE_MULTIMC = "multimc";
|
||||
public static final String MODPACK_TYPE_SERVER = "server";
|
||||
public static final String MODPACK_TYPE_MODRINTH = "modrinth";
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
@@ -76,7 +77,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware {
|
||||
implements DecoratorPage, PageAware {
|
||||
|
||||
private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object();
|
||||
private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden");
|
||||
@@ -88,20 +89,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", new State("", null, false, false, false));
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
/// The selected game directory.
|
||||
private @Nullable GameDirectory gameDirectory;
|
||||
|
||||
/// The selected repository.
|
||||
private @Nullable HMCLGameRepository repository;
|
||||
|
||||
/// The current instance ID.
|
||||
private @Nullable GameInstanceID instanceId;
|
||||
/// The current game instance when editing instance settings, or `null` for preset settings.
|
||||
private final ObjectProperty<@Nullable HMCLGameInstance> gameInstance =
|
||||
new SimpleObjectProperty<>(this, "gameInstance");
|
||||
|
||||
/// The current setting.
|
||||
private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting");
|
||||
|
||||
private final ObjectProperty<GameVersionNumber> currentGameVersionNumber = new SimpleObjectProperty<>(this, "currentGameVersionNumber", GameVersionNumber.unknown());
|
||||
|
||||
private boolean updatingJavaSetting = false;
|
||||
private boolean updatingSelectedJava = false;
|
||||
private boolean updatingParentSetting = false;
|
||||
@@ -129,12 +123,24 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
private final InvalidationListener javaListener = o -> refreshJavaSettings();
|
||||
private final InvalidationListener weakJavaListener = holder.weak(javaListener);
|
||||
|
||||
public GameSettingsPage(Class<S> settingType) {
|
||||
/// Creates a settings page.
|
||||
///
|
||||
/// @param settingType [GameSettings.Instance] or [GameSettings.Preset]
|
||||
/// @param instanceContext parent instance property for instance settings; ignored for presets and may be `null`
|
||||
public GameSettingsPage(
|
||||
Class<S> settingType,
|
||||
@Nullable ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class;
|
||||
|
||||
this.isPresetSetting = settingType == GameSettings.Preset.class;
|
||||
if (!isPresetSetting) {
|
||||
bindActiveParentSetting();
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
holder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
this.scrollPane = new ScrollPane();
|
||||
@@ -790,21 +796,23 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
var highPerformancePane = createInheritableBooleanButton(GameSettings::highPerformanceProperty);
|
||||
graphicsSettings.getContent().add(highPerformancePane);
|
||||
highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences"));
|
||||
highPerformancePane.setSubtitle(i18n("settings.advanced.windows_only"));
|
||||
|
||||
this.currentGameVersionNumber.addListener((o, oldValue, newValue) -> {
|
||||
boolean showBackendChoose = isPresetSetting || newValue.compareTo("26.2-snapshot-2") >= 0;
|
||||
InvalidationListener updateGraphicsVisibility = o -> {
|
||||
GameVersionNumber version = currentGameVersion();
|
||||
boolean showBackendChoose = isPresetSetting || version.compareTo("26.2-snapshot-2") >= 0;
|
||||
graphicsBackendPane.setVisible(showBackendChoose);
|
||||
graphicsBackendPane.setManaged(showBackendChoose);
|
||||
|
||||
boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(newValue);
|
||||
boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(version);
|
||||
openGLRendererPane.setVisible(showOpenGL);
|
||||
openGLRendererPane.setManaged(showOpenGL);
|
||||
|
||||
boolean showVulkan = GraphicsAPI.VULKAN.isSupported(newValue);
|
||||
boolean showVulkan = isPresetSetting || GraphicsAPI.VULKAN.isSupported(version);
|
||||
vulkanRendererPane.setVisible(showVulkan);
|
||||
vulkanRendererPane.setManaged(showVulkan);
|
||||
});
|
||||
};
|
||||
this.gameInstance.addListener(updateGraphicsVisibility);
|
||||
updateGraphicsVisibility.invalidated(this.gameInstance);
|
||||
}
|
||||
|
||||
var nativeLibrarySettings = new ComponentList();
|
||||
@@ -821,20 +829,24 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
nativeLibrarySettings.getContent().add(nativesDirPane);
|
||||
nativesDirPane.setTitle(i18n("settings.advanced.natives_directory"));
|
||||
{
|
||||
var txtNativesDir = new JFXTextField();
|
||||
txtNativesDir.setPrefWidth(400);
|
||||
nativesDirPane.setRight(txtNativesDir);
|
||||
bindIndependentTextField(nativesDirPane, txtNativesDir, GameSettings::nativesDirectoryProperty);
|
||||
var nativesDirSelector = new FileSelector()
|
||||
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
|
||||
nativesDirSelector.setPrefWidth(400);
|
||||
nativesDirPane.setRight(nativesDirSelector);
|
||||
bindInheritableStringProperty(
|
||||
nativesDirPane,
|
||||
nativesDirSelector.valueProperty(),
|
||||
GameSettings::nativesDirectoryProperty);
|
||||
}
|
||||
|
||||
var noNativesPatchPane = createIndependentBooleanButton(GameSettings::notPatchNativesProperty);
|
||||
nativeLibrarySettings.getContent().add(noNativesPatchPane);
|
||||
noNativesPatchPane.setTitle(i18n("settings.advanced.dont_patch_natives"));
|
||||
|
||||
var useNativeGLFWPane = createIndependentBooleanButton(GameSettings::useNativeGLFWProperty);
|
||||
nativeLibrarySettings.getContent().add(useNativeGLFWPane);
|
||||
useNativeGLFWPane.setTitle(i18n("settings.advanced.use_native_glfw"));
|
||||
useNativeGLFWPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
|
||||
var useNativeGLFWORsdlPane = createIndependentBooleanButton(GameSettings::useNativeGLFWorSDLProperty);
|
||||
nativeLibrarySettings.getContent().add(useNativeGLFWORsdlPane);
|
||||
useNativeGLFWORsdlPane.setTitle(i18n("settings.advanced.use_native_glfw_or_sdl"));
|
||||
useNativeGLFWORsdlPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only"));
|
||||
|
||||
var useNativeOpenALPane = createIndependentBooleanButton(GameSettings::useNativeOpenALProperty);
|
||||
nativeLibrarySettings.getContent().add(useNativeOpenALPane);
|
||||
@@ -1860,16 +1872,20 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
|
||||
private boolean isCurrentInstanceModpack() {
|
||||
return repository != null && instanceId != null && repository.isModpack(instanceId);
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
return gameInstance != null && gameInstance.isModpack();
|
||||
}
|
||||
|
||||
/// Returns the current instance version root displayed for modpack running directories.
|
||||
private String getCurrentInstanceVersionRoot() {
|
||||
if (repository == null || instanceId == null) {
|
||||
return "";
|
||||
}
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
return gameInstance != null ? gameInstance.getInstanceRoot().toString() : "";
|
||||
}
|
||||
|
||||
return repository.getInstanceRoot(instanceId).toString();
|
||||
/// Returns the Minecraft version of the loaded instance, or [GameVersionNumber#unknown()] for presets.
|
||||
private GameVersionNumber currentGameVersion() {
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
return gameInstance != null ? gameInstance.getVersion() : GameVersionNumber.unknown();
|
||||
}
|
||||
|
||||
/// Keeps a listener attached to the current instance's parent preset property.
|
||||
@@ -2589,8 +2605,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
|
||||
/// Returns the runtime parent preset for an instance, including the game directory's migrated preset fallback.
|
||||
private GameSettings.Preset getEffectiveParentGameSettings(GameSettings.Instance instance) {
|
||||
if (repository != null) {
|
||||
return repository.getParentGameSettings(instance);
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
if (gameInstance != null) {
|
||||
return gameInstance.getRepository().getParentGameSettings(instance);
|
||||
}
|
||||
|
||||
return getExplicitParentGameSettings(instance);
|
||||
@@ -2629,26 +2646,21 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.gameDirectory = repository.getGameDirectory();
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
HMCLGameInstance gameInstance = instance.instance();
|
||||
this.gameInstance.set(gameInstance);
|
||||
|
||||
assert isPresetSetting == (instanceId == null);
|
||||
assert isPresetSetting == (gameInstance == null);
|
||||
|
||||
if (instanceId != null) {
|
||||
this.currentGameVersionNumber.set(GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId)));
|
||||
|
||||
@Nullable GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId);
|
||||
if (gameInstance != null) {
|
||||
@Nullable GameSettings.Instance setting = gameInstance.getSettingsOrCreate();
|
||||
this.currentSetting.set((S) setting);
|
||||
setSettingsReadOnly(
|
||||
setting == null || repository.isInstanceGameSettingsReadOnly(instanceId),
|
||||
setting == null || gameInstance.isSettingsReadOnly(),
|
||||
i18n("settings.game.instance_settings.unsupported"),
|
||||
setting != null ? this::forceOverwriteInstanceGameSettings : null);
|
||||
loadIcon();
|
||||
} else {
|
||||
this.currentGameVersionNumber.set(GameVersionNumber.unknown());
|
||||
this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate());
|
||||
setSettingsReadOnly(
|
||||
SettingsManager.isGameSettingsReadOnly(),
|
||||
@@ -2657,11 +2669,6 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the loaded instance ID, or `null` when this page edits preset settings.
|
||||
private @Nullable GameInstanceID getLoadedInstanceId() {
|
||||
return instanceId == null ? null : instanceId;
|
||||
}
|
||||
|
||||
/// Updates the page read-only state used when settings cannot be saved safely.
|
||||
///
|
||||
/// @param readOnly whether the current settings should be displayed read-only
|
||||
@@ -2701,13 +2708,13 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
|
||||
/// Backs up and overwrites the current instance's `instance-game-settings.json`.
|
||||
private void forceOverwriteInstanceGameSettings() {
|
||||
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
|
||||
if (repository == null || loadedInstanceId == null) {
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> {
|
||||
repository.forceOverwriteInstanceGameSettings(loadedInstanceId);
|
||||
gameInstance.forceOverwriteSettings();
|
||||
setSettingsReadOnly(false, "");
|
||||
});
|
||||
}
|
||||
@@ -2721,11 +2728,12 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
|
||||
private void loadIcon() {
|
||||
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
|
||||
if (repository == null || loadedInstanceId == null)
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId));
|
||||
iconPickerItem.setImage(gameInstance.getIconImage());
|
||||
}
|
||||
|
||||
/// Refreshes Java selection controls and keeps inherited parent Java properties observed.
|
||||
@@ -2789,17 +2797,19 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
private void initJavaSubtitle() {
|
||||
S setting = currentSetting.get();
|
||||
|
||||
if (setting == null || gameDirectory == null)
|
||||
if (setting == null) {
|
||||
return;
|
||||
}
|
||||
initializeSelectedJava();
|
||||
|
||||
JavaVersionType javaVersionType = setting.javaTypeProperty().getValue();
|
||||
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
|
||||
GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null;
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
@Nullable GameSettings.Effective effectiveSetting =
|
||||
gameInstance != null ? gameInstance.getEffectiveSettings() : null;
|
||||
JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType;
|
||||
boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION;
|
||||
|
||||
if (instanceId == null && autoSelected) {
|
||||
if (gameInstance == null && autoSelected) {
|
||||
javaSublist.setDescription(i18n("settings.game.java_directory.auto"));
|
||||
return;
|
||||
}
|
||||
@@ -2811,13 +2821,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
|
||||
if (JavaManager.isInitialized()) {
|
||||
GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get();
|
||||
GameInstanceManifest manifest;
|
||||
if (this.instanceId == null) {
|
||||
manifest = null;
|
||||
} else {
|
||||
manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null;
|
||||
}
|
||||
GameVersionNumber gameVersionNumber = currentGameVersion();
|
||||
GameInstanceManifest manifest = gameInstance != null ? gameInstance.getResolvedManifest() : null;
|
||||
|
||||
try {
|
||||
JavaRuntime java = effectiveSetting != null
|
||||
@@ -2837,19 +2842,21 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
}
|
||||
|
||||
private void onExploreIcon() {
|
||||
if (repository == null || instanceId == null)
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
|
||||
Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon));
|
||||
}
|
||||
Controllers.dialog(new GameInstanceIconDialog(gameInstance, this::loadIcon));
|
||||
}
|
||||
|
||||
private void onDeleteIcon() {
|
||||
@Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId();
|
||||
if (repository == null || loadedInstanceId == null)
|
||||
HMCLGameInstance gameInstance = this.gameInstance.get();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.deleteIconFile(loadedInstanceId);
|
||||
GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId);
|
||||
gameInstance.deleteIconFile();
|
||||
GameSettings.Instance localGameSettings = gameInstance.getSettingsOrCreate();
|
||||
if (localGameSettings != null) {
|
||||
localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.jackhuang.hmcl.ui.construct.LineInheritableToggleButton;
|
||||
import org.jackhuang.hmcl.ui.construct.RadioChoiceList;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.platform.SystemInfo;
|
||||
import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
@@ -621,7 +622,7 @@ final class IndependentSettingBinder {
|
||||
|
||||
long memoryAllocated;
|
||||
if (Boolean.TRUE.equals(autoMemory)) {
|
||||
memoryAllocated = HMCLGameRepository.getAutoAllocatedMemory(memoryStatus.available());
|
||||
memoryAllocated = HMCLGameRepository.getAutoAllocatedMemory(memoryStatus.available(), Platform.SYSTEM_PLATFORM);
|
||||
} else if (maxMemory != null && maxMemory > 0) {
|
||||
memoryAllocated = maxMemory * 1024L * 1024L;
|
||||
} else {
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -50,13 +51,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
/// Provides image-format detection and loaders for image formats not handled
|
||||
/// directly by JavaFX.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class ImageUtils {
|
||||
|
||||
// ImageLoaders
|
||||
@@ -81,38 +85,37 @@ public final class ImageUtils {
|
||||
LoaderParameters parameters = new LoaderParameters();
|
||||
parameters.autoStartAnimations = false;
|
||||
|
||||
SVGImage image;
|
||||
Supplier<Image> supplier = () -> {
|
||||
SVGImage image = SVGLoader.load(content, parameters);
|
||||
var snapshotParameters = new SnapshotParameters();
|
||||
snapshotParameters.setFill(Color.TRANSPARENT);
|
||||
|
||||
if (requestedWidth <= 0. || requestedHeight <= 0.) {
|
||||
return image.toImage(snapshotParameters);
|
||||
}
|
||||
|
||||
double scaleX = requestedWidth / image.getScaledWidth();
|
||||
double scaleY = requestedHeight / image.getScaledHeight();
|
||||
|
||||
if (preserveRatio || scaleX == scaleY) {
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
return image.scale(scale).toImage(snapshotParameters);
|
||||
} else {
|
||||
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
|
||||
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable Image image;
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
image = SVGLoader.load(content, parameters);
|
||||
image = supplier.get();
|
||||
} else {
|
||||
// TODO: Currently, SVGLoader.load(...) requires the javafx.swing module if it operates on a non-JavaFX thread.
|
||||
image = CompletableFuture.supplyAsync(
|
||||
() -> SVGLoader.load(content, parameters),
|
||||
Schedulers.javafx()
|
||||
).get();
|
||||
image = CompletableFuture.supplyAsync(supplier, Schedulers.javafx()).get();
|
||||
}
|
||||
|
||||
if (image == null)
|
||||
throw new IOException("Failed to load SVG image");
|
||||
|
||||
var snapshotParameters = new SnapshotParameters();
|
||||
snapshotParameters.setFill(Color.TRANSPARENT);
|
||||
|
||||
if (requestedWidth <= 0. || requestedHeight <= 0.) {
|
||||
return image.toImage(snapshotParameters);
|
||||
}
|
||||
|
||||
double scaleX = requestedWidth / image.getScaledWidth();
|
||||
double scaleY = requestedHeight / image.getScaledHeight();
|
||||
|
||||
if (preserveRatio || scaleX == scaleY) {
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
return image.scale(scale).toImage(snapshotParameters);
|
||||
} else {
|
||||
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
|
||||
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
|
||||
}
|
||||
return image;
|
||||
};
|
||||
|
||||
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
@@ -198,6 +201,7 @@ public final class ImageUtils {
|
||||
|
||||
// ------
|
||||
|
||||
/// Maximum number of leading bytes inspected when selecting an image loader.
|
||||
public static final int HEADER_BUFFER_SIZE = 1024;
|
||||
|
||||
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
|
||||
@@ -209,12 +213,272 @@ public final class ImageUtils {
|
||||
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
|
||||
}
|
||||
|
||||
private static final byte[] SVG_HEADER = "<svg".getBytes(StandardCharsets.US_ASCII);
|
||||
/// ASCII local name of an SVG document element.
|
||||
private static final String SVG_ELEMENT_NAME = "svg";
|
||||
|
||||
// This is currently a simple check, more complex checks can be considered in the future
|
||||
/// ASCII prefix of an XML comment.
|
||||
private static final String XML_COMMENT_START = "<!--";
|
||||
|
||||
/// ASCII suffix of an XML comment.
|
||||
private static final String XML_COMMENT_END = "-->";
|
||||
|
||||
/// ASCII prefix of an XML processing instruction.
|
||||
private static final String XML_PROCESSING_INSTRUCTION_START = "<?";
|
||||
|
||||
/// ASCII suffix of an XML processing instruction.
|
||||
private static final String XML_PROCESSING_INSTRUCTION_END = "?>";
|
||||
|
||||
/// Case-sensitive ASCII prefix of an XML document type declaration.
|
||||
private static final String XML_DOCTYPE_START = "<!DOCTYPE";
|
||||
|
||||
/// Returns whether the supplied prefix identifies an SVG XML document.
|
||||
///
|
||||
/// An optional UTF-8 byte-order mark, XML whitespace, comments, processing
|
||||
/// instructions, and a document type declaration may precede the document
|
||||
/// element. The method identifies the document element only; it does not
|
||||
/// validate the remainder of the XML document. XML markup must use its
|
||||
/// ASCII byte representation; UTF-16 and UTF-32 prefixes are not recognized.
|
||||
///
|
||||
/// @param headerBuffer leading bytes of the candidate image
|
||||
/// @return whether the first XML document element is `svg`
|
||||
public static boolean isSVG(byte[] headerBuffer) {
|
||||
return headerBuffer.length > SVG_HEADER.length
|
||||
&& Arrays.equals(headerBuffer, 0, SVG_HEADER.length, SVG_HEADER, 0, SVG_HEADER.length);
|
||||
int offset = hasUtf8ByteOrderMark(headerBuffer) ? 3 : 0;
|
||||
|
||||
while (true) {
|
||||
offset = skipXmlWhitespace(headerBuffer, offset);
|
||||
|
||||
if (isSvgElementStart(headerBuffer, offset))
|
||||
return true;
|
||||
|
||||
if (matchesAscii(headerBuffer, offset, XML_COMMENT_START)) {
|
||||
offset = indexAfterAscii(
|
||||
headerBuffer,
|
||||
offset + XML_COMMENT_START.length(),
|
||||
XML_COMMENT_END
|
||||
);
|
||||
} else if (matchesAscii(headerBuffer, offset, XML_PROCESSING_INSTRUCTION_START)) {
|
||||
offset = indexAfterAscii(
|
||||
headerBuffer,
|
||||
offset + XML_PROCESSING_INSTRUCTION_START.length(),
|
||||
XML_PROCESSING_INSTRUCTION_END
|
||||
);
|
||||
} else if (isDoctypeStart(headerBuffer, offset)) {
|
||||
offset = skipDoctype(headerBuffer, offset);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (offset < 0)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the byte array starts with a UTF-8 byte-order mark.
|
||||
///
|
||||
/// @param data bytes to inspect
|
||||
/// @return whether `data` begins with `EF BB BF`
|
||||
private static boolean hasUtf8ByteOrderMark(byte[] data) {
|
||||
return svgScanLimit(data) >= 3
|
||||
&& data[0] == (byte) 0xef
|
||||
&& data[1] == (byte) 0xbb
|
||||
&& data[2] == (byte) 0xbf;
|
||||
}
|
||||
|
||||
/// Returns the bounded length available to SVG detection.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @return the lesser of the array length and [#HEADER_BUFFER_SIZE]
|
||||
private static int svgScanLimit(byte[] data) {
|
||||
return Math.min(data.length, HEADER_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
/// Skips XML whitespace from an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start inclusive offset at which to begin
|
||||
/// @return the first offset that does not contain XML whitespace
|
||||
private static int skipXmlWhitespace(byte[] data, int start) {
|
||||
int limit = svgScanLimit(data);
|
||||
while (start < limit && isXmlWhitespace(data[start]))
|
||||
start++;
|
||||
return start;
|
||||
}
|
||||
|
||||
/// Returns whether an SVG document-element qualified name begins at an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of a possible element opening delimiter
|
||||
/// @return whether the unprefixed or prefixed element has the local name `svg`
|
||||
private static boolean isSvgElementStart(byte[] data, int start) {
|
||||
int limit = svgScanLimit(data);
|
||||
if (start < 0 || start >= limit || data[start] != '<')
|
||||
return false;
|
||||
|
||||
int nameStart = start + 1;
|
||||
int localNameStart = nameStart;
|
||||
boolean hasPrefix = false;
|
||||
|
||||
for (int index = nameStart; index < limit; index++) {
|
||||
byte value = data[index];
|
||||
if (value == '>' || isXmlWhitespace(value)
|
||||
|| value == '/' && index + 1 < limit && data[index + 1] == '>') {
|
||||
return index - localNameStart == SVG_ELEMENT_NAME.length()
|
||||
&& matchesAscii(data, localNameStart, SVG_ELEMENT_NAME);
|
||||
}
|
||||
|
||||
if (value == ':') {
|
||||
if (hasPrefix || index == nameStart)
|
||||
return false;
|
||||
hasPrefix = true;
|
||||
localNameStart = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean nameStartCharacter = index == nameStart || index == localNameStart;
|
||||
if (nameStartCharacter
|
||||
? !isAsciiXmlNameStart(value)
|
||||
: !isAsciiXmlNamePart(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns whether a byte can begin an ASCII XML name component.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is an ASCII letter or underscore
|
||||
private static boolean isAsciiXmlNameStart(byte value) {
|
||||
return value >= 'A' && value <= 'Z'
|
||||
|| value >= 'a' && value <= 'z'
|
||||
|| value == '_';
|
||||
}
|
||||
|
||||
/// Returns whether a byte can continue an ASCII XML name component.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is accepted after the first name character
|
||||
private static boolean isAsciiXmlNamePart(byte value) {
|
||||
return isAsciiXmlNameStart(value)
|
||||
|| value >= '0' && value <= '9'
|
||||
|| value == '-'
|
||||
|| value == '.';
|
||||
}
|
||||
|
||||
/// Returns whether an XML document type declaration begins at an offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of a possible declaration
|
||||
/// @return whether the case-sensitive keyword is followed by XML whitespace
|
||||
private static boolean isDoctypeStart(byte[] data, int start) {
|
||||
int keywordEnd = start + XML_DOCTYPE_START.length();
|
||||
return matchesAscii(data, start, XML_DOCTYPE_START)
|
||||
&& keywordEnd < svgScanLimit(data)
|
||||
&& isXmlWhitespace(data[keywordEnd]);
|
||||
}
|
||||
|
||||
/// Skips an XML document type declaration, including its internal subset.
|
||||
///
|
||||
/// Quoted values, comments, and processing instructions are ignored while
|
||||
/// locating the declaration-closing `>`.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of the `<!DOCTYPE` prefix
|
||||
/// @return the offset after the declaration, or `-1` if it is incomplete
|
||||
private static int skipDoctype(byte[] data, int start) {
|
||||
int internalSubsetDepth = 0;
|
||||
byte quote = 0;
|
||||
int limit = svgScanLimit(data);
|
||||
|
||||
for (int index = start + XML_DOCTYPE_START.length(); index < limit; index++) {
|
||||
byte value = data[index];
|
||||
|
||||
if (quote != 0) {
|
||||
if (value == quote)
|
||||
quote = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value == '\'' || value == '"') {
|
||||
quote = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesAscii(data, index, XML_COMMENT_START)) {
|
||||
int commentEnd = indexAfterAscii(
|
||||
data,
|
||||
index + XML_COMMENT_START.length(),
|
||||
XML_COMMENT_END
|
||||
);
|
||||
if (commentEnd < 0)
|
||||
return -1;
|
||||
index = commentEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesAscii(data, index, XML_PROCESSING_INSTRUCTION_START)) {
|
||||
int instructionEnd = indexAfterAscii(
|
||||
data,
|
||||
index + XML_PROCESSING_INSTRUCTION_START.length(),
|
||||
XML_PROCESSING_INSTRUCTION_END
|
||||
);
|
||||
if (instructionEnd < 0)
|
||||
return -1;
|
||||
index = instructionEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value == '[') {
|
||||
internalSubsetDepth++;
|
||||
} else if (value == ']' && internalSubsetDepth > 0) {
|
||||
internalSubsetDepth--;
|
||||
} else if (value == '>' && internalSubsetDepth == 0) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Finds an ASCII sequence in a byte array.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start inclusive offset at which to begin searching
|
||||
/// @param asciiValue ASCII sequence to find
|
||||
/// @return the offset after the first match, or `-1` if no match exists
|
||||
private static int indexAfterAscii(byte[] data, int start, String asciiValue) {
|
||||
int lastStart = svgScanLimit(data) - asciiValue.length();
|
||||
for (int index = start; index <= lastStart; index++) {
|
||||
if (matchesAscii(data, index, asciiValue))
|
||||
return index + asciiValue.length();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Compares an ASCII string with bytes at an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset at which to compare
|
||||
/// @param asciiValue ASCII sequence to compare
|
||||
/// @return whether the complete sequence matches
|
||||
private static boolean matchesAscii(byte[] data, int start, String asciiValue) {
|
||||
if (start < 0 || start > svgScanLimit(data) - asciiValue.length())
|
||||
return false;
|
||||
|
||||
for (int index = 0; index < asciiValue.length(); index++) {
|
||||
if (Byte.toUnsignedInt(data[start + index]) != asciiValue.charAt(index))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Returns whether a byte is XML whitespace.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is space, tab, carriage return, or line feed
|
||||
private static boolean isXmlWhitespace(byte value) {
|
||||
return value == ' ' || value == '\t' || value == '\r' || value == '\n';
|
||||
}
|
||||
|
||||
private static final byte[] PNG_HEADER = {
|
||||
@@ -249,7 +513,7 @@ public final class ImageUtils {
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
|
||||
|
||||
PngChunkHeader header;
|
||||
@Nullable PngChunkHeader header;
|
||||
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
|
||||
// https://wiki.mozilla.org/APNG_Specification#Structure
|
||||
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
|
||||
@@ -436,7 +700,7 @@ public final class ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
PngAnimationControl animationControl = sequence.getAnimationControl();
|
||||
@Nullable PngAnimationControl animationControl = sequence.getAnimationControl();
|
||||
int cycleCount;
|
||||
if (animationControl != null) {
|
||||
cycleCount = animationControl.numPlays();
|
||||
@@ -465,6 +729,7 @@ public final class ImageUtils {
|
||||
return argb;
|
||||
}
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ImageUtils() {
|
||||
}
|
||||
}
|
||||
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user