Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
||||
|
||||
@@ -17,6 +17,7 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
terracotta-log-*
|
||||
hmcl-mod-update-list-*
|
||||
*.hmcl-theme
|
||||
/.java/
|
||||
/.local/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -186,15 +175,32 @@ public class JFXCheckBoxSkin extends CheckBoxSkin {
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (selection) {
|
||||
this.mark.setVisible(true);
|
||||
this.mark.setScaleX(1.0);
|
||||
this.mark.setScaleY(1.0);
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, control.getCheckedColor());
|
||||
} else {
|
||||
this.mark.setVisible(false);
|
||||
this.mark.setScaleX(0.0);
|
||||
this.mark.setScaleY(0.0);
|
||||
JFXNodeUtils.updateBackground(box.getBackground(), box, Color.TRANSPARENT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.transition.setRate(selection ? 1.0 : -1.0);
|
||||
this.select.setRate(selection ? 1.0 : -1.0);
|
||||
this.transition.play();
|
||||
this.select.play();
|
||||
}
|
||||
|
||||
private void createFillTransition() {
|
||||
|
||||
@@ -57,8 +57,10 @@ 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();
|
||||
|
||||
@@ -308,8 +313,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()) {
|
||||
@@ -396,6 +401,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 +458,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 +484,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config));
|
||||
dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -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,6 +28,7 @@ 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;
|
||||
@@ -42,12 +43,12 @@ 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) throws MismatchedModpackTypeException {
|
||||
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
|
||||
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
|
||||
|
||||
@@ -55,7 +56,7 @@ 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
|
||||
|
||||
@@ -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;
|
||||
@@ -84,9 +82,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 +91,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 +138,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 +149,55 @@ public final class LauncherHelper {
|
||||
launch();
|
||||
}
|
||||
|
||||
/// Builds and executes the launch pipeline for the captured game instance.
|
||||
private void launch0() {
|
||||
// https://github.com/HMCL-dev/HMCL/pull/4121
|
||||
PROCESSES.removeIf(it -> it.get() == null);
|
||||
|
||||
HMCLGameRepository repository = 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 +205,7 @@ public final class LauncherHelper {
|
||||
|
||||
String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName();
|
||||
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) {
|
||||
if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) {
|
||||
return new LibraryDownloadTask(dependencyManager, file, lib)
|
||||
.thenRunAsync(() -> javaAgents.add(agent));
|
||||
} else {
|
||||
@@ -204,18 +215,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 +240,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 +281,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,8 +354,7 @@ 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();
|
||||
if (responseCode == 404)
|
||||
@@ -363,8 +368,7 @@ public final class LauncherHelper {
|
||||
URI uri = ((DownloadException) ex).getUri();
|
||||
if (ex.getCause() instanceof SocketTimeoutException) {
|
||||
message = i18n("install.failed.downloading.timeout", uri);
|
||||
} else if (ex.getCause() instanceof ResponseCodeException) {
|
||||
ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause();
|
||||
} else if (ex.getCause() instanceof ResponseCodeException responseCodeException) {
|
||||
if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) {
|
||||
message = i18n("download.code." + responseCodeException.getResponseCode(), uri);
|
||||
} else {
|
||||
@@ -379,8 +383,7 @@ public final class LauncherHelper {
|
||||
message = i18n("account.failed.injector_download_failure");
|
||||
} else if (ex instanceof CharacterDeletedException) {
|
||||
message = i18n("account.failed.character_deleted");
|
||||
} else if (ex instanceof ResponseCodeException) {
|
||||
ResponseCodeException rce = (ResponseCodeException) ex;
|
||||
} else if (ex instanceof ResponseCodeException rce) {
|
||||
int responseCode = rce.getResponseCode();
|
||||
String uri = rce.getUri();
|
||||
if (responseCode == 404)
|
||||
@@ -423,9 +426,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 +459,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 +483,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 +494,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 +557,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 +570,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 +640,7 @@ public final class LauncherHelper {
|
||||
break;
|
||||
case MODDED_JAVA_16:
|
||||
// Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17
|
||||
String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null);
|
||||
@Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE);
|
||||
if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0)
|
||||
suggestions.add(i18n("launch.advice.forge37_0_60"));
|
||||
else
|
||||
@@ -651,8 +653,8 @@ public final class LauncherHelper {
|
||||
suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion));
|
||||
break;
|
||||
case CLEANROOM: {
|
||||
String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse("");
|
||||
if (!cleanroomVersion.isEmpty())
|
||||
@Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM);
|
||||
if (cleanroomVersion != null)
|
||||
suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion));
|
||||
else
|
||||
suggestions.add(i18n("launch.advice.cleanroom", 21, ""));
|
||||
@@ -681,7 +683,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);
|
||||
|
||||
@@ -829,16 +831,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 +848,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 +975,7 @@ public final class LauncherHelper {
|
||||
Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null;
|
||||
if (showLogs) {
|
||||
if (level == null)
|
||||
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
logBuffer.add(new Log(log, level));
|
||||
} else {
|
||||
lock.lock();
|
||||
@@ -1037,8 +1033,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;
|
||||
@@ -40,6 +41,16 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
protected abstract SortType getBackedRemoteModRepositorySortOrder();
|
||||
|
||||
@Override
|
||||
public String getApiBaseUrl() {
|
||||
return getBackedRemoteModRepository().getApiBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseUrl() {
|
||||
return getBackedRemoteModRepository().getBaseUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
|
||||
if (!StringUtils.containsChinese(searchFilter)) {
|
||||
@@ -49,7 +60,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
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(getType()).searchMod(searchFilter)) {
|
||||
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
|
||||
if (StringUtils.isNotBlank(englishSearchFilter)) {
|
||||
englishSearchFiltersSet.add(englishSearchFilter);
|
||||
@@ -82,7 +93,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
continue;
|
||||
}
|
||||
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByAddonType(getType()).getModByCurseForgeId(remoteAddon.slug());
|
||||
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
|
||||
searchResultArray[chineseIndex++] = remoteAddon;
|
||||
} else {
|
||||
@@ -94,7 +105,7 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
|
||||
|
||||
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(getType()).getModByCurseForgeId(remoteMod.slug());
|
||||
if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) {
|
||||
return Pair.pair(remoteMod, Integer.MAX_VALUE);
|
||||
}
|
||||
@@ -127,6 +138,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 +152,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,9 @@
|
||||
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;
|
||||
|
||||
@@ -44,17 +42,14 @@ 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,13 @@ 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 +56,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.toIterable;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/// Utilities for reading, installing, and applying modpack-specific game settings.
|
||||
@@ -114,8 +108,8 @@ public final class ModpackHelper {
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs);
|
||||
try {
|
||||
findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), file);
|
||||
throw new ManuallyCreatedModpackException(file);
|
||||
} catch (IOException e) {
|
||||
// ignore it
|
||||
@@ -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 -> {
|
||||
@@ -200,16 +195,10 @@ public final class ModpackHelper {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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 -> {
|
||||
@@ -237,10 +226,14 @@ public final class ModpackHelper {
|
||||
|
||||
public static Task<Void> getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration<?> configuration) throws UnsupportedModpackException {
|
||||
switch (configuration.getType()) {
|
||||
case ServerModpackRemoteInstallTask.MODPACK_TYPE:
|
||||
return new ModpackUpdateTask(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())
|
||||
.withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries")));
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedModpackException();
|
||||
}
|
||||
@@ -253,11 +246,11 @@ public final class ModpackHelper {
|
||||
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)
|
||||
.thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId))
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
else
|
||||
return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack)
|
||||
return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.GameJavaVersion;
|
||||
import org.jackhuang.hmcl.game.JavaVersionConstraint;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -37,7 +37,6 @@ 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 +320,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,7 +437,7 @@ public final class JavaManager {
|
||||
FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
|
||||
FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
FileUtils.tryGetPath(Objects.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime")
|
||||
.ifPresent(it -> searcher.searchAllOfficialJava(it, false));
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
searcher.searchAllOfficialJava(Path.of(System.getProperty("user.home"), ".minecraft/runtime"), false);
|
||||
@@ -499,7 +498,7 @@ public final class JavaManager {
|
||||
it.isJDK() ? "JDK" : "JRE",
|
||||
it.getVersion(),
|
||||
it.getPlatform().getArchitecture().getDisplayName(),
|
||||
Lang.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
Objects.requireNonNullElse(it.getVendor(), "Unknown"),
|
||||
it.getBinary()))
|
||||
.collect(Collectors.joining("\n", "Finished Java lookup, found " + searcher.javaRuntimes.size() + "\n", "")));
|
||||
return searcher.javaRuntimes;
|
||||
@@ -842,7 +841,7 @@ public final class JavaManager {
|
||||
}
|
||||
|
||||
void searchJavaInProgramFiles(String env, String defaultValue) {
|
||||
String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
String programFiles = Objects.requireNonNullElse(System.getenv(env), defaultValue);
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(programFiles);
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -22,10 +22,9 @@ 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;
|
||||
@@ -44,7 +43,6 @@ import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.*;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Manages the merged runtime view of local and user game directories.
|
||||
@@ -141,13 +139,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 +205,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.
|
||||
@@ -480,17 +490,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);
|
||||
|
||||
@@ -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;
|
||||
@@ -56,6 +61,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
@@ -63,6 +69,9 @@ import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -370,6 +379,42 @@ 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(), () -> {
|
||||
Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile);
|
||||
Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding);
|
||||
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 +598,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 +606,24 @@ public final class Controllers {
|
||||
}
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable URI uri) {
|
||||
if (uri == null) return;
|
||||
openUriOrCopy(uri.toString());
|
||||
}
|
||||
|
||||
public static void openUriOrCopy(@Nullable String uri) {
|
||||
if (uri == null) return;
|
||||
var dialog = new MessageDialogPane.Builder(
|
||||
i18n("web.open_in_browser", uri),
|
||||
i18n("message.confirm"),
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
)
|
||||
.addAction(i18n("button.copy"), () -> FXUtils.copyText(uri))
|
||||
.yesOrNo(() -> FXUtils.openLink(uri), null)
|
||||
.build();
|
||||
dialog(dialog);
|
||||
}
|
||||
|
||||
public static boolean isStopped() {
|
||||
return decorator == null;
|
||||
}
|
||||
@@ -579,4 +642,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +45,7 @@ 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;
|
||||
@@ -59,7 +61,6 @@ import javafx.scene.text.TextFlow;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Callback;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.glavo.url.WebURL;
|
||||
@@ -74,15 +75,16 @@ 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.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.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
@@ -467,25 +469,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));
|
||||
}
|
||||
@@ -725,116 +733,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,146 +827,6 @@ 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) {
|
||||
int itemCount = children.length;
|
||||
int childSelectedCount = 0;
|
||||
@@ -1390,20 +1148,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);
|
||||
@@ -1713,4 +1457,62 @@ 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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("-");
|
||||
|
||||
@@ -142,14 +136,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 +286,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 +337,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 +360,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 +375,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;
|
||||
@@ -324,7 +320,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<>() {
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,6 +30,7 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
|
||||
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 +38,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");
|
||||
@@ -60,8 +60,10 @@ public abstract class MDListCell<T> extends ListCell<T> {
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
ripplerContainer.releaseRippleImmediately();
|
||||
|
||||
updateControl(item, empty);
|
||||
if (empty) {
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setGraphic(root);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -882,6 +882,7 @@ public final class Decorator {
|
||||
private void playOpenAnimation() {
|
||||
if (!AnimationUtils.playWindowAnimation()) {
|
||||
stopWindowAnimation();
|
||||
Controllers.trimHeap();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -896,7 +897,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);
|
||||
}
|
||||
@@ -1018,6 +1025,13 @@ 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()
|
||||
|
||||
@@ -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;
|
||||
@@ -38,8 +41,13 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.LineFileChooserButton;
|
||||
import org.jackhuang.hmcl.ui.construct.LineToggleButton;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
|
||||
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;
|
||||
@@ -187,6 +195,7 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
txtGameDirectoryName.setText(suggestedName);
|
||||
}
|
||||
};
|
||||
locationChangeListener.changed(null, this.location.get(), this.location.get());
|
||||
locationProperty().addListener(locationChangeListener);
|
||||
|
||||
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
|
||||
@@ -200,8 +209,8 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
});
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -249,6 +258,14 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
|
||||
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;
|
||||
@@ -58,37 +58,37 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa
|
||||
|
||||
public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) {
|
||||
this.controller = controller;
|
||||
this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle());
|
||||
this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(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,11 +20,11 @@ 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;
|
||||
@@ -32,29 +32,29 @@ import org.jackhuang.hmcl.util.SettingsMap;
|
||||
|
||||
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 GameInstanceManifest manifest;
|
||||
protected final HMCLGameInstance instance;
|
||||
|
||||
public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) {
|
||||
public AdditionalInstallersPage(HMCLGameInstance instance, String 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();
|
||||
});
|
||||
}
|
||||
@@ -69,38 +69,34 @@ 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 gameVersionChanged = !instance.getVersion().toString().equals(getVersion(GameComponentType.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 (InstallerItem component : group.getComponents()) {
|
||||
GameComponentType componentType = component.getComponentType();
|
||||
String version = instance.getComponentVersion(component.getComponentType());
|
||||
String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version);
|
||||
boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveComponentAction);
|
||||
if (component.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == 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));
|
||||
component.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));
|
||||
} else if (alreadyInstalled || getVersion(componentType) != null) {
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
|
||||
} else {
|
||||
library.versionProperty().set(null);
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,20 @@ 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.io.FileUtils;
|
||||
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;
|
||||
@@ -95,7 +95,7 @@ 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)));
|
||||
GameComponentType.GAME, versionPageNavigator::onGameSelected)));
|
||||
modpackTab.setNodeSupplier(loadVersionFor(() -> {
|
||||
DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> {
|
||||
Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file);
|
||||
@@ -135,35 +135,31 @@ 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);
|
||||
@@ -171,9 +167,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
return task;
|
||||
}).whenComplete(Schedulers.javafx(), exception -> {
|
||||
if (exception != null) {
|
||||
if (exception instanceof CancellationException) {
|
||||
Controllers.showToast(i18n("message.cancelled"));
|
||||
} else {
|
||||
if (!(exception instanceof CancellationException)) {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
}
|
||||
} else {
|
||||
@@ -181,7 +175,7 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
}
|
||||
}), 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 +185,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 +284,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 +299,43 @@ 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) {
|
||||
GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID);
|
||||
builder.name(instanceId);
|
||||
builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion());
|
||||
if (instanceId == null) {
|
||||
throw new IllegalStateException("Instance ID is not set");
|
||||
}
|
||||
|
||||
settings.asStringMap().forEach((key, value) -> {
|
||||
if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key)
|
||||
&& value instanceof RemoteVersion remoteVersion)
|
||||
builder.version(remoteVersion);
|
||||
});
|
||||
try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) {
|
||||
builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion());
|
||||
|
||||
repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion());
|
||||
return builder.buildAsync().whenComplete(any -> {
|
||||
repository.refresh();
|
||||
repository.applyDefaultIsolationSetting(instanceId);
|
||||
}).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId));
|
||||
settings.asStringMap().forEach((key, value) -> {
|
||||
if (!GameComponentType.GAME.getPatchId().equals(key)
|
||||
&& value instanceof ComponentRemoteVersion remoteVersion)
|
||||
builder.component(remoteVersion);
|
||||
});
|
||||
|
||||
boolean modded = GameComponentType.MOD_LOADERS.stream()
|
||||
.anyMatch(componentType ->
|
||||
settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion);
|
||||
if (repository.shouldIsolateNewInstance(modded)) {
|
||||
builder.enableIsolation();
|
||||
}
|
||||
|
||||
Task<?> buildTask = builder.buildAsync();
|
||||
buildTask.onDone().register(event -> {
|
||||
if (!event.isFailed()) {
|
||||
runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
}
|
||||
});
|
||||
return buildTask;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -341,7 +351,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;
|
||||
@@ -52,20 +52,20 @@ public class InstallersPage extends AbstractInstallersPage {
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return ((RemoteVersion) controller.getSettings().get("game")).getGameVersion();
|
||||
return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion();
|
||||
}
|
||||
|
||||
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 +115,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());
|
||||
|
||||
@@ -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;
|
||||
@@ -109,9 +106,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, modpack.getEncoding(), 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, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)));
|
||||
}
|
||||
} catch (UnsupportedModpackException | ManuallyCreatedModpackException e) {
|
||||
Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR);
|
||||
@@ -124,10 +121,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));
|
||||
.thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-45
@@ -21,8 +21,9 @@ import javafx.scene.Node;
|
||||
import org.jackhuang.hmcl.download.*;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask;
|
||||
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;
|
||||
@@ -46,22 +47,18 @@ 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
|
||||
@@ -74,38 +71,46 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
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 gameInstance.getRepository().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().toString(), 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) {
|
||||
String newGameVersion = ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion();
|
||||
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,8 +134,7 @@ 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();
|
||||
if (responseCode == 404)
|
||||
@@ -145,8 +149,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
URI uri = ((DownloadException) exception).getUri();
|
||||
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();
|
||||
} 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);
|
||||
} else {
|
||||
@@ -155,14 +158,14 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
|
||||
} else {
|
||||
Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\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 +173,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 +182,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,8 @@ 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.ComponentVersionList;
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion;
|
||||
@@ -45,6 +45,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;
|
||||
@@ -77,27 +78,27 @@ 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 GameComponentType componentType;
|
||||
private final String title;
|
||||
private final Navigation navigation;
|
||||
private final DownloadProvider downloadProvider;
|
||||
private final VersionList<?> versionList;
|
||||
private final ComponentVersionList<?> 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,
|
||||
DownloadProvider downloadProvider,
|
||||
String libraryId,
|
||||
GameComponentType componentType,
|
||||
Runnable callback) {
|
||||
this.title = title;
|
||||
this.gameVersion = gameVersion;
|
||||
this.libraryId = libraryId;
|
||||
this.componentType = componentType;
|
||||
this.navigation = navigation;
|
||||
this.downloadProvider = downloadProvider;
|
||||
this.versionList = downloadProvider.getVersionListById(libraryId);
|
||||
this.versionList = downloadProvider.getVersionList(componentType);
|
||||
this.callback = callback;
|
||||
|
||||
refresh();
|
||||
@@ -152,12 +153,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 +174,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 +190,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 +213,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,7 +239,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
twoLineListItem.getTags().clear();
|
||||
|
||||
if (remoteVersion instanceof GameRemoteVersion) {
|
||||
RemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
|
||||
|
||||
switch (versionType) {
|
||||
@@ -243,7 +248,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
imageView.setImage(GameInstanceIconType.GRASS.getIcon());
|
||||
}
|
||||
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
|
||||
if (versionType == RemoteVersion.Type.SNAPSHOT
|
||||
if (versionType == ComponentRemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
|
||||
twoLineListItem.addTag(i18n("instance.game.april_fools"));
|
||||
imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon());
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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
|
||||
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
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
case OLD -> versionType == RemoteVersion.Type.OLD;
|
||||
case OLD -> versionType == ComponentRemoteVersion.Type.OLD;
|
||||
// case ALL,
|
||||
default -> true;
|
||||
};
|
||||
|
||||
@@ -19,8 +19,7 @@ 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;
|
||||
@@ -37,6 +36,7 @@ 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 +47,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 +69,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);
|
||||
@@ -136,9 +134,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 +151,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 +165,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,7 +173,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile);
|
||||
dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,7 +185,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
private Task<?> exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) {
|
||||
return new Task<Void>() {
|
||||
Task<?> dependency;
|
||||
@Nullable Task<?> dependency;
|
||||
|
||||
{
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
@@ -185,8 +193,9 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
|
||||
@Override
|
||||
public void execute() {
|
||||
GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId);
|
||||
dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(),
|
||||
HMCLGameInstance instance = resolveCurrentGameInstance();
|
||||
GameSettings.Effective setting = instance.getEffectiveSettings();
|
||||
dependency = new MultiMCModpackExportTask(instance, exportInfo.getWhitelist(),
|
||||
new MultiMCInstanceConfiguration(
|
||||
"OneSix",
|
||||
exportInfo.getName() + "-" + exportInfo.getVersion(),
|
||||
@@ -225,7 +234,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 +242,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 +254,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 +263,7 @@ public final class ExportWizardProvider implements WizardProvider {
|
||||
@Override
|
||||
public void execute() {
|
||||
dependency = new ModrinthModpackExportTask(
|
||||
repository,
|
||||
instanceId,
|
||||
resolveCurrentGameInstance(),
|
||||
exportInfo,
|
||||
modpackFile
|
||||
);
|
||||
@@ -268,12 +276,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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ 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;
|
||||
@@ -62,14 +62,15 @@ 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) {
|
||||
public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) {
|
||||
this.controller = controller;
|
||||
this.instanceId = instanceId;
|
||||
this.gameInstance = gameInstance;
|
||||
this.adviser = adviser;
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
|
||||
JFXTreeView<String> treeView = new JFXTreeView<>();
|
||||
treeView.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
@@ -97,17 +98,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 +146,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 +162,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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -831,10 +839,10 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
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 +1868,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 +2601,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 +2642,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 +2665,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 +2704,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 +2724,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 +2793,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 +2817,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 +2838,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() {
|
||||
}
|
||||
}
|
||||
@@ -19,27 +19,29 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonFile;
|
||||
import org.jackhuang.hmcl.addon.LocalAddonManager;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXCheckBoxTableCell;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
@@ -83,26 +85,42 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
enabledColumn.setMinWidth(40);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("addon.check_update.file"));
|
||||
fileNameColumn.setPrefWidth(200);
|
||||
fileNameColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version"));
|
||||
currentVersionColumn.setPrefWidth(200);
|
||||
currentVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version"));
|
||||
targetVersionColumn.setPrefWidth(200);
|
||||
targetVersionColumn.setPrefWidth(180);
|
||||
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
|
||||
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
|
||||
|
||||
TableColumn<AddonUpdateObject, String> changelogColumn = new TableColumn<>(i18n("addon.changelog"));
|
||||
{
|
||||
var oldCellFactory = changelogColumn.getCellFactory();
|
||||
changelogColumn.setCellFactory(param -> {
|
||||
TableCell<AddonUpdateObject, String> cell = oldCellFactory.call(param);
|
||||
cell.getStyleClass().add("addon-changelog-table-cell");
|
||||
cell.setOnMouseClicked(event -> {
|
||||
AddonUpdateObject object;
|
||||
if (cell.isEmpty() || cell.getTableRow() == null || (object = cell.getTableRow().getItem()) == null) return;
|
||||
Controllers.dialog(new AddonChangelog(object));
|
||||
});
|
||||
return cell;
|
||||
});
|
||||
changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view")));
|
||||
}
|
||||
|
||||
objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::new).collect(Collectors.toList()));
|
||||
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
|
||||
|
||||
TableView<AddonUpdateObject> table = new TableView<>(objects);
|
||||
table.setEditable(true);
|
||||
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn);
|
||||
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn, changelogColumn);
|
||||
setMargin(table, new Insets(10, 10, 5, 10));
|
||||
|
||||
setCenter(table);
|
||||
@@ -198,6 +216,7 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
final StringProperty currentVersion = new SimpleStringProperty();
|
||||
final StringProperty targetVersion = new SimpleStringProperty();
|
||||
final StringProperty source = new SimpleStringProperty();
|
||||
String changelog = null;
|
||||
|
||||
public AddonUpdateObject(LocalAddonFile.AddonUpdate data) {
|
||||
this.data = data;
|
||||
@@ -280,6 +299,88 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
|
||||
public AddonChangelog(AddonUpdateObject object) {
|
||||
RemoteAddon.Version targetVersion = object.data.targetVersion();
|
||||
|
||||
this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name())));
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
|
||||
loadChangelog(object, spinnerPane, scrollPane);
|
||||
spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane));
|
||||
|
||||
spinnerPane.setContent(scrollPane);
|
||||
box.getChildren().add(spinnerPane);
|
||||
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
|
||||
|
||||
this.setBody(box);
|
||||
|
||||
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
|
||||
versionPageBtn.setDisable(true);
|
||||
loadVersionPageUrl(object, versionPageBtn);
|
||||
|
||||
JFXButton closeButton = new JFXButton(i18n("button.ok"));
|
||||
closeButton.getStyleClass().add("dialog-accept");
|
||||
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
setActions(versionPageBtn, closeButton);
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
|
||||
|
||||
onEscPressed(this, closeButton::fire);
|
||||
}
|
||||
|
||||
private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) {
|
||||
spinnerPane.setLoading(true);
|
||||
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
|
||||
Task.supplyAsync(() -> {
|
||||
if (object.changelog != null) {
|
||||
return object.changelog;
|
||||
}
|
||||
RemoteAddon.Version version = object.data.targetVersion();
|
||||
if (repo == null) return null;
|
||||
return StringUtils.convertToHtml(
|
||||
repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.projectId(), version.versionId()),
|
||||
"238222".equals(object.data.targetVersion().projectId())
|
||||
);
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) {
|
||||
Task.supplyAsync(() -> {
|
||||
RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType());
|
||||
return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion());
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null && StringUtils.isNotBlank(result)) {
|
||||
button.setExternalLink(result);
|
||||
button.setDisable(false);
|
||||
} else {
|
||||
LOG.warning("Failed to load addon version page url", exception);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AddonUpdateTask extends Task<Void> {
|
||||
private final Collection<Task<?>> dependents;
|
||||
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
|
||||
|
||||
@@ -38,9 +38,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.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
@@ -68,12 +66,12 @@ import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor;
|
||||
|
||||
public class DownloadListPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable {
|
||||
public class DownloadListPage extends Control implements DecoratorPage {
|
||||
protected final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty failed = new SimpleBooleanProperty(false);
|
||||
private final boolean instanceSelection;
|
||||
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
|
||||
private final ObjectProperty<HMCLGameInstance.Optional> instanceReference = new SimpleObjectProperty<>();
|
||||
private final IntegerProperty pageOffset = new SimpleIntegerProperty(0);
|
||||
private final IntegerProperty pageCount = new SimpleIntegerProperty(-1);
|
||||
private final ListProperty<RemoteAddon> items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
|
||||
@@ -111,9 +109,8 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
return actions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.instanceReference.set(instance);
|
||||
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
@@ -124,10 +121,12 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
}
|
||||
|
||||
if (instanceSelection) {
|
||||
instances.setAll(repository.getDisplayInstanceManifests()
|
||||
.map(GameInstanceManifest::id)
|
||||
HMCLGameRepository repository = instance.repository();
|
||||
instances.setAll(repository.getDisplayInstances()
|
||||
.map(DefaultGameInstance::getId)
|
||||
.toList());
|
||||
selectedInstance.set(repository.getSelectedInstance());
|
||||
@Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance();
|
||||
selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +165,13 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
|
||||
int currentSearchID = searchID = searchID + 1;
|
||||
Task.supplyAsync(() -> {
|
||||
HMCLGameRepository.InstanceReference instanceReference = this.instanceReference.get();
|
||||
if (instanceReference.instanceId() == null) {
|
||||
HMCLGameInstance.Optional instanceReference = this.instanceReference.get();
|
||||
@Nullable HMCLGameInstance instance = instanceReference.instance();
|
||||
if (instance == null) {
|
||||
return userGameVersion;
|
||||
} else {
|
||||
return instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse("");
|
||||
GameVersionNumber version = instance.getVersion();
|
||||
return version != GameVersionNumber.unknown() ? version.toString() : "";
|
||||
}
|
||||
}).thenApplyAsync(
|
||||
gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC)
|
||||
@@ -217,10 +218,10 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
}
|
||||
}
|
||||
|
||||
protected HMCLGameRepository.InstanceReference getInstanceReference() {
|
||||
protected HMCLGameInstance.Optional getInstanceOptional() {
|
||||
if (instanceSelection) {
|
||||
@Nullable GameInstanceID instanceId = selectedInstance.get();
|
||||
return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), instanceId);
|
||||
return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId);
|
||||
} else {
|
||||
return instanceReference.get();
|
||||
}
|
||||
@@ -570,7 +571,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
FXUtils.onClicked(wrapper, () -> {
|
||||
RemoteAddon item = getItem();
|
||||
if (item != null)
|
||||
Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceReference(), getSkinnable().callback));
|
||||
Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceOptional(), getSkinnable().callback));
|
||||
});
|
||||
|
||||
setPrefWidth(0);
|
||||
@@ -581,17 +582,25 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst
|
||||
|
||||
@Override
|
||||
protected void updateItem(RemoteAddon item, boolean empty) {
|
||||
RemoteAddon oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.releaseRippleImmediately();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
setPadding(
|
||||
getIndex() == getListView().getItems().size() - 1
|
||||
? LAST_PADDING
|
||||
: PADDING
|
||||
getIndex() == getListView().getItems().size() - 1
|
||||
? LAST_PADDING
|
||||
: PADDING
|
||||
);
|
||||
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(getSkinnable().repository.getType()).getModByCurseForgeId(item.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : item.title());
|
||||
String description = item.description();
|
||||
if (description != null) {
|
||||
|
||||
@@ -30,10 +30,7 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
@@ -53,12 +50,15 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty(false);
|
||||
@@ -67,19 +67,19 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
private final ModTranslations translations;
|
||||
private final RemoteAddon addon;
|
||||
private final ModTranslations.Mod mod;
|
||||
private final HMCLGameRepository.InstanceReference instanceReference;
|
||||
private final HMCLGameInstance.Optional instanceReference;
|
||||
private final DownloadCallback callback;
|
||||
private final DownloadListPage page;
|
||||
private final RemoteAddonRepository.Type type;
|
||||
private final RemoteAddon.Type type;
|
||||
|
||||
private SimpleMultimap<String, RemoteAddon.Version, List<RemoteAddon.Version>> versions;
|
||||
|
||||
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference, @Nullable DownloadCallback callback) {
|
||||
public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) {
|
||||
this.page = page;
|
||||
this.repository = page.repository;
|
||||
this.addon = addon;
|
||||
this.type = Objects.requireNonNullElse(addon.repoType(), repository.getType());
|
||||
this.translations = ModTranslations.getTranslationsByRepositoryType(this.type);
|
||||
this.type = Objects.requireNonNullElse(addon.type(), repository.getType());
|
||||
this.translations = ModTranslations.getTranslationsByAddonType(this.type);
|
||||
this.mod = translations.getModByCurseForgeId(addon.slug());
|
||||
this.instanceReference = instanceReference;
|
||||
this.callback = callback;
|
||||
@@ -128,7 +128,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
return addon;
|
||||
}
|
||||
|
||||
public HMCLGameRepository.InstanceReference getInstanceReference() {
|
||||
public HMCLGameInstance.Optional getInstanceOptional() {
|
||||
return instanceReference;
|
||||
}
|
||||
|
||||
@@ -269,32 +269,31 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
FXUtils.onChangeAndOperate(control.loaded, loaded -> {
|
||||
if (control.versions == null) return;
|
||||
|
||||
if (control.instanceReference.repository() != null && control.instanceReference.instanceId() != null) {
|
||||
HMCLGameRepository repository = control.instanceReference.repository();
|
||||
GameInstanceManifest.Resolved resolvedManifest = repository.getResolvedInstanceManifest(control.instanceReference.instanceId());
|
||||
String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null);
|
||||
|
||||
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
|
||||
List<RemoteAddon.Version> modVersions = control.versions.get(gameVersion);
|
||||
if (modVersions != null && !modVersions.isEmpty()) {
|
||||
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders();
|
||||
@Nullable HMCLGameInstance instance = control.instanceReference.instance();
|
||||
if (instance != null) {
|
||||
String gameVersion = instance.getVersion().toString();
|
||||
if (!GameVersionNumber.unknown().equals(instance.getVersion())
|
||||
&& control.versions.containsKey(gameVersion)) {
|
||||
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
|
||||
if (addonVersions != null && !addonVersions.isEmpty()) {
|
||||
Set<ModLoaderType> targetLoaders = instance.getModLoaders();
|
||||
|
||||
resolve:
|
||||
for (RemoteAddon.Version modVersion : modVersions) {
|
||||
if (getSkinnable().type == RemoteAddonRepository.Type.MOD) {
|
||||
for (ModLoaderType loader : modVersion.loaders()) {
|
||||
for (RemoteAddon.Version addonVersion : addonVersions) {
|
||||
if (getSkinnable().type == RemoteAddon.Type.MOD) {
|
||||
for (ModLoaderType loader : addonVersion.loaders()) {
|
||||
if (targetLoaders.contains(loader)) {
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, modVersion, control)
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, addonVersion, control)
|
||||
);
|
||||
break resolve;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
list.getContent().addAll(
|
||||
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, modVersion, control)
|
||||
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
|
||||
new AddonItem(control.addon, addonVersion, control)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -373,7 +372,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
public final RemoteAddon addon;
|
||||
|
||||
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference) {
|
||||
DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) {
|
||||
this.addon = addon;
|
||||
|
||||
HBox pane = new HBox(8);
|
||||
@@ -386,8 +385,8 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
pane.getChildren().setAll(imageView, content);
|
||||
FXUtils.setLimitHeight(this, 60);
|
||||
|
||||
RemoteAddonRepository.Type type = addon.repoType();
|
||||
DownloadCallback callback = switch (type) {
|
||||
RemoteAddon.Type type = addon.type();
|
||||
DownloadCallback callback = type == null ? null : switch (type) {
|
||||
case MOD -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD;
|
||||
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
|
||||
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
|
||||
@@ -399,21 +398,15 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
});
|
||||
setNode(IDX_LEADING, pane);
|
||||
|
||||
if (addon != RemoteAddon.BROKEN) {
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
|
||||
content.setSubtitle(addon.description());
|
||||
for (String category : addon.categories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
content.addTag(page.getLocalizedCategory(category, null));
|
||||
}
|
||||
if (StringUtils.isNotBlank(addon.iconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
|
||||
}
|
||||
} else {
|
||||
content.setTitle(i18n("addon.broken_dependency.title"));
|
||||
content.setSubtitle(i18n("addon.broken_dependency.desc"));
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/icon@4x.png"));
|
||||
ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug());
|
||||
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title());
|
||||
content.setSubtitle(addon.description());
|
||||
for (String category : addon.categories()) {
|
||||
if (page.shouldDisplayCategory(category))
|
||||
content.addTag(page.getLocalizedCategory(category, null));
|
||||
}
|
||||
if (StringUtils.isNotBlank(addon.iconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.iconUrl(), 80, 80, true, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,7 +503,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
private static final class AddonVersion extends JFXDialogLayout {
|
||||
public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) {
|
||||
RemoteAddonRepository.Type type = selfPage.type;
|
||||
RemoteAddon.Type type = selfPage.type;
|
||||
|
||||
String title = switch (type) {
|
||||
case WORLD -> "world.download.title";
|
||||
@@ -523,9 +516,20 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
AddonItem addonItem = new AddonItem(mod, version, selfPage);
|
||||
var addonItem = new AddonItem(mod, version, selfPage);
|
||||
addonItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again
|
||||
box.getChildren().setAll(addonItem);
|
||||
|
||||
JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog"));
|
||||
changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider())));
|
||||
|
||||
JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url"));
|
||||
versionPageBtn.setDisable(true);
|
||||
loadVersionPageUrl(version, selfPage.repository, versionPageBtn);
|
||||
|
||||
HBox additionalBox = new HBox(changelogButton, versionPageBtn);
|
||||
box.getChildren().add(additionalBox);
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
ComponentList dependenciesList = new ComponentList();
|
||||
@@ -536,6 +540,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
scrollPane.setContent(dependenciesList);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
FXUtils.onChangeAndOperate(scrollPane.widthProperty(), d -> FXUtils.setLimitWidth(dependenciesList, d.doubleValue()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
spinnerPane.setContent(scrollPane);
|
||||
@@ -546,10 +551,10 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
|
||||
JFXButton downloadButton = null;
|
||||
if (selfPage.callback != null) {
|
||||
downloadButton = new JFXButton(type == RemoteAddonRepository.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install"));
|
||||
downloadButton = new JFXButton(type == RemoteAddon.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install"));
|
||||
downloadButton.getStyleClass().add("dialog-accept");
|
||||
downloadButton.setOnAction(e -> {
|
||||
if (type == RemoteAddonRepository.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
|
||||
if (type == RemoteAddon.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
}
|
||||
selfPage.download(mod, version);
|
||||
@@ -576,7 +581,7 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
}
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
|
||||
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
}
|
||||
@@ -586,9 +591,12 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
Task.composeAsync(() -> {
|
||||
// TODO: Massive tasks may cause OOM.
|
||||
EnumMap<RemoteAddon.DependencyType, Pair<Label, List<DependencyAddonItem>>> dependencies = new EnumMap<>(RemoteAddon.DependencyType.class);
|
||||
AtomicBoolean hasBroken = new AtomicBoolean(false);
|
||||
List<Task<?>> queue = new ArrayList<>(version.dependencies().size());
|
||||
for (RemoteAddon.Dependency dependency : version.dependencies()) {
|
||||
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE || dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
|
||||
if (dependency.getType() == RemoteAddon.DependencyType.INCOMPATIBLE) continue;
|
||||
if (dependency.getType() == RemoteAddon.DependencyType.BROKEN) {
|
||||
hasBroken.set(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -603,30 +611,114 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenAcceptAsync(Schedulers.javafx(), dep -> {
|
||||
if (dep == RemoteAddon.BROKEN) {
|
||||
hasBroken.set(true);
|
||||
return;
|
||||
}
|
||||
DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference);
|
||||
var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue()));
|
||||
dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener);
|
||||
dependencies.get(dependency.getType()).value().add(dependencyAddonItem);
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR));
|
||||
}
|
||||
|
||||
return Task.allOf(queue).thenSupplyAsync(() ->
|
||||
dependencies.values().stream().flatMap(types ->
|
||||
Stream.concat(
|
||||
Stream.of(types.key()),
|
||||
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)))
|
||||
).toList()
|
||||
);
|
||||
return Task.allOf(queue).thenSupplyAsync(() -> {
|
||||
var dependenciesStream = dependencies.values().stream().flatMap(types -> {
|
||||
if (types.value().isEmpty()) return Stream.of();
|
||||
return Stream.concat(
|
||||
Stream.of(types.key()),
|
||||
types.value().stream().sorted(Comparator.comparing(item -> item.addon.slug(), String.CASE_INSENSITIVE_ORDER)));
|
||||
});
|
||||
if (!hasBroken.get()) {
|
||||
return dependenciesStream;
|
||||
} else {
|
||||
Label warning = new Label(i18n("addon.dependencies.has_broken"));
|
||||
warning.setWrapText(true);
|
||||
warning.setPadding(new Insets(0, 8, 0, 8));
|
||||
return Stream.concat(
|
||||
Stream.of(warning),
|
||||
dependenciesStream
|
||||
);
|
||||
}
|
||||
});
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
spinnerPane.setLoading(false);
|
||||
if (exception == null) {
|
||||
dependenciesList.getContent().setAll(result);
|
||||
dependenciesList.getContent().setAll(result.toList());
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
dependenciesList.getContent().setAll();
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository repo, JFXHyperlink button) {
|
||||
Task.supplyAsync(() -> repo.getVersionPageUrl(version))
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null && StringUtils.isNotBlank(result)) {
|
||||
button.setExternalLink(result);
|
||||
button.setDisable(false);
|
||||
} else {
|
||||
LOG.warning("Failed to load addon version page url", exception);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AddonChangelog extends JFXDialogLayout {
|
||||
|
||||
public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) {
|
||||
setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name())));
|
||||
|
||||
VBox box = new VBox(8);
|
||||
box.setPadding(new Insets(8));
|
||||
|
||||
SpinnerPane spinnerPane = new SpinnerPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
FXUtils.setOverflowHidden(scrollPane, 8);
|
||||
|
||||
loadChangelog(version, repo, provider, spinnerPane, scrollPane);
|
||||
spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, spinnerPane, scrollPane));
|
||||
|
||||
spinnerPane.setContent(scrollPane);
|
||||
box.getChildren().add(spinnerPane);
|
||||
VBox.setVgrow(spinnerPane, Priority.SOMETIMES);
|
||||
|
||||
this.setBody(box);
|
||||
|
||||
JFXButton closeButton = new JFXButton(i18n("button.ok"));
|
||||
closeButton.getStyleClass().add("dialog-accept");
|
||||
closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
|
||||
|
||||
setActions(closeButton);
|
||||
|
||||
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
|
||||
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
|
||||
|
||||
onEscPressed(this, closeButton::fire);
|
||||
}
|
||||
|
||||
private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) {
|
||||
spinnerPane.setLoading(true);
|
||||
Task.supplyAsync(() ->
|
||||
StringUtils.convertToHtml(
|
||||
repo.getAddonChangelog(provider, version.projectId(), version.versionId()),
|
||||
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 FUCK YOU JEI, FUCK YOU CURSEFORGE
|
||||
)
|
||||
).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty");
|
||||
scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl()));
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
spinnerPane.setFailedReason(null);
|
||||
} else {
|
||||
spinnerPane.setFailedReason(i18n("download.failed.refresh"));
|
||||
}
|
||||
spinnerPane.setLoading(false);
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,35 +17,35 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.WeakChangeListener;
|
||||
import javafx.geometry.Pos;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.ImageContainer;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class GameAdvancedListItem extends AdvancedListItem {
|
||||
private final ImageContainer imageContainer;
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
private HMCLGameRepository repository;
|
||||
@SuppressWarnings("unused")
|
||||
private Consumer<Event> onInstanceIconChangedListener;
|
||||
|
||||
@SuppressWarnings({"unused", "FieldCanBeLocal"})
|
||||
private Consumer<RefreshedGameInstancesEvent> onRefreshedInstancesListener;
|
||||
/// Strongly held so [WeakChangeListener] keeps delivering icon updates.
|
||||
private final ChangeListener<Image> iconListener;
|
||||
|
||||
private @Nullable WeakChangeListener<Image> weakIconListener;
|
||||
private @Nullable ReadOnlyObjectProperty<Image> observedIcon;
|
||||
|
||||
public GameAdvancedListItem() {
|
||||
this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE);
|
||||
this.iconListener = (observable, oldImage, newImage) -> imageContainer.setImage(newImage);
|
||||
imageContainer.setMouseTransparent(true);
|
||||
AdvancedListItem.setAlignment(imageContainer, Pos.CENTER);
|
||||
setLeftGraphic(imageContainer);
|
||||
@@ -53,31 +53,28 @@ public class GameAdvancedListItem extends AdvancedListItem {
|
||||
holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), this::loadInstance));
|
||||
}
|
||||
|
||||
private void loadInstance(GameInstanceID instanceId) {
|
||||
if (GameDirectoryManager.getSelectedRepository() != repository) {
|
||||
repository = GameDirectoryManager.getSelectedRepository();
|
||||
if (repository != null) {
|
||||
onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> {
|
||||
FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()));
|
||||
});
|
||||
if (!repository.isLoaded()) {
|
||||
onRefreshedInstancesListener = EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class)
|
||||
.registerWeak(event -> FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance())));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (instanceId != null && repository != null) {
|
||||
if (repository.hasInstance(instanceId)) {
|
||||
setTitle(i18n("instance.manage.manage"));
|
||||
setSubtitle(instanceId.toString());
|
||||
imageContainer.setImage(repository.getInstanceIconImage(instanceId));
|
||||
return;
|
||||
}
|
||||
private void loadInstance(@Nullable HMCLGameInstance instance) {
|
||||
unbindIcon();
|
||||
if (instance != null) {
|
||||
setTitle(i18n("instance.manage.manage"));
|
||||
setSubtitle(instance.getId().toString());
|
||||
observedIcon = instance.iconImageProperty();
|
||||
weakIconListener = new WeakChangeListener<>(iconListener);
|
||||
observedIcon.addListener(weakIconListener);
|
||||
imageContainer.setImage(instance.getIconImage());
|
||||
return;
|
||||
}
|
||||
|
||||
setTitle(i18n("instance.empty"));
|
||||
setSubtitle(i18n("instance.empty.add"));
|
||||
imageContainer.setImage(GameInstanceIconType.DEFAULT.getIcon());
|
||||
}
|
||||
|
||||
private void unbindIcon() {
|
||||
if (observedIcon != null && weakIconListener != null) {
|
||||
observedIcon.removeListener(weakIconListener);
|
||||
}
|
||||
observedIcon = null;
|
||||
weakIconListener = null;
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@ import javafx.scene.Node;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
@@ -31,6 +29,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
@@ -39,16 +38,14 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class GameInstanceIconDialog extends DialogPane {
|
||||
private final HMCLGameRepository repository;
|
||||
private final GameInstanceID instanceId;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final Runnable onFinish;
|
||||
private final GameSettings.Instance setting;
|
||||
private final GameSettings.@Nullable Instance setting;
|
||||
|
||||
public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID instanceId, Runnable onFinish) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
public GameInstanceIconDialog(HMCLGameInstance gameInstance, Runnable onFinish) {
|
||||
this.gameInstance = gameInstance;
|
||||
this.onFinish = onFinish;
|
||||
this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId);
|
||||
this.setting = gameInstance.getSettingsOrCreate();
|
||||
|
||||
setTitle(i18n("settings.icon"));
|
||||
FlowPane pane = new FlowPane();
|
||||
@@ -79,7 +76,7 @@ public class GameInstanceIconDialog extends DialogPane {
|
||||
Path selectedFile = Controllers.showOpenDialog(chooser);
|
||||
if (selectedFile != null) {
|
||||
try {
|
||||
repository.setInstanceIconFile(instanceId, selectedFile);
|
||||
gameInstance.setIconFile(selectedFile);
|
||||
|
||||
if (setting != null) {
|
||||
setting.iconProperty().setValue(GameInstanceIconType.DEFAULT);
|
||||
@@ -119,7 +116,7 @@ public class GameInstanceIconDialog extends DialogPane {
|
||||
|
||||
@Override
|
||||
protected void onAccept() {
|
||||
repository.onInstanceIconChanged.fireEvent(new Event(this));
|
||||
// Icon file / settings.iconProperty updates already invalidate iconImageProperty.
|
||||
onFinish.run();
|
||||
super.onAccept();
|
||||
}
|
||||
|
||||
@@ -21,16 +21,15 @@ import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.event.Event;
|
||||
import javafx.event.EventType;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.event.EventPriority;
|
||||
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.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -48,8 +47,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -65,10 +62,19 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
private final TabHeader.Tab<ResourcePackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty();
|
||||
private final ObjectProperty<HMCLGameRepository.InstanceReference> instanceReference = new SimpleObjectProperty<>();
|
||||
private final ObjectProperty<HMCLGameInstance.@Nullable Optional> instance =
|
||||
new SimpleObjectProperty<>(this, "instance");
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private GameInstanceID preferredInstanceId = null;
|
||||
/// Re-resolves the page context when its repository publishes a new snapshot.
|
||||
private final ChangeListener<? super HMCLGameRepositorySnapshot> repositorySnapshotListener =
|
||||
(observable, oldValue, newValue) -> checkSelectedInstance();
|
||||
|
||||
/// Repository currently observed for snapshot publications.
|
||||
private @Nullable HMCLGameRepository observedRepository;
|
||||
|
||||
/// Last concrete instance displayed by this page.
|
||||
private @Nullable GameInstanceID preferredInstanceId;
|
||||
|
||||
public static class WorkingDirChangedEvent extends Event {
|
||||
public static final EventType<WorkingDirChangedEvent> EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED");
|
||||
@@ -79,12 +85,13 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
}
|
||||
|
||||
public GameInstancePage() {
|
||||
gameSettingsTab.setNodeSupplier(loadInstanceFor(() -> new GameSettingsPage<>(GameSettings.Instance.class)));
|
||||
installerListTab.setNodeSupplier(loadInstanceFor(InstallerListPage::new));
|
||||
modListTab.setNodeSupplier(loadInstanceFor(ModListPage::new));
|
||||
resourcePackTab.setNodeSupplier(loadInstanceFor(ResourcePackListPage::new));
|
||||
worldListTab.setNodeSupplier(loadInstanceFor(WorldListPage::new));
|
||||
schematicsTab.setNodeSupplier(loadInstanceFor(SchematicsPage::new));
|
||||
// Child tabs subscribe to instanceProperty() themselves and reload on change.
|
||||
gameSettingsTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance));
|
||||
installerListTab.setNodeSupplier(() -> new InstallerListPage(instance));
|
||||
modListTab.setNodeSupplier(() -> new ModListPage(instance));
|
||||
resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance));
|
||||
worldListTab.setNodeSupplier(() -> new WorldListPage(instance));
|
||||
schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance));
|
||||
|
||||
tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab);
|
||||
tab.select(gameSettingsTab);
|
||||
@@ -92,31 +99,64 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
|
||||
|
||||
addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> {
|
||||
if (this.instanceReference.get() != null) {
|
||||
if (installerListTab.isInitialized())
|
||||
installerListTab.getNode().loadInstance(getRepository(), getInstanceId());
|
||||
if (modListTab.isInitialized())
|
||||
modListTab.getNode().loadInstance(getRepository(), getInstanceId());
|
||||
if (resourcePackTab.isInitialized())
|
||||
resourcePackTab.getNode().loadInstance(getRepository(), getInstanceId());
|
||||
if (worldListTab.isInitialized())
|
||||
worldListTab.getNode().loadInstance(getRepository(), getInstanceId());
|
||||
if (schematicsTab.isInitialized())
|
||||
schematicsTab.getNode().loadInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance.Optional current = this.instance.get();
|
||||
if (current != null) {
|
||||
// Re-resolve so subscribed tabs reload from the current snapshot.
|
||||
this.instance.set(current.refreshed());
|
||||
}
|
||||
});
|
||||
|
||||
listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST));
|
||||
// Page chrome that depends on the current instance.
|
||||
listenerHolder.add(FXUtils.onWeakChange(instance, current -> {
|
||||
observeRepository(current);
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
HMCLGameInstance gameInstance = current.instance();
|
||||
currentInstanceUpgradable.set(gameInstance != null && gameInstance.isModpack());
|
||||
if (gameInstance != null) {
|
||||
preferredInstanceId = gameInstance.getId();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Observes snapshot publications for the repository associated with the current page context.
|
||||
///
|
||||
/// @param current the current page context, or `null` when the page has no context
|
||||
private void observeRepository(HMCLGameInstance.@Nullable Optional current) {
|
||||
@Nullable HMCLGameRepository repository = current != null ? current.repository() : null;
|
||||
if (repository == observedRepository) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (observedRepository != null) {
|
||||
observedRepository.snapshotProperty().removeListener(repositorySnapshotListener);
|
||||
}
|
||||
observedRepository = repository;
|
||||
if (repository != null) {
|
||||
repository.snapshotProperty().addListener(repositorySnapshotListener);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current instance context for this page and its tabs.
|
||||
///
|
||||
/// Child tabs subscribe to this property and reload when it changes. The page only publishes
|
||||
/// context; it does not push `loadInstance` into children.
|
||||
///
|
||||
/// @return the observable instance context
|
||||
public ReadOnlyObjectProperty<HMCLGameInstance.@Nullable Optional> instanceProperty() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void checkSelectedInstance() {
|
||||
runInFX(() -> {
|
||||
if (this.instanceReference.get() == null) return;
|
||||
HMCLGameRepository repository = this.instanceReference.get().repository();
|
||||
@Nullable GameInstanceID instanceId = this.instanceReference.get().instanceId();
|
||||
if (instanceId == null || !repository.hasInstance(instanceId)) {
|
||||
HMCLGameInstance.Optional current = this.instance.get();
|
||||
if (current == null) return;
|
||||
current = current.refreshed();
|
||||
this.instance.set(current);
|
||||
if (current.isEmpty()) {
|
||||
if (preferredInstanceId != null) {
|
||||
loadInstance(preferredInstanceId, repository);
|
||||
loadInstance(preferredInstanceId, current.repository());
|
||||
} else {
|
||||
fireEvent(new PageCloseEvent());
|
||||
}
|
||||
@@ -124,56 +164,28 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
});
|
||||
}
|
||||
|
||||
private <T extends Node> Supplier<T> loadInstanceFor(Supplier<T> nodeSupplier) {
|
||||
return () -> {
|
||||
T node = nodeSupplier.get();
|
||||
if (instanceReference.get() != null) {
|
||||
if (node instanceof GameInstancePage.GameInstanceLoadable loadable) {
|
||||
@Nullable GameInstanceID instanceId = instanceReference.get().instanceId();
|
||||
loadable.loadInstance(instanceReference.get().repository(), instanceId);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
};
|
||||
}
|
||||
|
||||
public void showInstanceSettings() {
|
||||
tab.select(gameSettingsTab, false);
|
||||
}
|
||||
|
||||
public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) {
|
||||
this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId));
|
||||
this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId));
|
||||
}
|
||||
|
||||
public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repository) {
|
||||
// If we jumped to game list page and deleted this version
|
||||
// and back to this page, we should return to main page.
|
||||
if (this.instanceReference.get() != null && (!getRepository().isLoaded() ||
|
||||
if (this.instance.get() != null && (!getRepository().isLoaded() ||
|
||||
!getRepository().hasInstance(instanceId))) {
|
||||
Platform.runLater(() -> fireEvent(new PageCloseEvent()));
|
||||
return;
|
||||
}
|
||||
|
||||
setInstance(instanceId, repository);
|
||||
preferredInstanceId = instanceId;
|
||||
|
||||
if (gameSettingsTab.isInitialized())
|
||||
gameSettingsTab.getNode().loadInstance(repository, instanceId);
|
||||
if (installerListTab.isInitialized())
|
||||
installerListTab.getNode().loadInstance(repository, instanceId);
|
||||
if (modListTab.isInitialized())
|
||||
modListTab.getNode().loadInstance(repository, instanceId);
|
||||
if (resourcePackTab.isInitialized())
|
||||
resourcePackTab.getNode().loadInstance(repository, instanceId);
|
||||
if (worldListTab.isInitialized())
|
||||
worldListTab.getNode().loadInstance(repository, instanceId);
|
||||
if (schematicsTab.isInitialized())
|
||||
schematicsTab.getNode().loadInstance(repository, instanceId);
|
||||
currentInstanceUpgradable.set(repository.isModpack(instanceId));
|
||||
this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId));
|
||||
}
|
||||
|
||||
private void onNavigated(Navigator.NavigationEvent event) {
|
||||
if (this.instanceReference.get() == null)
|
||||
if (this.instance.get() == null)
|
||||
throw new IllegalStateException();
|
||||
|
||||
// If we jumped to game list page and deleted this version
|
||||
@@ -188,11 +200,18 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
}
|
||||
|
||||
private void onBrowse(String sub) {
|
||||
FXUtils.openFolder(getRepository().getRunDirectory(getInstanceId()).resolve(sub));
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
|
||||
}
|
||||
|
||||
private void redownloadAssetIndex() {
|
||||
Instances.updateGameAssets(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.updateGameAssets(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearLibraries() {
|
||||
@@ -209,9 +228,9 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
private void clearAssets() {
|
||||
Path assetsDir = getRepository().getBaseDirectory().resolve("assets");
|
||||
|
||||
HMCLGameRepository.InstanceReference currentInstanceReference = instanceReference.get();
|
||||
Path resourcesDir = currentInstanceReference != null
|
||||
? getRepository().getRunDirectory(currentInstanceReference.instanceId()).resolve("resources")
|
||||
HMCLGameInstance.Optional current = instance.get();
|
||||
Path resourcesDir = current != null && current.isPresent()
|
||||
? current.instance().getRunDirectory().resolve("resources")
|
||||
: null;
|
||||
|
||||
Task.runAsync(Schedulers.io(), () -> {
|
||||
@@ -227,46 +246,79 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
}
|
||||
|
||||
private void clearJunkFiles() {
|
||||
Instances.cleanInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.cleanInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void testGame() {
|
||||
Instances.testGame(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.testGame(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateGame() {
|
||||
Instances.updateInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.updateInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void generateLaunchScript() {
|
||||
Instances.generateLaunchScript(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.generateLaunchScript(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void export() {
|
||||
Instances.exportInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.exportInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void rename() {
|
||||
Instances.renameInstance(getRepository(), getInstanceId())
|
||||
.thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId));
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.renameInstance(gameInstance)
|
||||
.thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
private void remove() {
|
||||
Instances.deleteInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.deleteInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private void duplicate() {
|
||||
Instances.duplicateInstance(getRepository(), getInstanceId());
|
||||
HMCLGameInstance gameInstance = requireGameInstance();
|
||||
if (gameInstance != null) {
|
||||
Instances.duplicateInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable HMCLGameInstance requireGameInstance() {
|
||||
HMCLGameInstance.Optional current = instance.get();
|
||||
return current != null ? current.instance() : null;
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::repository).orElse(null);
|
||||
HMCLGameInstance.Optional current = instance.get();
|
||||
return current != null ? current.repository() : null;
|
||||
}
|
||||
|
||||
public @Nullable GameInstanceID getInstanceId() {
|
||||
return Optional.ofNullable(instanceReference.get())
|
||||
.map(HMCLGameRepository.InstanceReference::instanceId)
|
||||
.orElse(null);
|
||||
HMCLGameInstance.Optional current = instance.get();
|
||||
return current != null ? current.instanceId() : null;
|
||||
}
|
||||
|
||||
public HMCLGameInstance.Optional getInstance() {
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -350,7 +402,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
|
||||
control.state.bind(Bindings.createObjectBinding(() ->
|
||||
State.fromTitle(i18n("instance.manage.manage.title", getSkinnable().getInstanceId()), -1),
|
||||
getSkinnable().instanceReference));
|
||||
getSkinnable().instance));
|
||||
|
||||
//control.transitionPane.getStyleClass().add("gray-background");
|
||||
//FXUtils.setOverflowHidden(control.transitionPane, 8);
|
||||
@@ -358,12 +410,4 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads page content for a game instance in a repository.
|
||||
public interface GameInstanceLoadable {
|
||||
/// Loads page content for the given repository and game instance.
|
||||
///
|
||||
/// @param repository the repository containing the game instance
|
||||
/// @param instanceId the game instance ID, or `null` when only repository context is available
|
||||
void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId);
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,20 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.modpack.ModpackConfiguration;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT;
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -43,9 +40,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
public class GameItem {
|
||||
private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS);
|
||||
|
||||
protected final HMCLGameRepository repository;
|
||||
protected final String id;
|
||||
protected final GameInstanceID instanceId;
|
||||
protected final HMCLGameInstance gameInstance;
|
||||
|
||||
private boolean initialized = false;
|
||||
private StringProperty title;
|
||||
@@ -53,22 +48,28 @@ public class GameItem {
|
||||
private StringProperty subtitle;
|
||||
private ObjectProperty<Image> image;
|
||||
|
||||
public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.id = instanceId.toString();
|
||||
this.instanceId = instanceId;
|
||||
public GameItem(HMCLGameInstance gameInstance) {
|
||||
this.gameInstance = gameInstance;
|
||||
}
|
||||
|
||||
public GameDirectory getGameDirectory() {
|
||||
return repository.getGameDirectory();
|
||||
return gameInstance.getRepository().getGameDirectory();
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return repository;
|
||||
return gameInstance.getRepository();
|
||||
}
|
||||
|
||||
public GameInstanceID getInstanceId() {
|
||||
return gameInstance.getId();
|
||||
}
|
||||
|
||||
public HMCLGameInstance getGameInstance() {
|
||||
return gameInstance;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
return gameInstance.getId().toString();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
@@ -86,15 +87,16 @@ public class GameItem {
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
// GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms)
|
||||
Optional<String> gameVersion = repository.getGameVersion(instanceId);
|
||||
String modPackVersion = null;
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
@Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString();
|
||||
@Nullable String modPackVersion = null;
|
||||
try {
|
||||
ModpackConfiguration<?> config = repository.readModpackConfiguration(instanceId);
|
||||
@Nullable ModpackConfiguration<?> config = gameInstance.readModpackConfiguration();
|
||||
modPackVersion = config != null ? config.getVersion() : null;
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read modpack configuration from " + id, e);
|
||||
LOG.warning("Failed to read modpack configuration from " + getId(), e);
|
||||
}
|
||||
return new Result(gameVersion.orElse(null), modPackVersion);
|
||||
return new Result(gameVersion, modPackVersion);
|
||||
}, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> {
|
||||
if (exception == null) {
|
||||
if (result.tag != null) {
|
||||
@@ -102,26 +104,25 @@ public class GameItem {
|
||||
}
|
||||
|
||||
StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown")));
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), result.gameVersion);
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
String libraryId = mark.getLibraryId();
|
||||
String libraryVersion = mark.getLibraryVersion();
|
||||
if (libraryId.equals(MINECRAFT.getPatchId())) continue;
|
||||
if (I18n.hasKey("install.installer." + libraryId)) {
|
||||
libraries.append(", ").append(i18n("install.installer." + libraryId));
|
||||
if (libraryVersion != null)
|
||||
libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, ""));
|
||||
GameComponentAnalyzer analyzer = gameInstance.getAnalyzer();
|
||||
for (GameComponentAnalyzer.Mark mark : analyzer) {
|
||||
if (mark.componentType() == GameComponentType.GAME) continue;
|
||||
|
||||
if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) {
|
||||
libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId()));
|
||||
if (mark.version() != null)
|
||||
libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), ""));
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.set(libraries.toString());
|
||||
} else {
|
||||
LOG.warning("Failed to read version info from " + id, exception);
|
||||
LOG.warning("Failed to read version info from " + getId(), exception);
|
||||
}
|
||||
}, Schedulers.javafx());
|
||||
|
||||
title.set(id);
|
||||
image.set(repository.getInstanceIconImage(instanceId));
|
||||
title.set(getId());
|
||||
image.set(gameInstance.getIconImage());
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty titleProperty() {
|
||||
|
||||
@@ -29,7 +29,6 @@ import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
@@ -40,7 +39,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class GameListCell extends ListCell<GameListItem> {
|
||||
|
||||
private final Region graphic;
|
||||
private final RipplerContainer graphic;
|
||||
|
||||
private final ImageContainer imageView;
|
||||
private final TwoLineListItem content;
|
||||
@@ -70,7 +69,7 @@ public final class GameListCell extends ListCell<GameListItem> {
|
||||
fireEvent(new ActionEvent());
|
||||
GameListItem item = GameListCell.this.getItem();
|
||||
if (item != null) {
|
||||
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
|
||||
item.getRepository().setSelectedInstance(item.getGameInstance());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,8 +161,15 @@ public final class GameListCell extends ListCell<GameListItem> {
|
||||
|
||||
@Override
|
||||
public void updateItem(GameListItem item, boolean empty) {
|
||||
GameListItem oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.releaseRippleImmediately();
|
||||
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
|
||||
@@ -22,22 +22,25 @@ import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public class GameListItem extends GameItem {
|
||||
private final boolean isModpack;
|
||||
private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected");
|
||||
|
||||
public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
super(repository, instanceId);
|
||||
this.isModpack = repository.isModpack(instanceId);
|
||||
public GameListItem(HMCLGameInstance gameInstance) {
|
||||
super(gameInstance);
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
this.isModpack = gameInstance.isModpack();
|
||||
selected.bind(Bindings.createBooleanBinding(
|
||||
() -> {
|
||||
if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false;
|
||||
return Objects.equals(repository.getSelectedInstance(), instanceId);
|
||||
@Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance();
|
||||
return selectedInstance != null && selectedInstance.getId().equals(instanceId);
|
||||
},
|
||||
GameDirectoryManager.selectedGameDirectoryProperty(),
|
||||
GameDirectoryManager.selectedInstanceProperty()));
|
||||
@@ -48,39 +51,39 @@ public class GameListItem extends GameItem {
|
||||
}
|
||||
|
||||
public void rename() {
|
||||
Instances.renameInstance(repository, instanceId);
|
||||
Instances.renameInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void duplicate() {
|
||||
Instances.duplicateInstance(repository, instanceId);
|
||||
Instances.duplicateInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void remove() {
|
||||
Instances.deleteInstance(repository, instanceId);
|
||||
Instances.deleteInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void export() {
|
||||
Instances.exportInstance(repository, instanceId);
|
||||
Instances.exportInstance(gameInstance);
|
||||
}
|
||||
|
||||
public void browse() {
|
||||
Instances.openFolder(repository, instanceId);
|
||||
Instances.openFolder(gameInstance);
|
||||
}
|
||||
|
||||
public void testGame() {
|
||||
Instances.testGame(repository, instanceId);
|
||||
Instances.testGame(gameInstance);
|
||||
}
|
||||
|
||||
public void launch() {
|
||||
Instances.launch(repository, instanceId);
|
||||
Instances.launch(gameInstance);
|
||||
}
|
||||
|
||||
public void modifyGameSettings() {
|
||||
Instances.modifyGameSettings(repository, instanceId);
|
||||
Instances.modifyGameSettings(gameInstance);
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Instances.generateLaunchScript(repository, instanceId);
|
||||
Instances.generateLaunchScript(gameInstance);
|
||||
}
|
||||
|
||||
public boolean canUpdate() {
|
||||
@@ -88,6 +91,6 @@ public class GameListItem extends GameItem {
|
||||
}
|
||||
|
||||
public void update() {
|
||||
Instances.updateInstance(repository, instanceId);
|
||||
Instances.updateInstance(gameInstance);
|
||||
}
|
||||
}
|
||||
@@ -156,11 +156,13 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
setLoading(true);
|
||||
setFailedReason(null);
|
||||
|
||||
List<GameListItem> versionItems = repository.getDisplayInstanceManifests().map(instance -> new GameListItem(repository, instance.id())).toList();
|
||||
List<GameListItem> instanceItems = repository.getDisplayInstances()
|
||||
.map(GameListItem::new)
|
||||
.toList();
|
||||
|
||||
sourceList.setAll(versionItems);
|
||||
sourceList.setAll(instanceItems);
|
||||
|
||||
if (versionItems.isEmpty()) {
|
||||
if (instanceItems.isEmpty()) {
|
||||
setFailedReason(i18n("instance.empty.hint"));
|
||||
}
|
||||
|
||||
@@ -176,12 +178,12 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
String regex = searchText.substring("regex:".length());
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
return item -> pattern.matcher(item.id).find();
|
||||
return item -> pattern.matcher(item.getId()).find();
|
||||
} catch (PatternSyntaxException e) {
|
||||
return item -> false;
|
||||
}
|
||||
} else {
|
||||
return item -> item.id.toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
|
||||
return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,8 @@ import javafx.scene.control.ListView;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import javafx.stage.WindowEvent;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.ImageContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
@@ -46,27 +45,57 @@ import java.util.List;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
/// Displays game instances in a popup selection list.
|
||||
///
|
||||
/// @author Glavo
|
||||
public final class GameListPopupMenu extends StackPane {
|
||||
|
||||
private static final String KEY = GameListPopupMenu.class.getName() + ".popup";
|
||||
|
||||
public static boolean hideShowing(Node owner) {
|
||||
JFXPopup popup = (JFXPopup) owner.getProperties().get(KEY);
|
||||
if (popup != null && popup.isShowing()) {
|
||||
popup.hide();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows an instance selection popup relative to its owner.
|
||||
///
|
||||
/// @param owner the node used to position the popup
|
||||
/// @param vAlign the popup's vertical alignment relative to `owner`
|
||||
/// @param hAlign the popup's horizontal alignment relative to `owner`
|
||||
/// @param initOffsetX the horizontal offset from the aligned position
|
||||
/// @param initOffsetY the vertical offset from the aligned position
|
||||
/// @param instances the instances to copy into the popup, in display order
|
||||
public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
|
||||
double initOffsetX, double initOffsetY,
|
||||
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
|
||||
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, repository, versions);
|
||||
List<HMCLGameInstance> instances) {
|
||||
showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances);
|
||||
}
|
||||
|
||||
/// Shows and returns an instance selection popup relative to its owner.
|
||||
///
|
||||
/// @param owner the node used to position the popup
|
||||
/// @param vAlign the popup's vertical alignment relative to `owner`
|
||||
/// @param hAlign the popup's horizontal alignment relative to `owner`
|
||||
/// @param initOffsetX the horizontal offset from the aligned position
|
||||
/// @param initOffsetY the vertical offset from the aligned position
|
||||
/// @param instances the instances to copy into the popup, in display order
|
||||
/// @return the shown popup
|
||||
public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign,
|
||||
double initOffsetX, double initOffsetY,
|
||||
HMCLGameRepository repository, List<GameInstanceManifest> versions) {
|
||||
List<HMCLGameInstance> instances) {
|
||||
GameListPopupMenu menu = new GameListPopupMenu();
|
||||
menu.getItems().setAll(versions.stream()
|
||||
.filter(it -> repository.hasInstance(it.id()))
|
||||
.map(it -> new GameItem(repository, it.id()))
|
||||
menu.getItems().setAll(instances.stream()
|
||||
.map(GameItem::new)
|
||||
.toList());
|
||||
JFXPopup popup = new JFXPopup(menu);
|
||||
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
|
||||
owner.getProperties().put(KEY, popup);
|
||||
popup.addEventFilter(WindowEvent.WINDOW_HIDDEN, event -> owner.getProperties().remove(KEY, popup));
|
||||
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY, true);
|
||||
|
||||
return popup;
|
||||
}
|
||||
@@ -100,6 +129,7 @@ public final class GameListPopupMenu extends StackPane {
|
||||
private static final class Cell extends ListCell<GameItem> {
|
||||
|
||||
private final Region graphic;
|
||||
private final RipplerContainer ripplerContainer;
|
||||
|
||||
private final ImageContainer imageView;
|
||||
private final TwoLineListItem content;
|
||||
@@ -128,7 +158,7 @@ public final class GameListPopupMenu extends StackPane {
|
||||
container.setLeft(imageView);
|
||||
container.setCenter(content);
|
||||
|
||||
RipplerContainer ripplerContainer = new RipplerContainer(container);
|
||||
this.ripplerContainer = new RipplerContainer(container);
|
||||
|
||||
StackPane rootPane = new StackPane();
|
||||
rootPane.getStyleClass().add("advanced-list-item");
|
||||
@@ -138,7 +168,7 @@ public final class GameListPopupMenu extends StackPane {
|
||||
FXUtils.onClicked(rootPane, () -> {
|
||||
GameItem item = getItem();
|
||||
if (item != null) {
|
||||
item.getRepository().setSelectedInstance(new GameInstanceID(item.getId()));
|
||||
item.getRepository().setSelectedInstance(item.getGameInstance());
|
||||
if (getScene().getWindow() instanceof JFXPopup popup)
|
||||
popup.hide();
|
||||
}
|
||||
@@ -149,8 +179,14 @@ public final class GameListPopupMenu extends StackPane {
|
||||
|
||||
@Override
|
||||
protected void updateItem(GameItem item, boolean empty) {
|
||||
GameItem oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.ripplerContainer.releaseRippleImmediately();
|
||||
this.imageView.imageProperty().unbind();
|
||||
this.content.titleProperty().unbind();
|
||||
this.content.subtitleProperty().unbind();
|
||||
|
||||
+9
-24
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.game.LocalizedRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
@@ -31,40 +32,24 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
public static DownloadListPage ofMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofCurseForgeMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, CurseForgeRemoteAddonRepository.MODS, null);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofCurseForgeResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, null);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofModrinthResourcePack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.RESOURCE_PACK, null, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
|
||||
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.RESOURCE_PACK, CurseForgeRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.RESOURCE_PACKS);
|
||||
}
|
||||
|
||||
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
|
||||
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddonRepository.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
|
||||
var page = new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.SHADER_PACK, CurseForgeRemoteAddonRepository.SHADERS, ModrinthRemoteAddonRepository.SHADER_PACKS);
|
||||
page.supportChinese.set(false);
|
||||
return page;
|
||||
}
|
||||
|
||||
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddonRepository.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, boolean instanceSelection, RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
super(null, callback, instanceSelection);
|
||||
|
||||
repository = new Repository(type, curseForge, modrinth);
|
||||
@@ -98,11 +83,11 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
}
|
||||
|
||||
private class Repository extends LocalizedRemoteAddonRepository {
|
||||
private final RemoteAddonRepository.Type type;
|
||||
private final RemoteAddon.Type type;
|
||||
private final CurseForgeRemoteAddonRepository curseForge;
|
||||
private final ModrinthRemoteAddonRepository modrinth;
|
||||
|
||||
public Repository(Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
public Repository(RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseForge, ModrinthRemoteAddonRepository modrinth) {
|
||||
this.type = type;
|
||||
this.curseForge = curseForge;
|
||||
this.modrinth = modrinth;
|
||||
@@ -127,7 +112,7 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type getType() {
|
||||
public RemoteAddon.Type getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -36,25 +35,30 @@ import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class InstallerListPage extends ListPageBase<InstallerItem> implements GameInstancePage.GameInstanceLoadable {
|
||||
private HMCLGameRepository repository;
|
||||
private GameInstanceID instanceId;
|
||||
private GameInstanceManifest manifest;
|
||||
private String gameVersion;
|
||||
public class InstallerListPage extends ListPageBase<InstallerItem> {
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
|
||||
{
|
||||
FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
|
||||
/// Creates an installer list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public InstallerListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, it -> Set.of("jar", "exe").contains(FileUtils.getExtension(it)), mods -> {
|
||||
if (!mods.isEmpty())
|
||||
doInstallOffline(mods.get(0));
|
||||
});
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,78 +66,79 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ga
|
||||
return new InstallerListPageSkin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
this.manifest = repository.getInstanceManifest(instanceId);
|
||||
this.gameVersion = null;
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
gameVersion = repository.getGameVersion(manifest).orElse(null);
|
||||
|
||||
return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion);
|
||||
}).thenAcceptAsync(analyzer -> {
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
HMCLGameInstance gameInstance = instance.instance();
|
||||
this.gameInstance = gameInstance;
|
||||
if (gameInstance == null) {
|
||||
itemsProperty().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM);
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
|
||||
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
|
||||
for (InstallerItem item : group.getLibraries()) {
|
||||
String libraryId = item.getLibraryId();
|
||||
itemsProperty().clear();
|
||||
InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM);
|
||||
|
||||
// Skip fabric-api and quilt-api and legacyfabric-api
|
||||
if (libraryId.endsWith("-api")) {
|
||||
continue;
|
||||
}
|
||||
// Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine
|
||||
for (InstallerItem component : group.getComponents()) {
|
||||
|
||||
String libraryVersion = analyzer.getVersion(libraryId).orElse(null);
|
||||
|
||||
if (libraryVersion != null) {
|
||||
item.versionProperty().set(new InstallerItem.InstalledState(
|
||||
libraryVersion,
|
||||
analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR,
|
||||
false
|
||||
));
|
||||
} else {
|
||||
item.versionProperty().set(null);
|
||||
}
|
||||
|
||||
item.setOnInstall(() -> {
|
||||
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion));
|
||||
});
|
||||
|
||||
item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
|
||||
.thenComposeAsync(repository::saveAsync)
|
||||
.withComposeAsync(repository.refreshAsync())
|
||||
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
|
||||
.start());
|
||||
|
||||
itemsProperty().add(item);
|
||||
// Skip fabric-api and quilt-api and legacyfabric-api
|
||||
if (component.getComponentType().getPatchId().endsWith("-api")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// other third-party libraries which are unable to manage.
|
||||
for (LibraryAnalyzer.LibraryMark mark : analyzer) {
|
||||
String libraryId = mark.getLibraryId();
|
||||
String libraryVersion = mark.getLibraryVersion();
|
||||
if ("mcbbs".equals(libraryId))
|
||||
continue;
|
||||
@Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType());
|
||||
|
||||
// we have done this library above.
|
||||
if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null)
|
||||
continue;
|
||||
|
||||
InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM);
|
||||
installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false));
|
||||
installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId)
|
||||
.thenComposeAsync(repository::saveAsync)
|
||||
.withComposeAsync(repository.refreshAsync())
|
||||
.withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId))
|
||||
.start());
|
||||
|
||||
itemsProperty().add(installerItem);
|
||||
if (libraryVersion != null) {
|
||||
component.versionProperty().set(new InstallerItem.InstalledState(
|
||||
libraryVersion,
|
||||
!gameInstance.getAnalyzer().isClear(component.getComponentType()),
|
||||
false
|
||||
));
|
||||
} else {
|
||||
component.versionProperty().set(null);
|
||||
}
|
||||
}, Platform::runLater);
|
||||
|
||||
component.setOnInstall(() -> {
|
||||
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion));
|
||||
});
|
||||
|
||||
component.setOnRemove(() -> repository.updateInstanceAsync(
|
||||
gameInstance.getId(),
|
||||
publishedInstance -> repository.getDependency().removeComponentAsync(
|
||||
publishedInstance,
|
||||
component.getComponentType()))
|
||||
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
|
||||
.start());
|
||||
|
||||
itemsProperty().add(component);
|
||||
}
|
||||
|
||||
var currentItems = getItems().stream().map(InstallerItem::getComponentType).toList();
|
||||
// other third-party libraries which are unable to manage.
|
||||
for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) {
|
||||
if (currentItems.contains(mark.componentType())) continue;
|
||||
|
||||
// we have done this library above.
|
||||
|
||||
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
|
||||
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
|
||||
installerItem.setOnRemove(() -> repository.updateInstanceAsync(
|
||||
gameInstance.getId(),
|
||||
publishedInstance -> repository.getDependency().removeComponentAsync(
|
||||
publishedInstance,
|
||||
mark.componentType()))
|
||||
.withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance)
|
||||
.start());
|
||||
|
||||
itemsProperty().add(installerItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void reloadCurrentInstance() {
|
||||
if (gameInstance != null) {
|
||||
loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
public void installOffline() {
|
||||
@@ -144,16 +149,21 @@ public class InstallerListPage extends ListPageBase<InstallerItem> implements Ga
|
||||
}
|
||||
|
||||
private void doInstallOffline(Path file) {
|
||||
Task<?> task = repository.getDependency().installLibraryAsync(manifest, file)
|
||||
.thenComposeAsync(repository::saveAsync)
|
||||
.thenComposeAsync(repository.refreshAsync());
|
||||
if (gameInstance == null || !gameInstance.getManifest().isModifiable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
Task<?> task = repository.updateInstanceAsync(
|
||||
gameInstance.getId(),
|
||||
publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file));
|
||||
task.setName(i18n("install.installer.install_offline"));
|
||||
TaskExecutor executor = task.executor(new TaskListener() {
|
||||
@Override
|
||||
public void onStop(boolean success, TaskExecutor executor) {
|
||||
runInFX(() -> {
|
||||
if (success) {
|
||||
loadInstance(repository, instanceId);
|
||||
reloadCurrentInstance();
|
||||
Controllers.dialog(i18n("install.success"));
|
||||
} else {
|
||||
if (executor.getException() == null)
|
||||
|
||||
@@ -28,11 +28,7 @@ import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameLibrariesTask;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.QuickPlayOption;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
@@ -52,6 +48,7 @@ import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@@ -60,6 +57,7 @@ import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -106,9 +104,7 @@ public final class Instances {
|
||||
if (StringUtils.isNotBlank(mod.iconUrl()))
|
||||
installWizardProvider.setIconUrl(mod.iconUrl());
|
||||
Controllers.getDecorator().startWizard(installWizardProvider);
|
||||
} else if (e instanceof CancellationException) {
|
||||
Controllers.showToast(i18n("message.cancelled"));
|
||||
} else {
|
||||
} else if (!(e instanceof CancellationException)) {
|
||||
Controllers.dialog(
|
||||
i18n("install.failed.downloading.detail", file.file().url()) + "\n" + StringUtils.getStackTrace(e),
|
||||
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
|
||||
@@ -119,9 +115,11 @@ public final class Instances {
|
||||
);
|
||||
}
|
||||
|
||||
public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize()
|
||||
.equals(repository.getInstanceRoot(instanceId).toAbsolutePath().normalize());
|
||||
public static void deleteInstance(HMCLGameInstance gameInstance) {
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
boolean isIndependent = gameInstance.getRunDirectory().toAbsolutePath().normalize()
|
||||
.equals(gameInstance.getInstanceRoot().toAbsolutePath().normalize());
|
||||
String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) :
|
||||
i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed");
|
||||
|
||||
@@ -139,7 +137,9 @@ public final class Instances {
|
||||
Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton);
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> renameInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
public static CompletableFuture<String> renameInstance(HMCLGameInstance gameInstance) {
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
return Controllers.prompt(i18n("instance.manage.rename.message"), (newName, handler) -> {
|
||||
if (newName.equals(instanceId.toString())) {
|
||||
handler.resolve();
|
||||
@@ -152,7 +152,7 @@ public final class Instances {
|
||||
repository.refreshAsync()
|
||||
.thenRunAsync(Schedulers.javafx(), () -> {
|
||||
if (repository.hasInstance(newInstanceId)) {
|
||||
repository.setSelectedInstance(newInstanceId);
|
||||
repository.setSelectedInstance(repository.getInstance(newInstanceId));
|
||||
}
|
||||
}).start();
|
||||
} else {
|
||||
@@ -163,22 +163,24 @@ public final class Instances {
|
||||
new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString())));
|
||||
}
|
||||
|
||||
public static void exportInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, instanceId), i18n("modpack.wizard"));
|
||||
public static void exportInstance(HMCLGameInstance gameInstance) {
|
||||
Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard"));
|
||||
}
|
||||
|
||||
public static void openFolder(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
FXUtils.openFolder(repository.getRunDirectory(instanceId));
|
||||
public static void openFolder(HMCLGameInstance gameInstance) {
|
||||
FXUtils.openFolder(gameInstance.getRunDirectory());
|
||||
}
|
||||
|
||||
public static void installFromJson(HMCLGameRepository repository, Path file) {
|
||||
GameInstanceManifest manifest;
|
||||
try {
|
||||
manifest = JsonUtils.fromJsonFile(file, GameInstanceManifest.class);
|
||||
if (manifest == null) {
|
||||
if (manifest == null)
|
||||
throw new IllegalArgumentException("Missing game manifest");
|
||||
}
|
||||
if (manifest.inheritsFrom() != null)
|
||||
throw new IllegalArgumentException("Game manifest inherits from another manifest");
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to read game manifest from " + file, e);
|
||||
Controllers.dialog(i18n("install.new_game.malformed_json"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
|
||||
return;
|
||||
}
|
||||
@@ -189,21 +191,48 @@ public final class Instances {
|
||||
GameInstanceID instanceId = new GameInstanceID(result);
|
||||
|
||||
DefaultDependencyManager dependencyManager = repository.getDependency();
|
||||
GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId);
|
||||
GameInstanceManifest newManifest = manifest.withId(instanceId).withJar(instanceId);
|
||||
GameDownloadTask gameDownloadTask = new GameDownloadTask(
|
||||
dependencyManager,
|
||||
newManifest);
|
||||
AtomicReference<GameRepositoryDraft> activeDraft = new AtomicReference<>();
|
||||
|
||||
Controllers.taskDialog(
|
||||
Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion),
|
||||
Task.supplyAsync(() -> {
|
||||
GameRepositoryDraft draft = repository.openDraft();
|
||||
activeDraft.set(draft);
|
||||
return draft;
|
||||
})
|
||||
.thenComposeAsync(draft -> Task.allOf(
|
||||
gameDownloadTask,
|
||||
Task.allOf(
|
||||
new GameAssetDownloadTask(dependencyManager, newVersion, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true),
|
||||
new GameLibrariesTask(dependencyManager, newVersion, true)
|
||||
).withRunAsync(() -> {
|
||||
// ignore failure
|
||||
}))
|
||||
.thenComposeAsync(repository.saveAsync(newVersion))
|
||||
.thenRunAsync(repository::refresh)
|
||||
new GameAssetDownloadTask(
|
||||
dependencyManager,
|
||||
newManifest,
|
||||
GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY,
|
||||
true),
|
||||
new GameLibrariesTask(dependencyManager, newManifest, true))
|
||||
.withRunAsync(() -> {
|
||||
// ignore failure
|
||||
})))
|
||||
.thenAcceptAsync(ignored -> {
|
||||
GameRepositoryDraft draft = activeDraft.get();
|
||||
if (draft == null) {
|
||||
throw new IllegalStateException("Game repository draft is unavailable");
|
||||
}
|
||||
draft.put(newManifest);
|
||||
draft.putPrimaryJar(instanceId, gameDownloadTask.getResult());
|
||||
draft.commit();
|
||||
})
|
||||
.whenComplete(exception -> {
|
||||
GameRepositoryDraft draft = activeDraft.getAndSet(null);
|
||||
if (draft != null && draft.isOpen()) {
|
||||
draft.abort();
|
||||
}
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (exception) -> {
|
||||
if (exception == null) {
|
||||
repository.setSelectedInstance(new GameInstanceID(result));
|
||||
repository.setSelectedInstance(repository.getInstance(instanceId));
|
||||
} else {
|
||||
Controllers.dialog(
|
||||
DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR);
|
||||
@@ -212,7 +241,9 @@ public final class Instances {
|
||||
}, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName)));
|
||||
}
|
||||
|
||||
public static void duplicateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
public static void duplicateInstance(HMCLGameInstance gameInstance) {
|
||||
HMCLGameRepository repository = gameInstance.getRepository();
|
||||
GameInstanceID instanceId = gameInstance.getId();
|
||||
Controllers.prompt(
|
||||
new PromptDialogPane.Builder(i18n("instance.manage.duplicate.prompt"), (res, handler) -> {
|
||||
String newInstanceName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue();
|
||||
@@ -238,33 +269,35 @@ public final class Instances {
|
||||
.addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false)));
|
||||
}
|
||||
|
||||
public static void updateInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, instanceId));
|
||||
public static void updateInstance(HMCLGameInstance gameInstance) {
|
||||
Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId()));
|
||||
}
|
||||
|
||||
public static void updateGameAssets(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getInstanceManifest(instanceId), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true)
|
||||
.executor();
|
||||
public static void updateGameAssets(HMCLGameInstance gameInstance) {
|
||||
TaskExecutor executor = new GameAssetDownloadTask(
|
||||
gameInstance.getRepository().getDependency(),
|
||||
gameInstance.getResolvedManifest(),
|
||||
GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY,
|
||||
true).executor();
|
||||
Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL);
|
||||
executor.start();
|
||||
}
|
||||
|
||||
public static void cleanInstance(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
public static void cleanInstance(HMCLGameInstance gameInstance) {
|
||||
try {
|
||||
repository.clean(instanceId);
|
||||
gameInstance.getRepository().clean(gameInstance.getId());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to clean game directory", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
|
||||
if (!checkVersionForLaunching(repository, instanceId))
|
||||
return;
|
||||
public static void generateLaunchScript(HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
|
||||
ensureSelectedAccount(account -> {
|
||||
Path runDirectory = gameInstance.getRunDirectory();
|
||||
FileChooser chooser = new FileChooser();
|
||||
if (Files.isDirectory(repository.getRunDirectory(instanceId)))
|
||||
chooser.setInitialDirectory(repository.getRunDirectory(instanceId).toFile());
|
||||
if (Files.isDirectory(runDirectory))
|
||||
chooser.setInitialDirectory(runDirectory.toFile());
|
||||
chooser.setTitle(i18n("instance.launch_script.save"));
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
chooser.getExtensionFilters().add(
|
||||
@@ -282,7 +315,7 @@ public final class Instances {
|
||||
file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt);
|
||||
}
|
||||
|
||||
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
|
||||
LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account);
|
||||
for (Consumer<LauncherHelper> injecter : injecters) {
|
||||
injecter.accept(launcherHelper);
|
||||
}
|
||||
@@ -306,42 +339,16 @@ public final class Instances {
|
||||
};
|
||||
}
|
||||
|
||||
/// Launches the given instance after ensuring that an account is selected.
|
||||
///
|
||||
/// If `gameInstance` is `null`, an error dialog is shown and no account selection or launch is
|
||||
/// attempted.
|
||||
///
|
||||
/// @param gameInstance the instance to launch, or `null` when no instance is available
|
||||
/// @param injecters callbacks that configure the launcher before launch
|
||||
@SafeVarargs
|
||||
public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer<LauncherHelper>... injecters) {
|
||||
if (!checkVersionForLaunching(repository, instanceId))
|
||||
return;
|
||||
ensureSelectedAccount(account -> {
|
||||
LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId);
|
||||
for (Consumer<LauncherHelper> injecter : injecters) {
|
||||
injecter.accept(launcherHelper);
|
||||
}
|
||||
launcherHelper.launch();
|
||||
});
|
||||
}
|
||||
|
||||
public static void testGame(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
launch(repository, instanceId, LauncherHelper::setTestMode);
|
||||
}
|
||||
|
||||
public static void launchAndEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) {
|
||||
launch(repository, instanceId, launcherHelper ->
|
||||
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
|
||||
}
|
||||
|
||||
public static void generateLaunchScriptForQuickEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) {
|
||||
generateLaunchScript(repository, instanceId, launcherHelper ->
|
||||
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
|
||||
}
|
||||
|
||||
private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
boolean unavailable;
|
||||
if (instanceId == null || !repository.isLoaded()) {
|
||||
unavailable = true;
|
||||
} else {
|
||||
unavailable = !repository.hasInstance(instanceId);
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer<LauncherHelper>... injecters) {
|
||||
if (gameInstance == null) {
|
||||
JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download"));
|
||||
gotoDownload.getStyleClass().add("dialog-accept");
|
||||
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
|
||||
@@ -350,10 +357,30 @@ public final class Instances {
|
||||
MessageDialogPane.MessageType.ERROR,
|
||||
gotoDownload,
|
||||
null);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSelectedAccount(account -> {
|
||||
LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account);
|
||||
for (Consumer<LauncherHelper> injecter : injecters) {
|
||||
injecter.accept(launcherHelper);
|
||||
}
|
||||
launcherHelper.launch();
|
||||
});
|
||||
}
|
||||
|
||||
public static void testGame(HMCLGameInstance gameInstance) {
|
||||
launch(gameInstance, LauncherHelper::setTestMode);
|
||||
}
|
||||
|
||||
public static void launchAndEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) {
|
||||
launch(gameInstance, launcherHelper ->
|
||||
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
|
||||
}
|
||||
|
||||
public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) {
|
||||
generateLaunchScript(gameInstance, launcherHelper ->
|
||||
launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName)));
|
||||
}
|
||||
|
||||
private static void ensureSelectedAccount(Consumer<Account> action) {
|
||||
@@ -391,10 +418,11 @@ public final class Instances {
|
||||
Controllers.navigate(Controllers.getSettingsPage());
|
||||
}
|
||||
|
||||
public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
Controllers.getGameInstancePage().setInstance(instanceId, repository);
|
||||
public static void modifyGameSettings(HMCLGameInstance gameInstance) {
|
||||
Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository());
|
||||
Controllers.getGameInstancePage().showInstanceSettings();
|
||||
// VersionPage.loadVersion will be invoked after navigation
|
||||
Controllers.navigate(Controllers.getGameInstancePage());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,51 +17,93 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.Skin;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.mod.ModManager;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageAware;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
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;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObject> implements GameInstancePage.GameInstanceLoadable, PageAware {
|
||||
public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> implements PageAware {
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private ModManager modManager;
|
||||
private HMCLGameRepository repository;
|
||||
private GameInstanceID instanceId;
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
private String gameVersion;
|
||||
|
||||
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
|
||||
|
||||
public ModListPage() {
|
||||
/// Creates a mod list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public ModListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
|
||||
mods.forEach(it -> {
|
||||
try {
|
||||
@@ -72,6 +114,12 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
});
|
||||
loadMods(modManager);
|
||||
});
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,15 +131,15 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
loadMods(modManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameInstanceManifest resolved = repository.getResolvedInstanceManifest(instanceId).standaloneManifest();
|
||||
this.gameVersion = repository.getGameVersion(resolved).orElse(null);
|
||||
this.gameVersion = gameInstance.getVersion().toString();
|
||||
|
||||
loadMods(repository.getModManager(instanceId));
|
||||
loadMods(gameInstance.getModManager());
|
||||
}
|
||||
|
||||
private void loadMods(ModManager modManager) {
|
||||
@@ -105,7 +153,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
lock.lock();
|
||||
try {
|
||||
modManager.refresh();
|
||||
return modManager.getLocalFiles().stream().map(ModListPageSkin.ModInfoObject::new).toList();
|
||||
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} finally {
|
||||
@@ -131,13 +179,13 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
private void updateSupportedLoaders(ModManager modManager) {
|
||||
supportedLoaders.clear();
|
||||
|
||||
LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer();
|
||||
GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer();
|
||||
if (analyzer == null) {
|
||||
Collections.addAll(supportedLoaders, ModLoaderType.values());
|
||||
return;
|
||||
}
|
||||
|
||||
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
|
||||
for (GameComponentType type : GameComponentType.MOD_LOADERS) {
|
||||
if (type.isModLoader() && analyzer.has(type)) {
|
||||
ModLoaderType modLoaderType = type.getModLoaderType();
|
||||
if (modLoaderType != null) {
|
||||
@@ -149,26 +197,26 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
|
||||
if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) {
|
||||
if (analyzer.has(GameComponentType.QUILT)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) {
|
||||
if (analyzer.has(GameComponentType.LEGACY_FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGE);
|
||||
}
|
||||
|
||||
// Sinytra Connector
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|
||||
|| "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|
||||
|| "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
}
|
||||
@@ -176,7 +224,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("mods.add.title"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.litemod"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
|
||||
List<Path> res = Controllers.showOpenMultipleDialog(chooser);
|
||||
|
||||
if (res == null) return;
|
||||
@@ -208,11 +256,11 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}).start();
|
||||
}
|
||||
|
||||
void removeSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void removeSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
try {
|
||||
modManager.removeMods(selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModListPageSkin.ModInfoObject::getModInfo)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toArray(LocalModFile[]::new));
|
||||
loadMods(modManager);
|
||||
} catch (IOException ignore) {
|
||||
@@ -220,34 +268,40 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
}
|
||||
|
||||
void enableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void enableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModListPageSkin.ModInfoObject::getModInfo)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(true));
|
||||
}
|
||||
|
||||
void disableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void disableSelected(ObservableList<ModInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModListPageSkin.ModInfoObject::getModInfo)
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(false));
|
||||
}
|
||||
|
||||
public void openModFolder() {
|
||||
FXUtils.openFolder(repository.getRunDirectory(instanceId).resolve("mods"));
|
||||
if (gameInstance != null) {
|
||||
FXUtils.openFolder(gameInstance.getModsDirectory());
|
||||
}
|
||||
}
|
||||
|
||||
public void checkUpdates(Collection<LocalModFile> mods) {
|
||||
Objects.requireNonNull(mods);
|
||||
if (isLoading()) {
|
||||
if (isLoading() || gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
HMCLGameInstance gameInstance = this.gameInstance;
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
Optional<String> gameVersion = repository.getGameVersion(instanceId);
|
||||
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null);
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
return version != GameVersionNumber.unknown()
|
||||
? new AddonCheckUpdatesTask<>(
|
||||
DownloadProviders.getDownloadProvider(), version.toString(), mods)
|
||||
: null;
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception instanceof CancellationException) return;
|
||||
@@ -262,7 +316,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
.withStagesHints("update.checking"),
|
||||
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
|
||||
|
||||
if (repository.isModpack(instanceId)) {
|
||||
if (gameInstance.isModpack()) {
|
||||
Controllers.confirm(
|
||||
i18n("mods.update_modpack_mod.warning"), null,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
@@ -273,7 +327,10 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
|
||||
public void download() {
|
||||
Controllers.getDownloadPage().showModDownloads().selectInstance(instanceId);
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
Controllers.getDownloadPage().showModDownloads().selectInstance(gameInstance.getId());
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
}
|
||||
|
||||
@@ -287,14 +344,597 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
|
||||
public GameDirectory getGameDirectory() {
|
||||
return this.repository.getGameDirectory();
|
||||
return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null;
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return this.repository;
|
||||
return gameInstance != null ? gameInstance.getRepository() : null;
|
||||
}
|
||||
|
||||
public GameInstanceID getInstanceId() {
|
||||
return this.instanceId;
|
||||
return gameInstance != null ? gameInstance.getId() : null;
|
||||
}
|
||||
|
||||
@NotNullByDefault
|
||||
private static final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox toolbarNormal;
|
||||
private final HBox toolbarSelecting;
|
||||
|
||||
private final JFXListView<ModListPage.ModInfoObject> listView;
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
private final JFXTextField searchField;
|
||||
|
||||
/// Timer for debouncing search input to avoid executing search on every keystroke.
|
||||
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
|
||||
|
||||
public ModListPageSkin(ModListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
StackPane pane = new StackPane();
|
||||
pane.setPadding(new Insets(10));
|
||||
pane.getStyleClass().addAll("notice-pane");
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
pane.getChildren().setAll(root);
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
searchBar = new HBox();
|
||||
toolbarNormal = new HBox();
|
||||
toolbarSelecting = new HBox();
|
||||
|
||||
// Search Bar
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
searchPause.setOnFinished(e -> search());
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
|
||||
searchPause.setRate(1);
|
||||
searchPause.playFromStart();
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
changeToolbar(toolbarNormal);
|
||||
|
||||
searchField.clear();
|
||||
searchPause.stop();
|
||||
|
||||
isSearching.set(false);
|
||||
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
|
||||
});
|
||||
|
||||
onEscPressed(searchField, closeSearchBar::fire);
|
||||
|
||||
searchBar.getChildren().setAll(searchField, closeSearchBar);
|
||||
|
||||
// Toolbar Normal
|
||||
toolbarNormal.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getItems().stream()
|
||||
.map(ModListPage.ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
|
||||
// Toolbar Selecting
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> {
|
||||
selectAll.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
};
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
.map(ModListPage.ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
selectAll,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
|
||||
else
|
||||
changeToolbar(toolbarSelecting);
|
||||
});
|
||||
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
// Clear selection when pressing ESC
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new ModListPage.ModInfoListCell(listView, skinnable));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("mods.empty"));
|
||||
placeholderLabel.textProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (isSearching.get()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return i18n("mods.empty");
|
||||
}
|
||||
},
|
||||
isSearching)
|
||||
);
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
skinnable.getItems().addListener((ListChangeListener<? super ModListPage.ModInfoObject>) c -> {
|
||||
if (isSearching.get()) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
listView.setOnContextMenuRequested(event -> {
|
||||
ModListPage.ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ModListPage.ModInfoDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
|
||||
// So we ignore it here
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
Node oldToolbar = toolbarPane.getCurrentNode();
|
||||
if (newToolbar != oldToolbar) {
|
||||
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
if (newToolbar == searchBar) {
|
||||
Platform.runLater(searchField::requestFocus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void search() {
|
||||
isSearching.set(true);
|
||||
|
||||
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
|
||||
|
||||
String queryString = searchField.getText();
|
||||
if (StringUtils.isBlank(queryString)) {
|
||||
listView.getItems().setAll(getSkinnable().getItems());
|
||||
} else {
|
||||
listView.getItems().clear();
|
||||
|
||||
Predicate<@Nullable String> predicate;
|
||||
try {
|
||||
predicate = StringUtils.compileQuery(queryString);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do we need to search in the background thread?
|
||||
for (ModListPage.ModInfoObject item : getSkinnable().getItems()) {
|
||||
LocalModFile modInfo = item.getModInfo();
|
||||
if (predicate.test(modInfo.getFileName())
|
||||
|| predicate.test(modInfo.getName())
|
||||
|| predicate.test(modInfo.getVersion())
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
|
||||
private SoftReference<CompletableFuture<Image>> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
}
|
||||
|
||||
public LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private Image loadIcon() {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
|
||||
iconPaths.add(this.localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
|
||||
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load mod icons", e);
|
||||
}
|
||||
|
||||
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
|
||||
}
|
||||
|
||||
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<ModInfoObject>> current) {
|
||||
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
|
||||
CompletableFuture<Image> imageFuture;
|
||||
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
|
||||
Image image = imageFuture.getNow(null);
|
||||
if (image != null) {
|
||||
imageContainer.setImage(image);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
|
||||
this.iconCache = new SoftReference<>(imageFuture);
|
||||
}
|
||||
imageContainer.setImage(GameInstanceIconType.getIconType(localModFile.getModLoaderType()).getIcon());
|
||||
imageFuture.thenAcceptAsync(image -> {
|
||||
if (current != null) {
|
||||
ObjectProperty<ModInfoObject> infoObjectProperty = current.get();
|
||||
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
|
||||
// The current ListCell has already switched to another object
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
imageContainer.setImage(image);
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ModInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ModInfoDialog(ModInfoObject modInfo) {
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
titleContainer.setPadding(new Insets(0, 0, 12, 0));
|
||||
|
||||
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
|
||||
prefWidthProperty().bind(widthBinding);
|
||||
maxWidthProperty().bind(widthBinding);
|
||||
|
||||
var imageContainer = new ImageContainer(40);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
modInfo.loadIcon(imageContainer, null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
StringJoiner subtitle = new StringJoiner("\n");
|
||||
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(subtitle.toString());
|
||||
|
||||
titleContainer.getChildren().setAll(imageContainer, title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(modInfo.getModInfo().getDescription().toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
|
||||
ScrollPane descriptionPane = new ScrollPane(description);
|
||||
FXUtils.smoothScrolling(descriptionPane);
|
||||
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
descriptionPane.setFitToWidth(true);
|
||||
description.heightProperty().addListener((obs, oldVal, newVal) -> {
|
||||
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
|
||||
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
|
||||
descriptionPane.setPrefViewportHeight(targetHeight);
|
||||
});
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
Task.runAsync(() -> {
|
||||
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
|
||||
if (versionOptional.isPresent()) {
|
||||
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
|
||||
FXUtils.runInFX(() -> {
|
||||
for (ModLoaderType modLoaderType : versionOptional.get().loaders()) {
|
||||
String loaderName = switch (modLoaderType) {
|
||||
case FORGE -> i18n("install.installer.forge");
|
||||
case CLEANROOM -> i18n("install.installer.cleanroom");
|
||||
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric");
|
||||
case NEO_FORGE -> i18n("install.installer.neoforge");
|
||||
case FABRIC -> i18n("install.installer.fabric");
|
||||
case LITE_LOADER -> i18n("install.installer.liteloader");
|
||||
case QUILT -> i18n("install.installer.quilt");
|
||||
default -> null;
|
||||
};
|
||||
if (loaderName == null)
|
||||
continue;
|
||||
if (title.getTags()
|
||||
.stream()
|
||||
.noneMatch(it -> it.getText().equals(loaderName))) {
|
||||
title.addTag(loaderName);
|
||||
}
|
||||
}
|
||||
|
||||
button.setExternalLink(remoteAddon.pageUrl());
|
||||
button.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
button.setDisable(true);
|
||||
getActions().add(button);
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
|
||||
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
|
||||
officialPageButton.setExternalLink(modInfo.getModInfo().getUrl());
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setExternalLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
|
||||
pair("key", modInfo.getModInfo().getName()),
|
||||
pair("site", "all"),
|
||||
pair("filter", "0")
|
||||
)));
|
||||
getActions().add(searchButton);
|
||||
} else {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setExternalLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
|
||||
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
|
||||
|
||||
private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
|
||||
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
|
||||
|
||||
private final ModListPage page;
|
||||
|
||||
private final JFXCheckBox checkBox = new JFXCheckBox();
|
||||
private final ImageContainer imageContainer = new ImageContainer(32);
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
|
||||
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
|
||||
private BooleanProperty booleanProperty;
|
||||
|
||||
private Tooltip warningTooltip;
|
||||
|
||||
ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
|
||||
super(listView);
|
||||
this.page = page;
|
||||
|
||||
this.getStyleClass().add("mod-info-list-cell");
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
|
||||
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
|
||||
|
||||
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
|
||||
|
||||
container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
warningTooltip = null;
|
||||
}
|
||||
|
||||
if (empty) return;
|
||||
|
||||
List<String> warning = new ArrayList<>();
|
||||
|
||||
content.getTags().clear();
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
|
||||
|
||||
String displayName = modInfo.getName();
|
||||
if (modTranslations != null && I18n.isUseChinese()) {
|
||||
String chineseName = modTranslations.getName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.containsEmoji(chineseName)) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
chineseName.codePoints().forEach(ch -> {
|
||||
if (ch < 0x1F300 || ch > 0x1FAFF)
|
||||
builder.appendCodePoint(ch);
|
||||
});
|
||||
|
||||
chineseName = builder.toString().trim();
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName)) {
|
||||
displayName = displayName + " (" + chineseName + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
content.setTitle(displayName);
|
||||
|
||||
StringJoiner joiner = new StringJoiner(" | ");
|
||||
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
|
||||
joiner.add(modInfo.getId());
|
||||
|
||||
joiner.add(FileUtils.getName(modInfo.getFile()));
|
||||
|
||||
content.setSubtitle(joiner.toString());
|
||||
|
||||
if (modLoaderType == ModLoaderType.UNKNOWN) {
|
||||
content.addTagWarning(i18n("mods.unknown"));
|
||||
} else if (!page.supportedLoaders.contains(modLoaderType)) {
|
||||
warning.add(i18n("mods.warning.loader_mismatch"));
|
||||
switch (dataItem.getModInfo().getModLoaderType()) {
|
||||
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
|
||||
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
|
||||
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
|
||||
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
|
||||
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
|
||||
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
|
||||
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
|
||||
}
|
||||
}
|
||||
|
||||
String modVersion = modInfo.getVersion();
|
||||
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
|
||||
content.addTag(modVersion);
|
||||
}
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
|
||||
() -> page.rollback(modInfo, localModFile),
|
||||
popup.get()))
|
||||
.toList()
|
||||
);
|
||||
|
||||
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
|
||||
});
|
||||
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
|
||||
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
|
||||
|
||||
if (!warning.isEmpty()) {
|
||||
pseudoClassStateChanged(WARNING, true);
|
||||
|
||||
//noinspection ConstantValue
|
||||
this.warningTooltip = warning.size() == 1
|
||||
? new Tooltip(warning.get(0))
|
||||
: new Tooltip(String.join("\n", warning));
|
||||
FXUtils.installFastTooltip(this, warningTooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,680 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.DoubleBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameInstanceIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
|
||||
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;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@NotNullByDefault
|
||||
final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
private final HBox toolbarNormal;
|
||||
private final HBox toolbarSelecting;
|
||||
|
||||
private final JFXListView<ModInfoObject> listView;
|
||||
|
||||
/// Whether the search mechanism is currently active.
|
||||
private final BooleanProperty isSearching = new SimpleBooleanProperty(false);
|
||||
|
||||
private final JFXTextField searchField;
|
||||
|
||||
/// Timer for debouncing search input to avoid executing search on every keystroke.
|
||||
private final PauseTransition searchPause = new PauseTransition(Duration.millis(100));
|
||||
|
||||
ModListPageSkin(ModListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
StackPane pane = new StackPane();
|
||||
pane.setPadding(new Insets(10));
|
||||
pane.getStyleClass().addAll("notice-pane");
|
||||
|
||||
ComponentList root = new ComponentList();
|
||||
pane.getChildren().setAll(root);
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
listView.getStyleClass().add("no-horizontal-scrollbar");
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
searchBar = new HBox();
|
||||
toolbarNormal = new HBox();
|
||||
toolbarSelecting = new HBox();
|
||||
|
||||
// Search Bar
|
||||
searchBar.setAlignment(Pos.CENTER);
|
||||
searchBar.setPadding(new Insets(0, 5, 0, 5));
|
||||
searchField = new JFXTextField();
|
||||
searchField.setPromptText(i18n("search"));
|
||||
HBox.setHgrow(searchField, Priority.ALWAYS);
|
||||
searchPause.setOnFinished(e -> search());
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (isSearching.get() || !StringUtils.isBlank(newValue)) {
|
||||
searchPause.setRate(1);
|
||||
searchPause.playFromStart();
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
|
||||
() -> {
|
||||
changeToolbar(toolbarNormal);
|
||||
|
||||
searchField.clear();
|
||||
searchPause.stop();
|
||||
|
||||
isSearching.set(false);
|
||||
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
|
||||
});
|
||||
|
||||
onEscPressed(searchField, closeSearchBar::fire);
|
||||
|
||||
searchBar.getChildren().setAll(searchField, closeSearchBar);
|
||||
|
||||
// Toolbar Normal
|
||||
toolbarNormal.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getItems().stream()
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
createToolbarButton2(i18n("mods.download"), SVG.DOWNLOAD, skinnable::download),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
);
|
||||
|
||||
// Toolbar Selecting
|
||||
|
||||
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
|
||||
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));
|
||||
|
||||
ListChangeListener<Object> listener = change -> {
|
||||
selectAll.setDisable(!listView.getItems().isEmpty()
|
||||
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
|
||||
};
|
||||
|
||||
listView.getSelectionModel().getSelectedItems().addListener(listener);
|
||||
listView.getItems().addListener(listener);
|
||||
|
||||
toolbarSelecting.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
|
||||
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
|
||||
skinnable.removeSelected(listView.getSelectionModel().getSelectedItems());
|
||||
}, null);
|
||||
}),
|
||||
createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () ->
|
||||
skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
|
||||
skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())),
|
||||
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
|
||||
skinnable.checkUpdates(
|
||||
listView.getSelectionModel().getSelectedItems().stream()
|
||||
.map(ModInfoObject::getModInfo)
|
||||
.toList()
|
||||
)
|
||||
),
|
||||
selectAll,
|
||||
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
|
||||
listView.getSelectionModel().clearSelection())
|
||||
);
|
||||
|
||||
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
|
||||
selectedItem -> {
|
||||
if (selectedItem == null)
|
||||
changeToolbar(isSearching.get() ? searchBar : toolbarNormal);
|
||||
else
|
||||
changeToolbar(toolbarSelecting);
|
||||
});
|
||||
|
||||
FXUtils.setOverflowHidden(toolbarPane, 8);
|
||||
|
||||
root.getContent().add(toolbarPane);
|
||||
|
||||
// Clear selection when pressing ESC
|
||||
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
|
||||
if (e.getCode() == KeyCode.ESCAPE) {
|
||||
if (listView.getSelectionModel().getSelectedItem() != null) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
SpinnerPane center = new SpinnerPane();
|
||||
ComponentList.setVgrow(center, Priority.ALWAYS);
|
||||
center.loadingProperty().bind(skinnable.loadingProperty());
|
||||
|
||||
listView.setCellFactory(x -> new ModInfoListCell(listView));
|
||||
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
|
||||
StackPane placeholderContainer = new StackPane();
|
||||
placeholderContainer.getStyleClass().add("notice-pane");
|
||||
Label placeholderLabel = new Label(i18n("mods.empty"));
|
||||
placeholderLabel.textProperty().bind(
|
||||
Bindings.createStringBinding(() -> {
|
||||
if (isSearching.get()) {
|
||||
return i18n("search.no_results_found");
|
||||
} else {
|
||||
return i18n("mods.empty");
|
||||
}
|
||||
},
|
||||
isSearching)
|
||||
);
|
||||
placeholderContainer.getChildren().add(placeholderLabel);
|
||||
listView.setPlaceholder(placeholderContainer);
|
||||
|
||||
Bindings.bindContent(listView.getItems(), skinnable.getItems());
|
||||
skinnable.getItems().addListener((ListChangeListener<? super ModInfoObject>) c -> {
|
||||
if (isSearching.get()) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
listView.setOnContextMenuRequested(event -> {
|
||||
ModInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ModInfoDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
|
||||
// So we ignore it here
|
||||
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
|
||||
|
||||
center.setContent(listView);
|
||||
root.getContent().add(center);
|
||||
}
|
||||
|
||||
getChildren().setAll(pane);
|
||||
}
|
||||
|
||||
private void changeToolbar(HBox newToolbar) {
|
||||
Node oldToolbar = toolbarPane.getCurrentNode();
|
||||
if (newToolbar != oldToolbar) {
|
||||
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
|
||||
if (newToolbar == searchBar) {
|
||||
Platform.runLater(searchField::requestFocus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void search() {
|
||||
isSearching.set(true);
|
||||
|
||||
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
|
||||
|
||||
String queryString = searchField.getText();
|
||||
if (StringUtils.isBlank(queryString)) {
|
||||
listView.getItems().setAll(getSkinnable().getItems());
|
||||
} else {
|
||||
listView.getItems().clear();
|
||||
|
||||
Predicate<@Nullable String> predicate;
|
||||
try {
|
||||
predicate = StringUtils.compileQuery(queryString);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Illegal regular expression", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do we need to search in the background thread?
|
||||
for (ModInfoObject item : getSkinnable().getItems()) {
|
||||
LocalModFile modInfo = item.getModInfo();
|
||||
if (predicate.test(modInfo.getFileName())
|
||||
|| predicate.test(modInfo.getName())
|
||||
|| predicate.test(modInfo.getVersion())
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
|
||||
private SoftReference<CompletableFuture<Image>> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
}
|
||||
|
||||
LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private Image loadIcon() {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
|
||||
iconPaths.add(this.localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) {
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, 80, 80, true, true);
|
||||
if (!image.isError() && image.getWidth() > 0 && image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load mod icons", e);
|
||||
}
|
||||
|
||||
return GameInstanceIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
|
||||
}
|
||||
|
||||
public void loadIcon(ImageContainer imageContainer, @Nullable WeakReference<ObjectProperty<ModInfoObject>> current) {
|
||||
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
|
||||
CompletableFuture<Image> imageFuture;
|
||||
if (iconCache != null && (imageFuture = iconCache.get()) != null) {
|
||||
Image image = imageFuture.getNow(null);
|
||||
if (image != null) {
|
||||
imageContainer.setImage(image);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
|
||||
this.iconCache = new SoftReference<>(imageFuture);
|
||||
}
|
||||
imageContainer.setImage(GameInstanceIconType.getIconType(localModFile.getModLoaderType()).getIcon());
|
||||
imageFuture.thenAcceptAsync(image -> {
|
||||
if (current != null) {
|
||||
ObjectProperty<ModInfoObject> infoObjectProperty = current.get();
|
||||
if (infoObjectProperty == null || infoObjectProperty.get() != this) {
|
||||
// The current ListCell has already switched to another object
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
imageContainer.setImage(image);
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
}
|
||||
|
||||
final class ModInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ModInfoDialog(ModInfoObject modInfo) {
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
titleContainer.setPadding(new Insets(0, 0, 12, 0));
|
||||
|
||||
DoubleBinding widthBinding = Controllers.getDecorator().contentWidthProperty().multiply(0.7);
|
||||
prefWidthProperty().bind(widthBinding);
|
||||
maxWidthProperty().bind(widthBinding);
|
||||
|
||||
var imageContainer = new ImageContainer(40);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
modInfo.loadIcon(imageContainer, null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
StringJoiner subtitle = new StringJoiner("\n");
|
||||
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(subtitle.toString());
|
||||
|
||||
titleContainer.getChildren().setAll(imageContainer, title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(modInfo.getModInfo().getDescription().toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
|
||||
ScrollPane descriptionPane = new ScrollPane(description);
|
||||
FXUtils.smoothScrolling(descriptionPane);
|
||||
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
descriptionPane.setFitToWidth(true);
|
||||
description.heightProperty().addListener((obs, oldVal, newVal) -> {
|
||||
double maxHeight = Controllers.getDecorator().contentHeightProperty().get() * 0.5;
|
||||
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
|
||||
descriptionPane.setPrefViewportHeight(targetHeight);
|
||||
});
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteAddonRepository> item : Arrays.asList(
|
||||
pair("addon.curseforge", CurseForgeRemoteAddonRepository.MODS),
|
||||
pair("addon.modrinth", ModrinthRemoteAddonRepository.MODS)
|
||||
)) {
|
||||
RemoteAddonRepository repository = item.getValue();
|
||||
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
|
||||
Task.runAsync(() -> {
|
||||
Optional<RemoteAddon.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
|
||||
if (versionOptional.isPresent()) {
|
||||
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
|
||||
FXUtils.runInFX(() -> {
|
||||
for (ModLoaderType modLoaderType : versionOptional.get().loaders()) {
|
||||
String loaderName = switch (modLoaderType) {
|
||||
case FORGE -> i18n("install.installer.forge");
|
||||
case CLEANROOM -> i18n("install.installer.cleanroom");
|
||||
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric");
|
||||
case NEO_FORGE -> i18n("install.installer.neoforge");
|
||||
case FABRIC -> i18n("install.installer.fabric");
|
||||
case LITE_LOADER -> i18n("install.installer.liteloader");
|
||||
case QUILT -> i18n("install.installer.quilt");
|
||||
default -> null;
|
||||
};
|
||||
if (loaderName == null)
|
||||
continue;
|
||||
if (title.getTags()
|
||||
.stream()
|
||||
.noneMatch(it -> it.getText().equals(loaderName))) {
|
||||
title.addTag(loaderName);
|
||||
}
|
||||
}
|
||||
|
||||
button.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
Controllers.navigate(new DownloadPage(
|
||||
repository instanceof CurseForgeRemoteAddonRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
|
||||
remoteAddon,
|
||||
new HMCLGameRepository.InstanceReference(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()),
|
||||
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD
|
||||
));
|
||||
});
|
||||
button.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
button.setDisable(true);
|
||||
getActions().add(button);
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getUrl())) {
|
||||
JFXHyperlink officialPageButton = new JFXHyperlink(i18n("mods.url"));
|
||||
officialPageButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
FXUtils.openLink(modInfo.getModInfo().getUrl());
|
||||
});
|
||||
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
FXUtils.openLink(NetworkUtils.withQuery("https://search.mcmod.cn/s", mapOf(
|
||||
pair("key", modInfo.getModInfo().getName()),
|
||||
pair("site", "all"),
|
||||
pair("filter", "0")
|
||||
)));
|
||||
});
|
||||
getActions().add(searchButton);
|
||||
} else {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
FXUtils.openLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
|
||||
});
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Lazy<PopupMenu> menu = new Lazy<>(PopupMenu::new);
|
||||
private static final Lazy<JFXPopup> popup = new Lazy<>(() -> new JFXPopup(menu.get()));
|
||||
|
||||
final class ModInfoListCell extends MDListCell<ModInfoObject> {
|
||||
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
|
||||
|
||||
JFXCheckBox checkBox = new JFXCheckBox();
|
||||
ImageContainer imageContainer = new ImageContainer(32);
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
|
||||
JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
|
||||
JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
BooleanProperty booleanProperty;
|
||||
|
||||
Tooltip warningTooltip;
|
||||
|
||||
ModInfoListCell(JFXListView<ModInfoObject> listView) {
|
||||
super(listView);
|
||||
|
||||
this.getStyleClass().add("mod-info-list-cell");
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
|
||||
imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());
|
||||
|
||||
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));
|
||||
|
||||
container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
warningTooltip = null;
|
||||
}
|
||||
|
||||
if (empty) return;
|
||||
|
||||
List<String> warning = new ArrayList<>();
|
||||
|
||||
content.getTags().clear();
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.loadIcon(imageContainer, new WeakReference<>(this.itemProperty()));
|
||||
|
||||
String displayName = modInfo.getName();
|
||||
if (modTranslations != null && I18n.isUseChinese()) {
|
||||
String chineseName = modTranslations.getName();
|
||||
if (StringUtils.containsChinese(chineseName)) {
|
||||
if (StringUtils.containsEmoji(chineseName)) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
chineseName.codePoints().forEach(ch -> {
|
||||
if (ch < 0x1F300 || ch > 0x1FAFF)
|
||||
builder.appendCodePoint(ch);
|
||||
});
|
||||
|
||||
chineseName = builder.toString().trim();
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName)) {
|
||||
displayName = displayName + " (" + chineseName + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
content.setTitle(displayName);
|
||||
|
||||
StringJoiner joiner = new StringJoiner(" | ");
|
||||
if (modLoaderType != ModLoaderType.UNKNOWN && StringUtils.isNotBlank(modInfo.getId()))
|
||||
joiner.add(modInfo.getId());
|
||||
|
||||
joiner.add(FileUtils.getName(modInfo.getFile()));
|
||||
|
||||
content.setSubtitle(joiner.toString());
|
||||
|
||||
if (modLoaderType == ModLoaderType.UNKNOWN) {
|
||||
content.addTagWarning(i18n("mods.unknown"));
|
||||
} else if (!ModListPageSkin.this.getSkinnable().supportedLoaders.contains(modLoaderType)) {
|
||||
warning.add(i18n("mods.warning.loader_mismatch"));
|
||||
switch (dataItem.getModInfo().getModLoaderType()) {
|
||||
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
|
||||
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
|
||||
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
|
||||
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
|
||||
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
|
||||
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
|
||||
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
|
||||
}
|
||||
}
|
||||
|
||||
String modVersion = modInfo.getVersion();
|
||||
if (StringUtils.isNotBlank(modVersion) && !"${version}".equals(modVersion)) {
|
||||
content.addTag(modVersion);
|
||||
}
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active);
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
|
||||
() -> getSkinnable().rollback(modInfo, localModFile),
|
||||
popup.get()))
|
||||
.toList()
|
||||
);
|
||||
|
||||
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
|
||||
});
|
||||
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
|
||||
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));
|
||||
|
||||
if (!warning.isEmpty()) {
|
||||
pseudoClassStateChanged(WARNING, true);
|
||||
|
||||
//noinspection ConstantValue
|
||||
this.warningTooltip = warning.size() == 1
|
||||
? new Tooltip(warning.get(0))
|
||||
: new Tooltip(String.join("\n", warning));
|
||||
FXUtils.installFastTooltip(this, warningTooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.instances;
|
||||
|
||||
import org.jackhuang.hmcl.addon.RemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.RemoteAddon;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -56,7 +56,8 @@ public enum ModTranslations {
|
||||
}
|
||||
};
|
||||
|
||||
public static ModTranslations getTranslationsByRepositoryType(RemoteAddonRepository.Type type) {
|
||||
public static ModTranslations getTranslationsByAddonType(@Nullable RemoteAddon.Type type) {
|
||||
if (type == null) return EMPTY;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
|
||||
@@ -23,6 +23,7 @@ import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -43,8 +44,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile;
|
||||
import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.SettingsManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
@@ -53,18 +53,21 @@ import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.ListPageBase;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
@@ -76,7 +79,7 @@ import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> implements GameInstancePage.GameInstanceLoadable {
|
||||
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> {
|
||||
|
||||
private static final String TIP_KEY = "resourcePackWarning";
|
||||
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
|
||||
@@ -90,16 +93,26 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
};
|
||||
}
|
||||
|
||||
private HMCLGameRepository repository;
|
||||
private GameInstanceID instanceId;
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
|
||||
private Path resourcePackDirectory;
|
||||
private ResourcePackManager resourcePackManager;
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public ResourcePackListPage() {
|
||||
/// Creates a resource-pack list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public ResourcePackListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -107,11 +120,16 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
return new ResourcePackListPageSkin(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
this.resourcePackManager = new ResourcePackManager(repository, instanceId);
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
if (gameInstance == null) {
|
||||
this.resourcePackManager = null;
|
||||
this.resourcePackDirectory = null;
|
||||
getItems().clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.resourcePackManager = gameInstance.getResourcePackManager();
|
||||
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
|
||||
|
||||
refresh();
|
||||
@@ -185,7 +203,10 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
}
|
||||
|
||||
private void onDownload() {
|
||||
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(instanceId);
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId());
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
}
|
||||
|
||||
@@ -232,10 +253,18 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
}
|
||||
|
||||
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
|
||||
HMCLGameInstance gameInstance = this.gameInstance;
|
||||
if (gameInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
Optional<String> gameVersion = repository.getGameVersion(instanceId);
|
||||
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null);
|
||||
GameVersionNumber version = gameInstance.getVersion();
|
||||
return version != GameVersionNumber.unknown()
|
||||
? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(),
|
||||
version.toString(), resourcePacks)
|
||||
: null;
|
||||
})
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception != null || result == null) {
|
||||
@@ -249,7 +278,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
.withStagesHints("update.checking"),
|
||||
i18n("addon.check_update"), TaskCancellationAction.NORMAL);
|
||||
|
||||
if (repository.isModpack(instanceId)) {
|
||||
if (gameInstance.isModpack()) {
|
||||
Controllers.confirm(
|
||||
i18n("resourcepack.update_in_modpack.warning"), null,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
@@ -404,7 +433,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
|
||||
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
|
||||
listView.getSelectionModel().clearSelection();
|
||||
Controllers.dialog(new ResourcePackInfoDialog(control, selectedItem));
|
||||
Controllers.dialog(new ResourcePackInfoDialog(selectedItem));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -503,8 +532,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
|
||||
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
|
||||
|
||||
private ResourcePackInfoObject object = null;
|
||||
|
||||
private BooleanProperty booleanProperty = null;
|
||||
|
||||
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
|
||||
@@ -520,7 +547,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
checkBox = new JFXCheckBox() {
|
||||
@Override
|
||||
public void fire() {
|
||||
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && object != null && !object.getFile().isCompatible()) {
|
||||
if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && getItem() != null && !getItem().getFile().isCompatible()) {
|
||||
Controllers.confirm(
|
||||
i18n("resourcepack.warning.manipulate"),
|
||||
i18n("message.info"),
|
||||
@@ -535,7 +562,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
}
|
||||
};
|
||||
checkBox.setOnAction(e -> {
|
||||
if (object != null) object.file.setEnabled(checkBox.isSelected());
|
||||
if (getItem() != null) getItem().getFile().setEnabled(checkBox.isSelected());
|
||||
});
|
||||
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
@@ -555,7 +582,6 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
|
||||
if (empty || item == null) return;
|
||||
|
||||
this.object = item;
|
||||
ResourcePackFile file = item.getFile();
|
||||
imageContainer.setImage(item.getIcon());
|
||||
|
||||
@@ -569,7 +595,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
|
||||
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
|
||||
|
||||
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(this.page, item)));
|
||||
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(item)));
|
||||
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
@@ -588,7 +614,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
|
||||
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ResourcePackInfoDialog(ResourcePackListPage page, ResourcePackInfoObject packInfoObject) {
|
||||
ResourcePackInfoDialog(ResourcePackInfoObject packInfoObject) {
|
||||
ResourcePackFile pack = packInfoObject.getFile();
|
||||
|
||||
HBox titleContainer = new HBox();
|
||||
@@ -638,17 +664,7 @@ public final class ResourcePackListPage extends ListPageBase<ResourcePackListPag
|
||||
if (versionOptional.isPresent()) {
|
||||
RemoteAddon remoteAddon = repository.getAddonById(DownloadProviders.getDownloadProvider(), versionOptional.get().projectId());
|
||||
FXUtils.runInFX(() -> {
|
||||
button.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
Controllers.navigate(new DownloadPage(
|
||||
repository instanceof CurseForgeRemoteAddonRepository
|
||||
? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false)
|
||||
: HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false),
|
||||
remoteAddon,
|
||||
new HMCLGameRepository.InstanceReference(page.repository, page.instanceId),
|
||||
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK
|
||||
));
|
||||
});
|
||||
button.setExternalLink(remoteAddon.pageUrl());
|
||||
button.setDisable(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.instances;
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
@@ -34,13 +35,13 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.schematic.LitematicFile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.*;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.FileNameSet;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
@@ -54,6 +55,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
@@ -63,7 +65,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> implements GameInstancePage.GameInstanceLoadable {
|
||||
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
|
||||
|
||||
private static String translateAuthorName(String author) {
|
||||
if (I18n.isUseChinese() && "hsds".equals(author)) {
|
||||
@@ -72,14 +74,25 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
return author;
|
||||
}
|
||||
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
private Path schematicsDirectory;
|
||||
private DirItem currentDirectory;
|
||||
|
||||
public SchematicsPage() {
|
||||
/// Creates a schematics list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this,
|
||||
file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"),
|
||||
this::addFiles
|
||||
);
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,9 +100,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
return new SchematicsPageSkin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.schematicsDirectory = repository.getSchematicsDirectory(instanceId);
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
HMCLGameInstance gameInstance = instance.instance();
|
||||
this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null;
|
||||
|
||||
refresh();
|
||||
}
|
||||
@@ -161,10 +174,20 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
return;
|
||||
|
||||
Path parent = currentDirectory.path;
|
||||
|
||||
FileNameSet existingPaths;
|
||||
try {
|
||||
existingPaths = FileNameSet.list(parent, null);
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to list folders in " + parent, e);
|
||||
existingPaths = new FileNameSet(false);
|
||||
}
|
||||
|
||||
Controllers.prompt(
|
||||
i18n("schematics.create_directory.prompt"),
|
||||
(result, handler) -> {
|
||||
Path targetDir = parent.resolve(result);
|
||||
|
||||
if (Files.exists(targetDir)) {
|
||||
handler.reject(i18n("schematics.create_directory.failed.already_exists"));
|
||||
return;
|
||||
@@ -178,7 +201,11 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
LOG.warning("Failed to create directory: " + targetDir, e);
|
||||
handler.reject(i18n("schematics.create_directory.failed", targetDir));
|
||||
}
|
||||
}, "", new RequiredValidator(), new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid));
|
||||
},
|
||||
"",
|
||||
new RequiredValidator(),
|
||||
new Validator(i18n("schematics.create_directory.failed.invalid_name"), FileUtils::isNameValid),
|
||||
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
|
||||
}
|
||||
|
||||
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
|
||||
@@ -598,8 +625,14 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
|
||||
@Override
|
||||
protected void updateItem(Item item, boolean empty) {
|
||||
Item oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
graphics.releaseRippleImmediately();
|
||||
iconImageView.setImage(null);
|
||||
|
||||
if (empty || item == null) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
@@ -36,8 +37,7 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -56,7 +56,7 @@ import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
|
||||
import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes;
|
||||
@@ -64,23 +64,33 @@ import static org.jackhuang.hmcl.util.i18n.I18n.formatDateTime;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class WorldListPage extends ListPageBase<World> implements GameInstancePage.GameInstanceLoadable {
|
||||
public final class WorldListPage extends ListPageBase<World> {
|
||||
private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false);
|
||||
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
|
||||
|
||||
private Path savesDir;
|
||||
private List<World> worlds;
|
||||
private HMCLGameRepository repository;
|
||||
private GameInstanceID instanceId;
|
||||
private @Nullable HMCLGameInstance gameInstance;
|
||||
private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false);
|
||||
|
||||
private int refreshCount = 0;
|
||||
|
||||
public WorldListPage() {
|
||||
/// Creates a world list that reloads when `instanceContext` changes.
|
||||
///
|
||||
/// @param instanceContext the parent page's instance property
|
||||
public WorldListPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
|
||||
Objects.requireNonNull(instanceContext, "instanceContext");
|
||||
FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> {
|
||||
installWorld(modpacks.get(0));
|
||||
});
|
||||
|
||||
showAll.addListener(e -> updateWorldList());
|
||||
|
||||
listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> {
|
||||
if (current != null) {
|
||||
loadInstance(current);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -88,21 +98,19 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
return new WorldListPageSkin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) {
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
this.savesDir = repository.getSavesDirectory(instanceId);
|
||||
public void loadInstance(HMCLGameInstance.Optional instance) {
|
||||
this.gameInstance = instance.instance();
|
||||
this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null;
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void updateWorldList() {
|
||||
if (worlds == null) {
|
||||
if (worlds == null || gameInstance == null) {
|
||||
getItems().clear();
|
||||
} else if (showAll.get()) {
|
||||
getItems().setAll(worlds);
|
||||
} else {
|
||||
GameVersionNumber gameVersion = repository.getGameVersion(instanceId).map(GameVersionNumber::asGameVersion).orElse(null);
|
||||
GameVersionNumber gameVersion = gameInstance.getVersion();
|
||||
getItems().setAll(worlds.stream()
|
||||
.filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion))
|
||||
.toList());
|
||||
@@ -110,15 +118,16 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (repository == null || instanceId == null)
|
||||
if (gameInstance == null || savesDir == null)
|
||||
return;
|
||||
|
||||
int currentRefresh = ++refreshCount;
|
||||
HMCLGameInstance gameInstance = this.gameInstance;
|
||||
|
||||
setLoading(true);
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
// Ensure the game version number is parsed
|
||||
repository.getGameVersion(instanceId);
|
||||
gameInstance.getVersion();
|
||||
return World.getWorlds(savesDir);
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (refreshCount != currentRefresh) {
|
||||
@@ -126,8 +135,7 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<String> gameVersion = repository.getGameVersion(instanceId);
|
||||
supportQuickPlay.set(World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)));
|
||||
supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion()));
|
||||
|
||||
worlds = result;
|
||||
updateWorldList();
|
||||
@@ -180,7 +188,9 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
}
|
||||
|
||||
private void showManagePage(World world) {
|
||||
Controllers.navigate(new WorldManagePage(world, repository, instanceId));
|
||||
if (gameInstance != null) {
|
||||
Controllers.navigate(new WorldManagePage(world, gameInstance));
|
||||
}
|
||||
}
|
||||
|
||||
public void export(World world) {
|
||||
@@ -200,11 +210,15 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
}
|
||||
|
||||
public void launch(World world) {
|
||||
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
|
||||
if (gameInstance != null) {
|
||||
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
public void generateLaunchScript(World world) {
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
|
||||
if (gameInstance != null) {
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
public BooleanProperty showAllProperty() {
|
||||
@@ -330,6 +344,7 @@ public final class WorldListPage extends ListPageBase<World> implements GameInst
|
||||
|
||||
if (oldWorld == world && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.releaseRippleImmediately();
|
||||
this.content.getTags().clear();
|
||||
|
||||
if (empty || world == null) {
|
||||
|
||||
@@ -24,8 +24,7 @@ import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.World;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -36,13 +35,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.ChunkBaseApp;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -54,8 +51,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
|
||||
private final World world;
|
||||
private final Path backupsDir;
|
||||
private final HMCLGameRepository repository;
|
||||
private final GameInstanceID instanceId;
|
||||
private final HMCLGameInstance gameInstance;
|
||||
private final boolean supportQuickPlay;
|
||||
private FileChannel sessionLockChannel;
|
||||
|
||||
@@ -70,11 +66,10 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
private final TabHeader.Tab<WorldBackupsPage> worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage");
|
||||
private final TabHeader.Tab<DataPackListPage> dataPackTab = new TabHeader.Tab<>("dataPackListPage");
|
||||
|
||||
public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) {
|
||||
public WorldManagePage(World world, HMCLGameInstance gameInstance) {
|
||||
this.world = world;
|
||||
this.backupsDir = repository.getBackupsDirectory(instanceId);
|
||||
this.repository = repository;
|
||||
this.instanceId = instanceId;
|
||||
this.gameInstance = gameInstance;
|
||||
this.backupsDir = gameInstance.getBackupsDirectory();
|
||||
|
||||
updateSessionLockChannel();
|
||||
|
||||
@@ -91,8 +86,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
|
||||
this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true));
|
||||
|
||||
Optional<String> gameVersion = repository.getGameVersion(instanceId);
|
||||
supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion));
|
||||
supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion());
|
||||
|
||||
this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited);
|
||||
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated);
|
||||
@@ -151,11 +145,11 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco
|
||||
|
||||
public void launch() {
|
||||
fireEvent(new PageCloseEvent());
|
||||
Instances.launchAndEnterWorld(repository, instanceId, world.getFileName());
|
||||
Instances.launchAndEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
|
||||
public void generateLaunchScript() {
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName());
|
||||
Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,9 +31,12 @@ import org.jackhuang.hmcl.setting.DownloadSource;
|
||||
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
|
||||
import org.jackhuang.hmcl.setting.ProxyType;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.FXThread;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
@@ -128,7 +131,7 @@ public class DownloadSettingsPage extends StackPane {
|
||||
settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty()));
|
||||
|
||||
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
|
||||
cleanButton.setOnAction(e -> clearCacheDirectory());
|
||||
cleanButton.setOnAction(e -> clearCacheDirectory(cleanButton));
|
||||
fileCommonLocationSublist.setHeaderRight(cleanButton);
|
||||
|
||||
ComponentSublist downloadThreadsSublist = new ComponentSublist(() -> {
|
||||
@@ -348,10 +351,27 @@ public class DownloadSettingsPage extends StackPane {
|
||||
|
||||
}
|
||||
|
||||
private void clearCacheDirectory() {
|
||||
@FXThread
|
||||
private boolean cleaningCache = false;
|
||||
|
||||
private void clearCacheDirectory(JFXButton cleanButton) {
|
||||
if (cleaningCache) return;
|
||||
String commonDirectory = settings().getResolvedCommonDirectory();
|
||||
if (commonDirectory != null) {
|
||||
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
|
||||
cleaningCache = true;
|
||||
cleanButton.setMinWidth(cleanButton.getWidth());
|
||||
var txt = cleanButton.getText();
|
||||
cleanButton.setText("");
|
||||
var spinner = new JFXSpinner();
|
||||
spinner.setRadius(8);
|
||||
cleanButton.setGraphic(spinner);
|
||||
Task.runAsync("Clear Cache Directory", Schedulers.io(), () -> FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")))
|
||||
.whenComplete(Schedulers.javafx(), ignored -> {
|
||||
cleanButton.setGraphic(null);
|
||||
cleanButton.setText(txt);
|
||||
cleaningCache = false;
|
||||
}).setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
@@ -184,7 +183,10 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
|
||||
@FXThread
|
||||
private void loadJava(Collection<JavaRuntime> javaRuntimes) {
|
||||
if (javaRuntimes != null) {
|
||||
this.setItems(FXCollections.observableArrayList(javaRuntimes));
|
||||
// JavaRuntime equality is path-based. setAll can therefore retain a stale item when the
|
||||
// Java installation at an existing path has been upgraded.
|
||||
this.getItems().clear();
|
||||
this.getItems().addAll(javaRuntimes);
|
||||
this.setLoading(false);
|
||||
} else {
|
||||
this.setLoading(true);
|
||||
@@ -227,7 +229,7 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
|
||||
}
|
||||
|
||||
private static final class JavaItemCell extends ListCell<JavaRuntime> {
|
||||
private final Node graphic;
|
||||
private final RipplerContainer graphic;
|
||||
private final Label label = new Label();
|
||||
private final TwoLineListItem content;
|
||||
|
||||
@@ -298,7 +300,14 @@ public final class JavaManagementPage extends ListPageBase<JavaRuntime> {
|
||||
@Override
|
||||
protected void updateItem(JavaRuntime item, boolean empty) {
|
||||
JavaRuntime oldItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(item, empty);
|
||||
|
||||
if (oldItem == item && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.releaseRippleImmediately();
|
||||
|
||||
if (empty || item == null) {
|
||||
setGraphic(null);
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.main;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -48,7 +49,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
|
||||
public LauncherSettingsPage() {
|
||||
gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class));
|
||||
gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class, null));
|
||||
javaManagementTab.setNodeSupplier(JavaManagementPage::new);
|
||||
settingsTab.setNodeSupplier(SettingsPage::new);
|
||||
personalizationTab.setNodeSupplier(PersonalizationPage::new);
|
||||
@@ -59,7 +60,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab);
|
||||
|
||||
tab.select(gameTab);
|
||||
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(GameDirectoryManager.getSelectedRepository(), null));
|
||||
addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())));
|
||||
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
.addNavigationDrawerTab(tab, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL)
|
||||
@@ -89,7 +90,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor
|
||||
}
|
||||
|
||||
public void showGameSettings(HMCLGameRepository repository) {
|
||||
gameTab.getNode().loadInstance(repository, null);
|
||||
gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository));
|
||||
tab.select(gameTab, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import javafx.animation.KeyValue;
|
||||
import javafx.animation.RotateTransition;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.EventHandler;
|
||||
@@ -43,14 +44,10 @@ import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.DownloadProvider;
|
||||
import org.jackhuang.hmcl.download.VersionList;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.download.ComponentVersionList;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -76,30 +73,40 @@ 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 org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE;
|
||||
import static org.jackhuang.hmcl.download.ComponentRemoteVersion.Type.RELEASE;
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.state;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.SINE;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// Displays the launcher home controls for the currently selected game repository.
|
||||
public final class MainPage extends StackPane implements DecoratorPage {
|
||||
private static final String ANNOUNCEMENT = "announcement";
|
||||
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
private final ObjectProperty<@Nullable GameInstanceID> currentGame = new SimpleObjectProperty<>(this, "currentGame");
|
||||
private final ObjectProperty<@Nullable HMCLGameInstance> currentGame = new SimpleObjectProperty<>(this, "currentGame");
|
||||
private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate");
|
||||
private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog");
|
||||
private final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>(this, "latestVersion");
|
||||
private final ObservableList<GameInstanceManifest> versions = FXCollections.observableArrayList();
|
||||
private HMCLGameRepository repository;
|
||||
/// Mutable storage for visible instances from the selected repository's current snapshot.
|
||||
private final ObservableList<HMCLGameInstance> mutableInstances = FXCollections.observableArrayList();
|
||||
|
||||
/// Read-only observable view of [#mutableInstances].
|
||||
private final @UnmodifiableView ObservableList<HMCLGameInstance> instances =
|
||||
FXCollections.unmodifiableObservableList(mutableInstances);
|
||||
|
||||
/// Current snapshot of the repository selected by [GameDirectoryManager].
|
||||
private final ObservableValue<HMCLGameRepositorySnapshot> selectedRepositorySnapshot =
|
||||
BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty())
|
||||
.flatMap(HMCLGameRepository::snapshotProperty);
|
||||
|
||||
private TransitionPane announcementPane;
|
||||
private final StackPane updatePane;
|
||||
@@ -211,10 +218,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
HBox launchPane = new HBox();
|
||||
launchPane.getStyleClass().add("launch-pane");
|
||||
FXUtils.onScroll(launchPane, versions, list -> {
|
||||
GameInstanceID currentId = getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
|
||||
}, it -> repository.setSelectedInstance(it.id()));
|
||||
FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList()));
|
||||
FXUtils.onScroll(launchPane, instances, list -> {
|
||||
@Nullable HMCLGameInstance currentGame = getCurrentGame();
|
||||
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, instance -> instance.getRepository().setSelectedInstance(instance));
|
||||
|
||||
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
|
||||
{
|
||||
@@ -233,7 +242,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
private Tooltip tooltip;
|
||||
|
||||
@Override
|
||||
public void accept(@Nullable GameInstanceID currentGame) {
|
||||
public void accept(@Nullable HMCLGameInstance currentGame) {
|
||||
if (currentGame == null) {
|
||||
launchLabel.setText(i18n("instance.launch.empty"));
|
||||
currentLabel.setText(null);
|
||||
@@ -244,7 +253,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
FXUtils.installFastTooltip(launchButton, tooltip);
|
||||
} else {
|
||||
launchLabel.setText(i18n("instance.launch"));
|
||||
currentLabel.setText(currentGame.toString());
|
||||
currentLabel.setText(currentGame.getId().toString());
|
||||
graphic.getChildren().setAll(launchLabel, currentLabel);
|
||||
FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch);
|
||||
if (tooltip != null)
|
||||
@@ -259,13 +268,17 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
menuButton = new JFXButton();
|
||||
menuButton.getStyleClass().add("menu-button");
|
||||
menuButton.setOnAction(e -> {
|
||||
if (GameListPopupMenu.hideShowing(menuButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
JFXPopup popup = GameListPopupMenu.showAndGetPopup(
|
||||
menuButton,
|
||||
JFXPopup.PopupVPosition.BOTTOM,
|
||||
JFXPopup.PopupHPosition.RIGHT,
|
||||
0,
|
||||
-menuButton.getHeight(),
|
||||
repository, versions
|
||||
instances
|
||||
);
|
||||
|
||||
Node graphic = menuButton.getGraphic();
|
||||
@@ -342,12 +355,12 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
private void launch() {
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
Instances.launch(repository, repository.getSelectedInstance());
|
||||
Instances.launch(repository.getSelectedInstance());
|
||||
}
|
||||
|
||||
private void launchNoGame() {
|
||||
DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider();
|
||||
VersionList<?> versionList = downloadProvider.getVersionListById("game");
|
||||
ComponentVersionList<?> versionList = downloadProvider.getVersionList(GameComponentType.GAME);
|
||||
|
||||
Holder<GameInstanceID> instanceHolder = new Holder<>();
|
||||
Task<?> task = versionList.refreshAsync("")
|
||||
@@ -359,26 +372,25 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
.orElseThrow(() -> new IOException("No versions found")))
|
||||
.thenComposeAsync(version -> {
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
DefaultDependencyManager dependency = repository.getDependency();
|
||||
HMCLDependencyManager dependency = repository.getDependency();
|
||||
|
||||
String gameVersion = version.getGameVersion();
|
||||
GameInstanceID instanceId = new GameInstanceID(gameVersion);
|
||||
|
||||
instanceHolder.value = instanceId;
|
||||
|
||||
return dependency.newGameBuilder()
|
||||
.name(instanceId)
|
||||
.gameVersion(gameVersion)
|
||||
.buildAsync();
|
||||
try (HMCLGameBuilder builder = dependency.newGameBuilder(instanceId)) {
|
||||
return builder
|
||||
.component(GameComponentType.GAME, gameVersion)
|
||||
.buildAsync();
|
||||
}
|
||||
})
|
||||
.whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh())
|
||||
.whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
GameDirectoryManager.getSelectedRepository().setSelectedInstance(instanceHolder.value);
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
repository.setSelectedInstance(repository.getInstance(instanceHolder.value));
|
||||
launch();
|
||||
} else if (exception instanceof CancellationException) {
|
||||
Controllers.showToast(i18n("message.cancelled"));
|
||||
} else {
|
||||
} else if (!(exception instanceof CancellationException)) {
|
||||
LOG.warning("Failed to install game", exception);
|
||||
Controllers.dialog(StringUtils.getStackTrace(exception),
|
||||
i18n("install.failed"),
|
||||
@@ -406,28 +418,35 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
return state;
|
||||
}
|
||||
|
||||
public GameDirectory getGameDirectory() {
|
||||
return repository.getGameDirectory();
|
||||
}
|
||||
|
||||
public HMCLGameRepository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public GameInstanceID getCurrentGame() {
|
||||
/// Returns the instance shown by the launch controls.
|
||||
///
|
||||
/// @return the current instance, or `null` when no instance is selected
|
||||
public @Nullable HMCLGameInstance getCurrentGame() {
|
||||
return currentGame.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<@Nullable GameInstanceID> currentGameProperty() {
|
||||
/// Returns the property for the instance shown by the launch controls.
|
||||
///
|
||||
/// @return the current-instance property
|
||||
public ObjectProperty<@Nullable HMCLGameInstance> currentGameProperty() {
|
||||
return currentGame;
|
||||
}
|
||||
|
||||
public void setCurrentGame(@Nullable GameInstanceID currentGame) {
|
||||
/// Sets the instance shown by the launch controls.
|
||||
///
|
||||
/// @param currentGame the instance to show, or `null` to show the empty state
|
||||
public void setCurrentGame(@Nullable HMCLGameInstance currentGame) {
|
||||
this.currentGame.set(currentGame);
|
||||
}
|
||||
|
||||
public ObservableList<GameInstanceManifest> getVersions() {
|
||||
return versions;
|
||||
/// Returns the observable instances displayed by launch-selection controls.
|
||||
///
|
||||
/// The list is updated from the selected repository's published snapshot and contains no hidden
|
||||
/// instances. The returned view cannot be mutated.
|
||||
///
|
||||
/// @return the observable launch-menu instances
|
||||
public @UnmodifiableView ObservableList<HMCLGameInstance> getInstances() {
|
||||
return instances;
|
||||
}
|
||||
|
||||
public boolean isShowUpdate() {
|
||||
@@ -466,9 +485,4 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
this.latestVersion.set(latestVersion);
|
||||
}
|
||||
|
||||
public void initVersions(HMCLGameRepository repository, List<GameInstanceManifest> versions) {
|
||||
FXUtils.checkFxUserThread();
|
||||
this.repository = repository;
|
||||
this.versions.setAll(versions);
|
||||
}
|
||||
}
|
||||
@@ -25,19 +25,15 @@ import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.StringBinding;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ColorPicker;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.paint.Paint;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontSmoothingType;
|
||||
import javafx.stage.FileChooser;
|
||||
@@ -45,23 +41,13 @@ import org.glavo.monetfx.Brightness;
|
||||
import org.glavo.monetfx.ColorStyle;
|
||||
import org.glavo.uuid.UUIDs;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.theme.BackgroundLoadPolicy;
|
||||
import org.jackhuang.hmcl.theme.BuiltinBackground;
|
||||
import org.jackhuang.hmcl.theme.NetworkBackgroundImageCachePolicy;
|
||||
import org.jackhuang.hmcl.theme.Theme;
|
||||
import org.jackhuang.hmcl.theme.ThemeColor;
|
||||
import org.jackhuang.hmcl.theme.ThemeColorSource;
|
||||
import org.jackhuang.hmcl.theme.ThemePackExporter;
|
||||
import org.jackhuang.hmcl.theme.ThemePackManifest;
|
||||
import org.jackhuang.hmcl.theme.ThemePackManager;
|
||||
import org.jackhuang.hmcl.theme.ThemeReference;
|
||||
import org.jackhuang.hmcl.theme.*;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
|
||||
@@ -1271,7 +1257,7 @@ public class PersonalizationPage extends StackPane {
|
||||
|
||||
Label lblLogFontDisplay = new Label("[23:33:33] [Client Thread/INFO] [WaterPower]: Loaded mod WaterPower.");
|
||||
lblLogFontDisplay.fontProperty().bind(Bindings.createObjectBinding(
|
||||
() -> Font.font(Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().logFontSizeProperty().get()),
|
||||
() -> Font.font(Objects.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().logFontSizeProperty().get()),
|
||||
settings().logFontFamilyProperty(), settings().logFontSizeProperty()));
|
||||
|
||||
logFontPane.getChildren().add(lblLogFontDisplay);
|
||||
|
||||
@@ -20,18 +20,11 @@ package org.jackhuang.hmcl.ui.main;
|
||||
import com.jfoenix.controls.JFXPopup;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.scene.layout.Region;
|
||||
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.GameInstanceManifest;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.setting.GameDirectory;
|
||||
import org.jackhuang.hmcl.setting.GameDirectoryManager;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
@@ -45,29 +38,21 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
||||
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
|
||||
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
|
||||
import org.jackhuang.hmcl.ui.instances.GameAdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
|
||||
import org.jackhuang.hmcl.ui.nbt.NBTFileType;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@@ -75,13 +60,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
private MainPage mainPage = null;
|
||||
|
||||
public RootPage() {
|
||||
EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class)
|
||||
.register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource()));
|
||||
|
||||
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
|
||||
if (repository.isLoaded())
|
||||
onRefreshedVersions(GameDirectoryManager.getSelectedRepository());
|
||||
|
||||
getStyleClass().remove("gray-background");
|
||||
getLeft().getStyleClass().add("gray-background");
|
||||
}
|
||||
@@ -122,20 +100,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
|
||||
FXUtils.onChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), mainPage::setCurrentGame);
|
||||
mainPage.latestVersionProperty().bind(UpdateChecker.latestVersionProperty());
|
||||
|
||||
GameDirectoryManager.registerVersionsListener(repository -> {
|
||||
GameDirectory gameDirectory = repository.getGameDirectory();
|
||||
List<GameInstanceManifest> children = repository.getInstanceManifests().parallelStream()
|
||||
.filter(version -> !version.isHidden())
|
||||
.sorted(Comparator
|
||||
.comparing((GameInstanceManifest manifest) -> Lang.requireNonNullElse(manifest.releaseTime(), Instant.EPOCH))
|
||||
.thenComparing(manifest -> VersionNumber.asVersion(repository.getGameVersion(manifest).orElse(manifest.id().toString()))))
|
||||
.collect(Collectors.toList());
|
||||
runInFX(() -> {
|
||||
if (gameDirectory == GameDirectoryManager.getSelectedGameDirectory())
|
||||
mainPage.initVersions(repository, children);
|
||||
});
|
||||
});
|
||||
this.mainPage = mainPage;
|
||||
}
|
||||
return mainPage;
|
||||
@@ -155,17 +119,18 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
// second item in left sidebar
|
||||
GameAdvancedListItem gameListItem = new GameAdvancedListItem();
|
||||
gameListItem.setOnAction(e -> {
|
||||
GameInstanceID instanceId = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
|
||||
if (instanceId == null) {
|
||||
@Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
|
||||
if (instance == null) {
|
||||
Controllers.navigate(Controllers.getGameListPage());
|
||||
} else {
|
||||
Instances.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), instanceId);
|
||||
Instances.modifyGameSettings(instance);
|
||||
}
|
||||
});
|
||||
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
|
||||
GameInstanceID currentId = getSkinnable().getMainPage().getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
|
||||
}, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.id()));
|
||||
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getInstances(), list -> {
|
||||
@Nullable HMCLGameInstance currentGame = getSkinnable().getMainPage().getCurrentGame();
|
||||
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, instance -> instance.getRepository().setSelectedInstance(instance));
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage);
|
||||
}
|
||||
@@ -182,10 +147,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
AdvancedListItem downloadItem = new AdvancedListItem();
|
||||
downloadItem.setLeftIcon(SVG.DOWNLOAD);
|
||||
downloadItem.setTitle(i18n("download"));
|
||||
downloadItem.setOnAction(e -> {
|
||||
Controllers.getDownloadPage().showGameDownloads();
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
});
|
||||
downloadItem.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
|
||||
if (AnimationUtils.isAnimationEnabled()) {
|
||||
FXUtils.prepareOnMouseEnter(downloadItem, Controllers::prepareDownloadPage);
|
||||
}
|
||||
@@ -252,44 +214,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
JFXPopup.PopupHPosition.LEFT,
|
||||
gameListItem.getWidth(),
|
||||
0,
|
||||
getSkinnable().getMainPage().getRepository(),
|
||||
getSkinnable().getMainPage().getVersions());
|
||||
getSkinnable().getMainPage().getInstances());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkedModpack = false;
|
||||
|
||||
private void onRefreshedVersions(HMCLGameRepository repository) {
|
||||
runInFX(() -> {
|
||||
if (!checkedModpack) {
|
||||
checkedModpack = true;
|
||||
|
||||
if (repository.getInstanceCount() == 0) {
|
||||
Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip");
|
||||
Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack");
|
||||
|
||||
Path modpackFile;
|
||||
if (Files.exists(zipModpack)) {
|
||||
modpackFile = zipModpack;
|
||||
} else if (Files.exists(mrpackModpack)) {
|
||||
modpackFile = mrpackModpack;
|
||||
} else {
|
||||
modpackFile = null;
|
||||
}
|
||||
|
||||
if (modpackFile != null) {
|
||||
Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile))
|
||||
.thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding))
|
||||
.thenApplyAsync(modpack -> ModpackHelper
|
||||
.getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null)
|
||||
.executor())
|
||||
.thenAcceptAsync(Schedulers.javafx(), executor -> {
|
||||
Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL);
|
||||
executor.start();
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -632,7 +632,7 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
|
||||
private final ThemePackManagementPage page;
|
||||
|
||||
/// Root graphic reused by this cell.
|
||||
private final Region graphic;
|
||||
private final RipplerContainer graphic;
|
||||
|
||||
/// The text content shown for the current theme pack.
|
||||
private final TwoLineListItem content = new TwoLineListItem();
|
||||
@@ -714,19 +714,21 @@ public final class ThemePackManagementPage extends ListPageBase<ThemePackManager
|
||||
@Override
|
||||
protected void updateItem(ThemePackManager.@Nullable InstalledThemePack themePack, boolean empty) {
|
||||
var currentItem = getItem();
|
||||
boolean oldEmpty = isEmpty();
|
||||
|
||||
super.updateItem(themePack, empty);
|
||||
|
||||
if (Objects.equals(getItem(), currentItem)) return;
|
||||
if (Objects.equals(getItem(), currentItem) && oldEmpty == empty) return;
|
||||
|
||||
this.graphic.releaseRippleImmediately();
|
||||
content.getTags().clear();
|
||||
iconImage.setImage(null);
|
||||
iconFallback.setVisible(false);
|
||||
|
||||
if (empty || themePack == null) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setGraphic(graphic);
|
||||
|
||||
ThemePackManifest manifest = themePack.manifest();
|
||||
|
||||
@@ -221,7 +221,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
).addAction(i18n("instance.launch"), () -> {
|
||||
var repository = GameDirectoryManager.getSelectedRepository();
|
||||
Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> {
|
||||
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
|
||||
launcherHelper.setKeep();
|
||||
launcherHelper.setDisableOfflineSkin();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.game.GameInstanceID;
|
||||
import org.jackhuang.hmcl.game.HMCLGameInstance;
|
||||
import org.jackhuang.hmcl.setting.*;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
@@ -41,6 +42,7 @@ import org.jackhuang.hmcl.ui.main.MainPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.SettingsManager.userState;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -54,7 +56,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ChangeListener<GameInstanceID> instanceChangeListenerHolder;
|
||||
private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder;
|
||||
|
||||
public TerracottaPage() {
|
||||
statusPage.setNodeSupplier(TerracottaControllerPage::new);
|
||||
@@ -79,27 +81,28 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
.add(accountListItem)
|
||||
.addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> {
|
||||
var repository = GameDirectoryManager.getSelectedRepository();
|
||||
Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> {
|
||||
Instances.launch(repository.getSelectedInstance(), launcherHelper -> {
|
||||
launcherHelper.setKeep();
|
||||
launcherHelper.setDisableOfflineSkin();
|
||||
});
|
||||
}, item -> {
|
||||
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(),
|
||||
instanceName -> item.setSubtitle(instanceName != null ? instanceName.toString() : i18n("instance.empty"))
|
||||
instance -> item.setSubtitle(instance != null ? instance.getId().toString() : i18n("instance.empty"))
|
||||
);
|
||||
|
||||
MainPage mainPage = Controllers.getRootPage().getMainPage();
|
||||
FXUtils.onScroll(item, mainPage.getVersions(), list -> {
|
||||
GameInstanceID currentId = mainPage.getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.id().equals(currentId));
|
||||
}, it -> mainPage.getRepository().setSelectedInstance(it.id()));
|
||||
FXUtils.onScroll(item, mainPage.getInstances(), list -> {
|
||||
@Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame();
|
||||
@Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null;
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, instance -> instance.getRepository().setSelectedInstance(instance));
|
||||
|
||||
FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item,
|
||||
JFXPopup.PopupVPosition.BOTTOM,
|
||||
JFXPopup.PopupHPosition.LEFT,
|
||||
item.getWidth(),
|
||||
0,
|
||||
mainPage.getRepository(), mainPage.getVersions()));
|
||||
mainPage.getInstances()));
|
||||
})
|
||||
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
|
||||
+12
-1
@@ -68,14 +68,25 @@ public abstract class TaskExecutorDialogWizardDisplayer extends AbstractWizardDi
|
||||
else if (!settings.containsKey("forbid_success_message"))
|
||||
Controllers.dialog(i18n("message.success"), null, MessageType.SUCCESS, () -> onEnd());
|
||||
} else {
|
||||
if (executor.getException() == null)
|
||||
if (executor.getException() == null) {
|
||||
onEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
if (executor.getException() instanceof CancellationException) {
|
||||
onEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
if (executor.getException().getCause() instanceof OutOfMemoryError outOfMemoryError) {
|
||||
try {
|
||||
Controllers.dialog(StringUtils.getStackTrace(outOfMemoryError), null, MessageType.ERROR, () -> onEnd());
|
||||
} catch (OutOfMemoryError ignored) {
|
||||
onEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String appendix = StringUtils.getStackTrace(executor.getException());
|
||||
if (settings.get(WizardProvider.FailureCallback.KEY) != null)
|
||||
settings.get(WizardProvider.FailureCallback.KEY).onFail(settings, executor.getException(), () -> onEnd());
|
||||
|
||||
@@ -159,9 +159,7 @@ public final class UpdateHandler {
|
||||
} else {
|
||||
Exception e = executor.getException();
|
||||
LOG.warning("Failed to update to " + version, e);
|
||||
if (e instanceof CancellationException) {
|
||||
Platform.runLater(() -> Controllers.showToast(i18n("message.cancelled")));
|
||||
} else {
|
||||
if (!(e instanceof CancellationException)) {
|
||||
Platform.runLater(() -> Controllers.dialog(e.toString(), i18n("update.failed"), MessageType.ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.addon.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.addon.mod.ModManager;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.setting.GameSettings;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
@@ -73,13 +73,14 @@ public final class NativePatcher {
|
||||
);
|
||||
}
|
||||
|
||||
public static GameInstanceManifest patchNative(DefaultGameRepository repository,
|
||||
GameInstanceManifest manifest, String gameVersion,
|
||||
public static GameInstanceManifest patchNative(DefaultGameInstance instance,
|
||||
GameInstanceManifest manifest,
|
||||
JavaRuntime javaVersion,
|
||||
GameSettings.Effective settings,
|
||||
List<String> javaArguments) {
|
||||
GameVersionNumber gameVersion = instance.getVersion();
|
||||
if (settings.getInheritable(GameSettings::useCustomNativesProperty)) {
|
||||
if (gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") < 0)
|
||||
if (gameVersion.compareTo("1.19") < 0)
|
||||
return manifest;
|
||||
|
||||
ArrayList<Library> newLibraries = new ArrayList<>();
|
||||
@@ -96,17 +97,17 @@ public final class NativePatcher {
|
||||
return manifest.withLibraries(newLibraries);
|
||||
}
|
||||
|
||||
final boolean useNativeGLFW = settings.getInheritable(GameSettings::useNativeGLFWProperty);
|
||||
final boolean useNativeGLFWorSDL = settings.getInheritable(GameSettings::useNativeGLFWorSDLProperty);
|
||||
final boolean useNativeOpenAL = settings.getInheritable(GameSettings::useNativeOpenALProperty);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && (useNativeGLFW || useNativeOpenAL)
|
||||
&& gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") >= 0) {
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()
|
||||
&& (useNativeGLFWorSDL || useNativeOpenAL) && gameVersion.compareTo("1.19") >= 0) {
|
||||
|
||||
manifest = manifest.withLibraries(manifest.getLibraries().stream()
|
||||
.filter(library -> {
|
||||
if (library.classifier() != null && library.classifier().startsWith("natives")
|
||||
&& "org.lwjgl".equals(library.groupId())) {
|
||||
if ((useNativeGLFW && "lwjgl-glfw".equals(library.artifactId()))
|
||||
if ((useNativeGLFWorSDL && ("lwjgl-glfw".equals(library.artifactId()) || library.artifactId().contains("sdl")))
|
||||
|| (useNativeOpenAL && "lwjgl-openal".equals(library.artifactId()))) {
|
||||
LOG.info("Filter out " + library.name());
|
||||
return false;
|
||||
@@ -122,7 +123,6 @@ public final class NativePatcher {
|
||||
|
||||
OperatingSystem os = javaVersion.getPlatform().getOperatingSystem();
|
||||
Architecture arch = javaVersion.getArchitecture();
|
||||
GameVersionNumber gameVersionNumber = gameVersion != null ? GameVersionNumber.asGameVersion(gameVersion) : null;
|
||||
|
||||
if (settings.getInheritable(GameSettings::notPatchNativesProperty))
|
||||
return manifest;
|
||||
@@ -131,8 +131,7 @@ public final class NativePatcher {
|
||||
return manifest;
|
||||
|
||||
if (arch == Architecture.ARM64 && (os == OperatingSystem.MACOS || os == OperatingSystem.WINDOWS)
|
||||
&& gameVersionNumber != null
|
||||
&& gameVersionNumber.compareTo("1.19") >= 0)
|
||||
&& gameVersion.compareTo("1.19") >= 0)
|
||||
return manifest;
|
||||
|
||||
Map<String, Library> replacements = getNatives(javaVersion.getPlatform());
|
||||
@@ -172,7 +171,7 @@ public final class NativePatcher {
|
||||
}
|
||||
|
||||
if (lwjglVersionChanged) {
|
||||
ModManager modManager = repository.getModManager(manifest.id());
|
||||
ModManager modManager = instance.getModManager();
|
||||
try {
|
||||
for (LocalModFile mod : modManager.getLocalFiles()) {
|
||||
if ("sodium".equals(mod.getId())) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.translator.Translator;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
@@ -77,7 +77,7 @@ public final class I18n {
|
||||
return getTranslator().formatSpeed(bytes);
|
||||
}
|
||||
|
||||
public static String getDisplayVersion(RemoteVersion version) {
|
||||
public static String getDisplayVersion(ComponentRemoteVersion version) {
|
||||
return getTranslator().getDisplayVersion(version);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
@@ -44,7 +44,7 @@ public class Translator {
|
||||
return displayLocale;
|
||||
}
|
||||
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
public String getDisplayVersion(ComponentRemoteVersion remoteVersion) {
|
||||
return remoteVersion.getSelfVersion();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -73,7 +73,7 @@ public class Translator_en_Qabs extends Translator {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
public String getDisplayVersion(ComponentRemoteVersion remoteVersion) {
|
||||
return translate(remoteVersion.getSelfVersion());
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user