Compare commits
56
Commits
v3.7.0.298
..
v3.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341c60b0de | ||
|
|
69f19115ea | ||
|
|
ecb4eedd12 | ||
|
|
df6f35bc8e | ||
|
|
58d3780553 | ||
|
|
58b357d4b8 | ||
|
|
d69d27af57 | ||
|
|
f9a39f1c90 | ||
|
|
f8a4261480 | ||
|
|
2c428faa95 | ||
|
|
da91fe15df | ||
|
|
1df962884c | ||
|
|
8ad5d83946 | ||
|
|
74878bda0c | ||
|
|
b32d3304b0 | ||
|
|
60375c9632 | ||
|
|
33877691f8 | ||
|
|
109d6874ab | ||
|
|
a225e7fac4 | ||
|
|
7dad43570e | ||
|
|
8a166266d8 | ||
|
|
c53fbd0efb | ||
|
|
1e74567a22 | ||
|
|
0b9616f460 | ||
|
|
0b89612ec5 | ||
|
|
642893d2e9 | ||
|
|
2e878e43cb | ||
|
|
c8edf4be62 | ||
|
|
96f16382c0 | ||
|
|
11c45e0ab8 | ||
|
|
08f6f973c0 | ||
|
|
35e63fc8c8 | ||
|
|
23037b8afc | ||
|
|
25323dc385 | ||
|
|
496b403eeb | ||
|
|
7be00f2408 | ||
|
|
08d7974e27 | ||
|
|
f16cbed5b4 | ||
|
|
0988dc53cd | ||
|
|
f1c8791961 | ||
|
|
07e5ac9e0e | ||
|
|
c98dbe6694 | ||
|
|
460134cc1e | ||
|
|
9dd117c454 | ||
|
|
c60af8675f | ||
|
|
411efbe774 | ||
|
|
2e09a40b69 | ||
|
|
ba47c2d10d | ||
|
|
fd90ca8afe | ||
|
|
dda30274a4 | ||
|
|
2b43ee24a1 | ||
|
|
66f1e60433 | ||
|
|
3c3e2b8cfc | ||
|
|
365bb089c5 | ||
|
|
87af44328b | ||
|
|
8731faaac7 |
No files matched your search
@@ -22,4 +22,4 @@ jobs:
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||
@@ -2,8 +2,8 @@ name: Check Update
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 * * * *'
|
||||
# schedule:
|
||||
# - cron: '30 * * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -28,14 +28,12 @@ jobs:
|
||||
- name: Download artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
env:
|
||||
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -84,14 +82,12 @@ jobs:
|
||||
- name: Download artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
env:
|
||||
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -103,9 +99,9 @@ jobs:
|
||||
echo "" >> RELEASE_NOTE
|
||||
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
|
||||
echo "| --- | --- |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
env:
|
||||
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
|
||||
- name: Create release
|
||||
@@ -120,3 +116,4 @@ jobs:
|
||||
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
|
||||
name: ${{ env.HMCL_TAG_NAME }}
|
||||
tag_name: ${{ env.HMCL_TAG_NAME }}
|
||||
prerelease: true
|
||||
@@ -16,6 +16,8 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
|
||||
# gradle build
|
||||
/build/
|
||||
|
||||
+17
-16
@@ -121,8 +121,25 @@ tasks.compileJava {
|
||||
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
val addOpens = listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
|
||||
val hmclProperties = buildList {
|
||||
add("hmcl.version" to project.version.toString())
|
||||
add("hmcl.add-opens" to addOpens.joinToString(" "))
|
||||
System.getenv("GITHUB_SHA")?.let {
|
||||
add("hmcl.version.hash" to it)
|
||||
}
|
||||
@@ -149,22 +166,6 @@ val createPropertiesFile by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val addOpens = listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
|
||||
tasks.jar {
|
||||
enabled = false
|
||||
dependsOn(tasks["shadowJar"])
|
||||
|
||||
@@ -191,7 +191,7 @@ public final class LauncherHelper {
|
||||
LaunchOptions launchOptions = repository.getLaunchOptions(
|
||||
selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null);
|
||||
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModManager(selectedVersion).getModsDirectory(), 10));
|
||||
LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10));
|
||||
|
||||
return new HMCLGameLauncher(
|
||||
repository,
|
||||
@@ -915,7 +915,15 @@ public final class LauncherHelper {
|
||||
|
||||
}
|
||||
|
||||
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
|
||||
public static int countMangedProcesses() {
|
||||
PROCESSES.removeIf(it -> {
|
||||
ManagedProcess process = it.get();
|
||||
return process == null || !process.isRunning();
|
||||
});
|
||||
return PROCESSES.size();
|
||||
}
|
||||
|
||||
public static void stopManagedProcesses() {
|
||||
while (!PROCESSES.isEmpty())
|
||||
|
||||
@@ -250,6 +250,21 @@ public final class Config implements Observable {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
public boolean isAcceptPreviewUpdate() {
|
||||
return acceptPreviewUpdate.get();
|
||||
}
|
||||
|
||||
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
|
||||
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
|
||||
}
|
||||
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ public final class GlobalConfig implements Observable {
|
||||
|
||||
private final IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
private final IntegerProperty logRetention = new SimpleIntegerProperty();
|
||||
@@ -94,6 +96,18 @@ public final class GlobalConfig implements Observable {
|
||||
this.agreementVersion.set(agreementVersion);
|
||||
}
|
||||
|
||||
public int getTerracottaAgreementVersion() {
|
||||
return terracottaAgreementVersion.get();
|
||||
}
|
||||
|
||||
public IntegerProperty terracottaAgreementVersionProperty() {
|
||||
return terracottaAgreementVersion;
|
||||
}
|
||||
|
||||
public void setTerracottaAgreementVersion(int terracottaAgreementVersion) {
|
||||
this.terracottaAgreementVersion.set(terracottaAgreementVersion);
|
||||
}
|
||||
|
||||
public int getPlatformPromptVersion() {
|
||||
return platformPromptVersion.get();
|
||||
}
|
||||
@@ -153,6 +167,7 @@ public final class GlobalConfig implements Observable {
|
||||
public static final class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
|
||||
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
|
||||
"agreementVersion",
|
||||
"terracottaAgreementVersion",
|
||||
"platformPromptVersion",
|
||||
"logRetention",
|
||||
"userJava",
|
||||
@@ -169,6 +184,7 @@ public final class GlobalConfig implements Observable {
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
|
||||
jsonObject.add("terracottaAgreementVersion", context.serialize(src.getTerracottaAgreementVersion()));
|
||||
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
|
||||
jsonObject.add("logRetention", context.serialize(src.getLogRetention()));
|
||||
jsonObject.add("fontAntiAliasing", context.serialize(src.getFontAntiAliasing()));
|
||||
@@ -196,6 +212,7 @@ public final class GlobalConfig implements Observable {
|
||||
|
||||
GlobalConfig config = new GlobalConfig();
|
||||
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setTerracottaAgreementVersion(Optional.ofNullable(obj.get("terracottaAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setLogRetention(Optional.ofNullable(obj.get("logRetention")).map(JsonElement::getAsInt).orElse(20));
|
||||
config.setEnableOfflineAccount(Optional.ofNullable(obj.get("enableOfflineAccount")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.setting;
|
||||
|
||||
import javafx.scene.image.Image;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
public enum VersionIconType {
|
||||
@@ -39,6 +40,18 @@ public enum VersionIconType {
|
||||
|
||||
// Please append new items at last
|
||||
|
||||
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
|
||||
return switch (modLoaderType) {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGED -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
case CLEANROOM -> VersionIconType.CLEANROOM;
|
||||
default -> VersionIconType.COMMAND;
|
||||
};
|
||||
}
|
||||
|
||||
private final String resourceUrl;
|
||||
|
||||
VersionIconType(String resourceUrl) {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.InvocationDispatcher;
|
||||
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.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class TerracottaManager {
|
||||
private TerracottaManager() {
|
||||
}
|
||||
|
||||
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
|
||||
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
|
||||
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
|
||||
|
||||
static {
|
||||
Task.runAsync(() -> {
|
||||
if (TerracottaMetadata.PROVIDER == null) {
|
||||
setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.OS));
|
||||
LOG.warning("Terracotta hasn't support your OS: " + org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM);
|
||||
} else {
|
||||
switch (TerracottaMetadata.PROVIDER.status()) {
|
||||
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
|
||||
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
|
||||
case READY -> launch(setState(new TerracottaState.Launching()));
|
||||
}
|
||||
}
|
||||
}).whenComplete(exception -> {
|
||||
if (exception != null) {
|
||||
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
|
||||
return STATE.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
static {
|
||||
Lang.thread(() -> {
|
||||
while (true) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
|
||||
LockSupport.parkNanos(500_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
int port = portSpecific.port;
|
||||
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
|
||||
|
||||
TerracottaState next;
|
||||
try {
|
||||
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(jsonString -> {
|
||||
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
|
||||
if (object.index <= index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
object.port = port;
|
||||
return object;
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.run();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Cannot fetch state from Terracotta.", e);
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
|
||||
if (next != null) {
|
||||
compareAndSet(state, next);
|
||||
}
|
||||
|
||||
LockSupport.parkNanos(500_000);
|
||||
}
|
||||
}, "Terracotta Background Daemon", true);
|
||||
}
|
||||
|
||||
public static boolean validate(Path file) {
|
||||
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
|
||||
}
|
||||
|
||||
public static TerracottaState.Preparing install(@Nullable Path file) {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState.Preparing preparing;
|
||||
if (state instanceof TerracottaState.Preparing it) {
|
||||
preparing = it;
|
||||
} else {
|
||||
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||
}
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
return file != null ? TarFileTree.open(file) : null;
|
||||
}).thenComposeAsync(Schedulers.javafx(), tree -> {
|
||||
return getProvider().install(preparing, tree).whenComplete(exception -> {
|
||||
if (tree != null) {
|
||||
tree.close();
|
||||
}
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
});
|
||||
}).whenComplete(exception -> {
|
||||
if (exception == null) {
|
||||
try {
|
||||
TerracottaMetadata.removeLegacyVersionFiles();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to remove legacy terracotta files.", e);
|
||||
}
|
||||
|
||||
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching);
|
||||
}
|
||||
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
|
||||
LOG.warning("Cannot install terracotta from local package.", exception);
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
} else if (exception instanceof DownloadException) {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
|
||||
} else {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
}
|
||||
}).start();
|
||||
|
||||
return setState(preparing);
|
||||
}
|
||||
|
||||
private static ITerracottaProvider getProvider() {
|
||||
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
|
||||
if (provider == null) {
|
||||
throw new AssertionError("Terracotta Provider must NOT be null.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
public static TerracottaState recover(@Nullable Path file) {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (getProvider().status()) {
|
||||
case NOT_EXIST, LEGACY_VERSION -> install(file);
|
||||
case READY -> {
|
||||
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
|
||||
launch(launching);
|
||||
yield launching;
|
||||
}
|
||||
};
|
||||
} catch (NullPointerException | IOException e) {
|
||||
LOG.warning("Cannot determine Terracotta state.", e);
|
||||
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}
|
||||
|
||||
private static void launch(TerracottaState.Launching state) {
|
||||
Task.supplyAsync(() -> {
|
||||
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
long exitTime = -1;
|
||||
while (true) {
|
||||
if (Files.exists(path)) {
|
||||
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
|
||||
return object.get("port").getAsInt();
|
||||
}
|
||||
|
||||
if (!process.isRunning()) {
|
||||
if (exitTime == -1) {
|
||||
exitTime = System.currentTimeMillis();
|
||||
} else if (System.currentTimeMillis() - exitTime >= 10000) {
|
||||
throw new IllegalStateException("Process has exited for 10s.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
|
||||
TerracottaState next;
|
||||
if (exception == null) {
|
||||
next = new TerracottaState.Unknown(port);
|
||||
} else {
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
compareAndSet(state, next);
|
||||
}).start();
|
||||
}
|
||||
|
||||
public static Task<String> exportLogs() {
|
||||
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
return Task.completed(null);
|
||||
}
|
||||
|
||||
public static TerracottaState.Waiting setWaiting() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
return new TerracottaState.Waiting(-1, -1, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getPlayerName() {
|
||||
Account account = Accounts.getSelectedAccount();
|
||||
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
|
||||
}
|
||||
|
||||
public static TerracottaState.HostScanning setScanning() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
|
||||
).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||
|
||||
return new TerracottaState.HostScanning(-1, -1, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
|
||||
)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends TerracottaState> T setState(T value) {
|
||||
if (value == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
STATE_V.set(value);
|
||||
STATE_D.accept(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
|
||||
if (next == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (STATE_V.compareAndSet(previous, next)) {
|
||||
STATE_D.accept(next);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class TerracottaMetadata {
|
||||
private TerracottaMetadata() {
|
||||
}
|
||||
|
||||
public record Link(@SerializedName("desc") LocalizedText description, String link) {
|
||||
}
|
||||
|
||||
private record Config(
|
||||
@SerializedName("version_legacy") String legacy,
|
||||
@SerializedName("version_recent") List<String> recent,
|
||||
@SerializedName("version_latest") String latest,
|
||||
|
||||
@SerializedName("classifiers") Map<String, String> classifiers,
|
||||
@SerializedName("downloads") List<String> downloads,
|
||||
@SerializedName("downloads_CN") List<String> downloadsCN,
|
||||
@SerializedName("links") List<Link> links
|
||||
) {
|
||||
private TerracottaNative of(String classifier) {
|
||||
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
|
||||
for (String download : LocaleUtils.IS_CHINA_MAINLAND
|
||||
? Lang.merge(this.downloadsCN, this.downloads)
|
||||
: Lang.merge(this.downloads, this.downloadsCN)) {
|
||||
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
|
||||
}
|
||||
|
||||
String hash = Objects.requireNonNull(this.classifiers.get(classifier), String.format("Classifier %s doesn't exist.", classifier));
|
||||
if (!hash.startsWith("sha256:")) {
|
||||
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
|
||||
}
|
||||
hash = hash.substring("sha256:".length());
|
||||
|
||||
return new TerracottaNative(
|
||||
Collections.unmodifiableList(links),
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve(
|
||||
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
|
||||
).toAbsolutePath(),
|
||||
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static final ITerracottaProvider PROVIDER;
|
||||
public static final String PACKAGE_NAME;
|
||||
public static final List<Link> PACKAGE_LINKS;
|
||||
public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of(
|
||||
"v", "v1",
|
||||
"launcher_version", Metadata.VERSION
|
||||
));
|
||||
|
||||
private static final Pattern LEGACY;
|
||||
private static final List<String> RECENT;
|
||||
private static final String LATEST;
|
||||
|
||||
static {
|
||||
Config config;
|
||||
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
|
||||
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
|
||||
} catch (IOException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
|
||||
LEGACY = Pattern.compile(config.legacy);
|
||||
RECENT = config.recent;
|
||||
LATEST = config.latest;
|
||||
|
||||
ProviderContext context = locateProvider(config);
|
||||
PROVIDER = context != null ? context.provider() : null;
|
||||
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
|
||||
|
||||
if (context != null) {
|
||||
List<Link> packageLinks = new ArrayList<>(config.links.size());
|
||||
for (Link link : config.links) {
|
||||
packageLinks.add(new Link(
|
||||
link.description,
|
||||
link.link.replace("${version}", LATEST)
|
||||
.replace("${classifier}", context.branch)
|
||||
));
|
||||
}
|
||||
|
||||
Collections.shuffle(packageLinks);
|
||||
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
|
||||
} else {
|
||||
PACKAGE_LINKS = null;
|
||||
}
|
||||
}
|
||||
|
||||
private record ProviderContext(ITerracottaProvider provider, String branch) {
|
||||
ProviderContext(ITerracottaProvider provider, String system, String arch) {
|
||||
this(provider, system + "-" + arch);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ProviderContext locateProvider(Config config) {
|
||||
String architecture = switch (Architecture.SYSTEM_ARCH) {
|
||||
case X86_64 -> "x86_64";
|
||||
case ARM64 -> "arm64";
|
||||
default -> null;
|
||||
};
|
||||
if (architecture == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_8_1)) {
|
||||
yield new ProviderContext(
|
||||
new GeneralProvider(config.of(String.format("windows-%s.exe", architecture))),
|
||||
"windows", architecture
|
||||
);
|
||||
}
|
||||
yield null;
|
||||
}
|
||||
case LINUX -> new ProviderContext(
|
||||
new GeneralProvider(config.of(String.format("linux-%s", architecture))),
|
||||
"linux", architecture
|
||||
);
|
||||
case MACOS -> new ProviderContext(
|
||||
new MacOSProvider(
|
||||
config.of(String.format("macos-%s.pkg", architecture)),
|
||||
config.of(String.format("macos-%s", architecture))
|
||||
),
|
||||
"macos", architecture
|
||||
);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static void removeLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
FileUtils.deleteDirectory(path);
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public final class TerracottaNative {
|
||||
private final List<URI> links;
|
||||
private final FileDownloadTask.IntegrityCheck checking;
|
||||
private final Path path;
|
||||
|
||||
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
|
||||
this.links = links;
|
||||
this.path = path;
|
||||
this.checking = checking;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
|
||||
if (tree == null) {
|
||||
return new FileDownloadTask(links, path, checking) {
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void withResult(boolean success) {
|
||||
delegate.withResult(success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
if (!context.hasInstallFence()) {
|
||||
throw new CancellationException("User has installed terracotta from local archives.");
|
||||
}
|
||||
delegate.write(buffer, offset, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (isSuccess() && !context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
delegate.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return Task.runAsync(() -> {
|
||||
String name = FileUtils.getName(path);
|
||||
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
|
||||
if (entry == null) {
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
|
||||
}
|
||||
|
||||
if (!context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
|
||||
try (
|
||||
InputStream stream = tree.getInputStream(entry);
|
||||
OutputStream os = Files.newOutputStream(path)
|
||||
) {
|
||||
stream.transferTo(new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
os.write(b);
|
||||
digest.update((byte) b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
|
||||
os.write(buffer, offset, len);
|
||||
digest.update(buffer, offset, len);
|
||||
}
|
||||
});
|
||||
}
|
||||
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
|
||||
Files.delete(path);
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ITerracottaProvider.Status status() throws IOException {
|
||||
if (Files.exists(path)) {
|
||||
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
|
||||
return ITerracottaProvider.Status.READY;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (TerracottaMetadata.hasLegacyVersionFiles()) {
|
||||
return ITerracottaProvider.Status.LEGACY_VERSION;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
|
||||
}
|
||||
return ITerracottaProvider.Status.NOT_EXIST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSubtype;
|
||||
import org.jackhuang.hmcl.util.gson.JsonType;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public abstract sealed class TerracottaState {
|
||||
protected TerracottaState() {
|
||||
}
|
||||
|
||||
public boolean isUIFakeState() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final class Bootstrap extends TerracottaState {
|
||||
static final Bootstrap INSTANCE = new Bootstrap();
|
||||
|
||||
private Bootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Uninitialized extends TerracottaState {
|
||||
private final boolean hasLegacy;
|
||||
|
||||
Uninitialized(boolean hasLegacy) {
|
||||
this.hasLegacy = hasLegacy;
|
||||
}
|
||||
|
||||
public boolean hasLegacy() {
|
||||
return hasLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
|
||||
private final ReadOnlyDoubleWrapper progress;
|
||||
|
||||
private final AtomicBoolean installFence = new AtomicBoolean(false);
|
||||
|
||||
Preparing(ReadOnlyDoubleWrapper progress) {
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public ReadOnlyDoubleProperty progressProperty() {
|
||||
return progress.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bindProgress(ObservableValue<? extends Number> value) {
|
||||
progress.bind(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestInstallFence() {
|
||||
return installFence.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasInstallFence() {
|
||||
return !installFence.get();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Launching extends TerracottaState {
|
||||
Launching() {
|
||||
}
|
||||
}
|
||||
|
||||
static abstract sealed class PortSpecific extends TerracottaState {
|
||||
transient int port;
|
||||
|
||||
protected PortSpecific(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonType(
|
||||
property = "state",
|
||||
subtypes = {
|
||||
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
|
||||
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
|
||||
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
|
||||
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
|
||||
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
|
||||
@JsonSubtype(clazz = Exception.class, name = "exception"),
|
||||
}
|
||||
)
|
||||
static abstract sealed class Ready extends PortSpecific {
|
||||
@SerializedName("index")
|
||||
final int index;
|
||||
|
||||
@SerializedName("state")
|
||||
private final String state;
|
||||
|
||||
Ready(int port, int index, String state) {
|
||||
super(port);
|
||||
this.index = index;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUIFakeState() {
|
||||
return this.index == -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Unknown extends PortSpecific {
|
||||
Unknown(int port) {
|
||||
super(port);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Waiting extends Ready {
|
||||
Waiting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostScanning extends Ready {
|
||||
HostScanning(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostStarting extends Ready {
|
||||
HostStarting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostOK extends Ready implements Validation {
|
||||
@SerializedName("room")
|
||||
private final String code;
|
||||
|
||||
@SerializedName("profile_index")
|
||||
private final int profileIndex;
|
||||
|
||||
@SerializedName("profiles")
|
||||
private final List<TerracottaProfile> profiles;
|
||||
|
||||
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
|
||||
super(port, index, state);
|
||||
this.code = code;
|
||||
this.profileIndex = profileIndex;
|
||||
this.profiles = profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (code == null) {
|
||||
throw new JsonParseException("code is null");
|
||||
}
|
||||
if (profiles == null) {
|
||||
throw new JsonParseException("profiles is null");
|
||||
}
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public List<TerracottaProfile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestStarting extends Ready {
|
||||
GuestStarting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestOK extends Ready implements Validation {
|
||||
@SerializedName("url")
|
||||
private final String url;
|
||||
|
||||
@SerializedName("profile_index")
|
||||
private final int profileIndex;
|
||||
|
||||
@SerializedName("profiles")
|
||||
private final List<TerracottaProfile> profiles;
|
||||
|
||||
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
|
||||
super(port, index, state);
|
||||
this.url = url;
|
||||
this.profileIndex = profileIndex;
|
||||
this.profiles = profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (profiles == null) {
|
||||
throw new JsonParseException("profiles is null");
|
||||
}
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public List<TerracottaProfile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Exception extends Ready implements Validation {
|
||||
public enum Type {
|
||||
PING_HOST_FAIL,
|
||||
PING_HOST_RST,
|
||||
GUEST_ET_CRASH,
|
||||
HOST_ET_CRASH,
|
||||
PING_SERVER_RST,
|
||||
SCAFFOLDING_INVALID_RESPONSE
|
||||
}
|
||||
|
||||
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
|
||||
|
||||
@SerializedName("type")
|
||||
private final int type;
|
||||
|
||||
Exception(int port, int index, String state, int type) {
|
||||
super(port, index, state);
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (type < 0 || type >= LOOKUP.length) {
|
||||
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
|
||||
}
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return LOOKUP[type];
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Fatal extends TerracottaState {
|
||||
public enum Type {
|
||||
OS,
|
||||
NETWORK,
|
||||
INSTALL,
|
||||
TERRACOTTA,
|
||||
UNKNOWN;
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
|
||||
public Fatal(Type type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isRecoverable() {
|
||||
return this.type != Type.OS && this.type != Type.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.profile;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public enum ProfileKind {
|
||||
@SerializedName("HOST")
|
||||
HOST,
|
||||
@SerializedName("LOCAL")
|
||||
LOCAL,
|
||||
@SerializedName("GUEST")
|
||||
GUEST
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.profile;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public final class TerracottaProfile {
|
||||
@SerializedName("machine_id")
|
||||
private final String machineID;
|
||||
|
||||
@SerializedName("name")
|
||||
private final String name;
|
||||
|
||||
@SerializedName("vendor")
|
||||
private final String vendor;
|
||||
|
||||
@SerializedName("kind")
|
||||
private final ProfileKind type;
|
||||
|
||||
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
|
||||
this.machineID = machineID;
|
||||
this.name = name;
|
||||
this.vendor = vendor;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getMachineID() {
|
||||
return machineID;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getVendor() {
|
||||
return vendor;
|
||||
}
|
||||
|
||||
public ProfileKind getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GeneralProvider implements ITerracottaProvider {
|
||||
private final TerracottaNative target;
|
||||
|
||||
public GeneralProvider(TerracottaNative target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
return target.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
Task<?> task = target.install(context, tree);
|
||||
context.bindProgress(task.progressProperty());
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)));
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
return List.of(target.getPath().toString(), "--hmcl", path.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public interface ITerracottaProvider {
|
||||
enum Status {
|
||||
NOT_EXIST,
|
||||
LEGACY_VERSION,
|
||||
READY
|
||||
}
|
||||
|
||||
interface Context {
|
||||
void bindProgress(ObservableValue<? extends Number> value);
|
||||
|
||||
boolean requestInstallFence();
|
||||
|
||||
boolean hasInstallFence();
|
||||
}
|
||||
|
||||
abstract class ProviderException extends IOException {
|
||||
public ProviderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ProviderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ProviderException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
final class ArchiveFileMissingException extends ProviderException {
|
||||
public ArchiveFileMissingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
Status status() throws IOException;
|
||||
|
||||
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
|
||||
|
||||
List<String> ofCommandLine(Path path);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class MacOSProvider implements ITerracottaProvider {
|
||||
public final TerracottaNative installer, binary;
|
||||
|
||||
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
|
||||
this.installer = installer;
|
||||
this.binary = binary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
assert binary != null;
|
||||
|
||||
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
|
||||
return Status.NOT_EXIST;
|
||||
}
|
||||
|
||||
return binary.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
assert installer != null && binary != null;
|
||||
|
||||
Task<?> installerTask = installer.install(context, tree);
|
||||
Task<?> binaryTask = binary.install(context, tree);
|
||||
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
|
||||
|
||||
return Task.allOf(
|
||||
installerTask.thenComposeAsync(() -> {
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||
"osascript",
|
||||
"-e",
|
||||
String.format(
|
||||
"do shell script \"installer -pkg %s -target /\" with prompt \"%s\" with administrator privileges",
|
||||
installer.getPath(),
|
||||
i18n("terracotta.sudo_installing")
|
||||
)
|
||||
));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit());
|
||||
}),
|
||||
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
assert binary != null;
|
||||
|
||||
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import javafx.stage.StageStyle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
@@ -55,12 +56,15 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
|
||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
||||
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
|
||||
import org.jackhuang.hmcl.ui.main.RootPage;
|
||||
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
|
||||
import org.jackhuang.hmcl.ui.versions.GameListPage;
|
||||
import org.jackhuang.hmcl.ui.versions.VersionPage;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -108,6 +112,7 @@ public final class Controllers {
|
||||
return accountListPage;
|
||||
});
|
||||
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
|
||||
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
|
||||
|
||||
private Controllers() {
|
||||
}
|
||||
@@ -150,6 +155,11 @@ public final class Controllers {
|
||||
return downloadPage.get();
|
||||
}
|
||||
|
||||
// FXThread
|
||||
public static Node getTerracottaPage() {
|
||||
return terracottaPage.get();
|
||||
}
|
||||
|
||||
// FXThread
|
||||
public static DecoratorController getDecorator() {
|
||||
return decorator;
|
||||
@@ -424,6 +434,38 @@ public final class Controllers {
|
||||
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
||||
}
|
||||
|
||||
public static void confirmWithCountdown(String text, String title, int seconds, MessageType messageType,
|
||||
@Nullable Runnable ok, @Nullable Runnable cancel) {
|
||||
if (seconds <= 0)
|
||||
throw new IllegalArgumentException("Seconds must be greater than 0");
|
||||
|
||||
JFXButton btnOk = new JFXButton(i18n("button.ok"));
|
||||
btnOk.getStyleClass().add(messageType == MessageType.WARNING || messageType == MessageType.ERROR
|
||||
? "dialog-error"
|
||||
: "dialog-accept");
|
||||
|
||||
if (ok != null)
|
||||
btnOk.setOnAction(e -> ok.run());
|
||||
btnOk.setDisable(true);
|
||||
|
||||
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
|
||||
for (int i = 0; i < seconds; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(btnOk.textProperty(), i18n("button.ok.countdown", seconds - i)));
|
||||
}
|
||||
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
|
||||
new KeyValue(btnOk.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnOk.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
confirmAction(text, title, messageType, btnOk, () -> {
|
||||
timeline.stop();
|
||||
if (cancel != null)
|
||||
cancel.run();
|
||||
});
|
||||
timeline.play();
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
|
||||
return prompt(title, onResult, "");
|
||||
}
|
||||
@@ -470,6 +512,10 @@ public final class Controllers {
|
||||
Controllers.getSettingsPage().showFeedback();
|
||||
Controllers.navigate(Controllers.getSettingsPage());
|
||||
break;
|
||||
case "hmcl://game/launch":
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
FXUtils.openLink(href);
|
||||
|
||||
@@ -36,6 +36,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
@@ -1387,6 +1388,16 @@ public final class FXUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static void clearFocus(Node node) {
|
||||
Scene scene = node.getScene();
|
||||
if (scene != null) {
|
||||
Parent root = scene.getRoot();
|
||||
if (root != null) {
|
||||
root.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyOnDoubleClick(Labeled label) {
|
||||
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
|
||||
@@ -1400,12 +1411,16 @@ public final class FXUtils {
|
||||
}
|
||||
|
||||
public static void copyText(String text) {
|
||||
copyText(text, i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyText(String text, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(text);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (!Controllers.isStopped()) {
|
||||
Controllers.showToast(i18n("message.copied"));
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,12 @@ import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
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.*;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -59,9 +62,9 @@ import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
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 class GameCrashWindow extends Stage {
|
||||
private final Version version;
|
||||
@@ -283,7 +286,7 @@ public class GameCrashWindow extends Stage {
|
||||
alert.showAndWait();
|
||||
|
||||
return null;
|
||||
});
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
|
||||
private final class View extends VBox {
|
||||
|
||||
@@ -100,7 +100,10 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
|
||||
ret.getStyleClass().add("jfx-tool-bar-button");
|
||||
ret.setGraphic(wrap(svg.createIcon(Theme.blackFill(), -1)));
|
||||
ret.setText(text);
|
||||
ret.setOnAction(e -> onClick.run());
|
||||
ret.setOnAction(e -> {
|
||||
onClick.run();
|
||||
FXUtils.clearFocus(ret);
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,22 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
setBody(new ProgressIndicator());
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
|
||||
boolean isPreview = remoteVersion.isPreview();
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
Document document = Jsoup.parse(new URL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("#nowchange");
|
||||
String id = null;
|
||||
Node node = null;
|
||||
if (isPreview) {
|
||||
id = "nowpreview";
|
||||
node = document.selectFirst("#" + id);
|
||||
}
|
||||
if (node == null) {
|
||||
id = "nowchange";
|
||||
node = document.selectFirst("#" + id);
|
||||
}
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName()))
|
||||
throw new IOException("Cannot find #nowchange in document");
|
||||
throw new IOException("Cannot find current changelog in document");
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
@@ -60,7 +71,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
});
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName()) && !"nowchange".equals(node.attr("id"))) {
|
||||
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
|
||||
break;
|
||||
}
|
||||
renderer.appendNode(node);
|
||||
|
||||
@@ -19,7 +19,13 @@ package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
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.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -45,51 +51,23 @@ 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;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
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);
|
||||
|
||||
private static boolean isExemptedRegion() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static {
|
||||
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
|
||||
|
||||
if ("false".equals(property)
|
||||
|| "auto".equals(property) && isExemptedRegion()
|
||||
|| "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND
|
||||
|| globalConfig().isEnableOfflineAccount())
|
||||
RESTRICTED.set(false);
|
||||
else
|
||||
|
||||
@@ -19,9 +19,6 @@ package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
@@ -39,7 +36,6 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import javafx.scene.layout.*;
|
||||
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||
@@ -268,33 +264,12 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
};
|
||||
|
||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> doCreate.run());
|
||||
btnYes.setDisable(true);
|
||||
|
||||
int countdown = 10;
|
||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||
for (int i = 0; i < countdown; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||
}
|
||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnYes.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
Controllers.confirmAction(
|
||||
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
|
||||
Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
btnYes,
|
||||
() -> {
|
||||
timeline.stop();
|
||||
doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
}
|
||||
);
|
||||
timeline.play();
|
||||
});
|
||||
} else {
|
||||
doCreate.run();
|
||||
}
|
||||
|
||||
@@ -73,8 +73,7 @@ final class ComponentListCell extends StackPane {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateLayout() {
|
||||
if (content instanceof ComponentList) {
|
||||
ComponentList list = (ComponentList) content;
|
||||
if (content instanceof ComponentList list) {
|
||||
content.getStyleClass().remove("options-list");
|
||||
content.getStyleClass().add("options-sublist");
|
||||
|
||||
@@ -130,7 +129,10 @@ final class ComponentListCell extends StackPane {
|
||||
groupNode.getChildren().add(headerRippler);
|
||||
|
||||
VBox container = new VBox();
|
||||
container.setPadding(new Insets(8, 16, 10, 16));
|
||||
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
|
||||
if (hasPadding) {
|
||||
container.setPadding(new Insets(8, 16, 10, 16));
|
||||
}
|
||||
FXUtils.setLimitHeight(container, 0);
|
||||
FXUtils.setOverflowHidden(container);
|
||||
container.getChildren().setAll(content);
|
||||
@@ -149,7 +151,8 @@ final class ComponentListCell extends StackPane {
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1);
|
||||
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
|
||||
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
|
||||
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
|
||||
double contentHeight = expanded ? newAnimatedHeight : 0;
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.Node;
|
||||
|
||||
@@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList {
|
||||
|
||||
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
|
||||
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
|
||||
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
|
||||
|
||||
public ComponentSublist() {
|
||||
super();
|
||||
@@ -55,4 +58,16 @@ public class ComponentSublist extends ComponentList {
|
||||
public void setHeaderRight(Node headerRight) {
|
||||
this.headerRight.set(headerRight);
|
||||
}
|
||||
|
||||
public boolean hasComponentPadding() {
|
||||
return componentPadding.get();
|
||||
}
|
||||
|
||||
public BooleanProperty componentPaddingProperty() {
|
||||
return componentPadding;
|
||||
}
|
||||
|
||||
public void setComponentPadding(boolean componentPadding) {
|
||||
this.componentPadding.set(componentPadding);
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,8 @@ import org.jackhuang.hmcl.util.AggregatedObservableList;
|
||||
public class TwoLineListItem extends VBox {
|
||||
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
|
||||
|
||||
public static Label createTagLabel(String tag) {
|
||||
private static Label createTagLabel(String tag) {
|
||||
Label tagLabel = new Label();
|
||||
tagLabel.getStyleClass().add("tag");
|
||||
tagLabel.setText(tag);
|
||||
HBox.setMargin(tagLabel, new Insets(0, 8, 0, 0));
|
||||
return tagLabel;
|
||||
@@ -111,7 +110,15 @@ public class TwoLineListItem extends VBox {
|
||||
}
|
||||
|
||||
public void addTag(String tag) {
|
||||
getTags().add(createTagLabel(tag));
|
||||
Label tagLabel = createTagLabel(tag);
|
||||
tagLabel.getStyleClass().add("tag");
|
||||
getTags().add(tagLabel);
|
||||
}
|
||||
|
||||
public void addTagWarning(String tag) {
|
||||
Label tagLabel = createTagLabel(tag);
|
||||
tagLabel.getStyleClass().add("tag-warning");
|
||||
getTags().add(tagLabel);
|
||||
}
|
||||
|
||||
public ObservableList<Label> getTags() {
|
||||
|
||||
@@ -19,7 +19,8 @@ package org.jackhuang.hmcl.ui.download;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
@@ -49,14 +50,20 @@ 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.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.wizard.Navigation;
|
||||
import org.jackhuang.hmcl.ui.wizard.Refreshable;
|
||||
import org.jackhuang.hmcl.ui.wizard.WizardPage;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.NativePatcher;
|
||||
import org.jackhuang.hmcl.util.SettingsMap;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -65,8 +72,8 @@ import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.*;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class VersionsPage extends Control implements WizardPage, Refreshable {
|
||||
private final String gameVersion;
|
||||
@@ -172,7 +179,10 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
{
|
||||
if ("game".equals(control.libraryId)) {
|
||||
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
|
||||
wikiButton.setOnAction(event -> onOpenWiki());
|
||||
wikiButton.setOnAction(event -> {
|
||||
onOpenWiki();
|
||||
FXUtils.clearFocus(wikiButton);
|
||||
});
|
||||
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
|
||||
actions.getChildren().add(wikiButton);
|
||||
}
|
||||
@@ -233,13 +243,14 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
|
||||
if (remoteVersion instanceof GameRemoteVersion) {
|
||||
RemoteVersion.Type versionType = remoteVersion.getVersionType();
|
||||
GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion());
|
||||
|
||||
switch (versionType) {
|
||||
case RELEASE:
|
||||
case RELEASE -> {
|
||||
twoLineListItem.addTag(i18n("version.game.release"));
|
||||
imageView.setImage(VersionIconType.GRASS.getIcon());
|
||||
break;
|
||||
case PENDING:
|
||||
case SNAPSHOT:
|
||||
}
|
||||
case PENDING, SNAPSHOT -> {
|
||||
if (versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
|
||||
twoLineListItem.addTag(i18n("version.game.april_fools"));
|
||||
@@ -248,11 +259,16 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
twoLineListItem.addTag(i18n("version.game.snapshot"));
|
||||
imageView.setImage(VersionIconType.COMMAND.getIcon());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
default -> {
|
||||
twoLineListItem.addTag(i18n("version.game.old"));
|
||||
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (NativePatcher.checkSupportedStatus(gameVersion, Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION)) {
|
||||
case UNTESTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.untested"));
|
||||
case UNSUPPORTED -> twoLineListItem.addTagWarning(i18n("version.game.support_status.unsupported"));
|
||||
}
|
||||
} else {
|
||||
VersionIconType iconType;
|
||||
@@ -361,18 +377,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
);
|
||||
}
|
||||
}
|
||||
// {
|
||||
// HBox actionsBox = new HBox(8);
|
||||
// GridPane.setColumnSpan(actionsBox, 4);
|
||||
// actionsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
//
|
||||
// JFXButton refreshButton = FXUtils.newRaisedButton(i18n("button.refresh"));
|
||||
// refreshButton.setOnAction(event -> control.onRefresh());
|
||||
//
|
||||
// actionsBox.getChildren().setAll(refreshButton);
|
||||
//
|
||||
// searchPane.addRow(rowIndex++, actionsBox);
|
||||
// }
|
||||
}
|
||||
|
||||
{
|
||||
@@ -448,21 +452,16 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
|
||||
if (filter != null)
|
||||
versions = versions.filter(it -> {
|
||||
RemoteVersion.Type versionType = it.getVersionType();
|
||||
switch (filter) {
|
||||
case RELEASE:
|
||||
return versionType == RemoteVersion.Type.RELEASE;
|
||||
case SNAPSHOTS:
|
||||
return versionType == RemoteVersion.Type.SNAPSHOT
|
||||
|| versionType == RemoteVersion.Type.PENDING;
|
||||
case APRIL_FOOLS:
|
||||
return versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
case OLD:
|
||||
return versionType == RemoteVersion.Type.OLD;
|
||||
case ALL:
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
return switch (filter) {
|
||||
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
|
||||
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|
||||
|| versionType == RemoteVersion.Type.PENDING;
|
||||
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
|
||||
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
|
||||
case OLD -> versionType == RemoteVersion.Type.OLD;
|
||||
// case ALL,
|
||||
default -> true;
|
||||
};
|
||||
});
|
||||
|
||||
String nameQuery = nameField.getText();
|
||||
|
||||
@@ -182,11 +182,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage()));
|
||||
|
||||
// sixth item in left sidebar
|
||||
AdvancedListItem chatItem = new AdvancedListItem();
|
||||
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
||||
chatItem.setActionButtonVisible(false);
|
||||
chatItem.setTitle(i18n("chat"));
|
||||
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
AdvancedListItem terracottaItem = new AdvancedListItem();
|
||||
terracottaItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
terracottaItem.setActionButtonVisible(false);
|
||||
terracottaItem.setTitle(i18n("terracotta"));
|
||||
terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage()));
|
||||
|
||||
// the left sidebar
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
@@ -198,7 +198,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
.add(downloadItem)
|
||||
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
||||
.add(launcherSettingsItem)
|
||||
.add(chatItem);
|
||||
.add(terracottaItem);
|
||||
|
||||
// the root page, with the sidebar in left, navigator in center.
|
||||
setLeft(sideBar);
|
||||
|
||||
@@ -114,9 +114,12 @@ public final class SettingsPage extends SettingsView {
|
||||
chkUpdateStable.setUserData(UpdateChannel.STABLE);
|
||||
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
|
||||
updateChannel.set(UpdateChannel.getChannel());
|
||||
updateChannel.addListener((a, b, newValue) -> {
|
||||
UpdateChecker.requestCheckUpdate(newValue);
|
||||
});
|
||||
|
||||
InvalidationListener checkUpdateListener = e -> {
|
||||
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
|
||||
};
|
||||
updateChannel.addListener(checkUpdateListener);
|
||||
previewPane.selectedProperty().addListener(checkUpdateListener);
|
||||
// ====
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ public abstract class SettingsView extends StackPane {
|
||||
protected final JFXRadioButton chkUpdateStable;
|
||||
protected final JFXRadioButton chkUpdateDev;
|
||||
protected final JFXButton btnUpdate;
|
||||
protected final OptionToggleButton previewPane;
|
||||
protected final ScrollPane scroll;
|
||||
|
||||
public SettingsView() {
|
||||
@@ -147,6 +148,15 @@ public abstract class SettingsView extends StackPane {
|
||||
settingsPane.getContent().add(updatePane);
|
||||
}
|
||||
|
||||
{
|
||||
previewPane = new OptionToggleButton();
|
||||
previewPane.setTitle(i18n("update.preview"));
|
||||
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
|
||||
FXUtils.installFastTooltip(previewPane, i18n("update.preview.tooltip"));
|
||||
|
||||
settingsPane.getContent().add(previewPane);
|
||||
}
|
||||
|
||||
{
|
||||
fileCommonLocation = new MultiFileItem<>();
|
||||
fileCommonLocationSublist = new ComponentSublist();
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.terracotta;
|
||||
|
||||
import com.jfoenix.controls.JFXProgressBar;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.WeakChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaManager;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaState;
|
||||
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
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.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaControllerPage extends StackPane {
|
||||
private static final String FEEDBACK_TIP = "terracotta-feedback";
|
||||
private static final ObjectProperty<TerracottaState> UI_STATE = new SimpleObjectProperty<>();
|
||||
|
||||
static {
|
||||
FXUtils.onChangeAndOperate(TerracottaManager.stateProperty(), state -> {
|
||||
if (state != null) {
|
||||
UI_STATE.set(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
|
||||
Consider moving UI into multiple files for each state respectively. */
|
||||
public TerracottaControllerPage() {
|
||||
TransitionPane transition = new TransitionPane();
|
||||
|
||||
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
|
||||
DoubleProperty progressProperty = new SimpleDoubleProperty();
|
||||
ObservableList<Node> nodesProperty = FXCollections.observableList(new ArrayList<>());
|
||||
|
||||
FXUtils.applyDragListener(this, path -> {
|
||||
TerracottaState state = UI_STATE.get();
|
||||
|
||||
if (state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
|
||||
) {
|
||||
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, files -> {
|
||||
Path path = files.get(0);
|
||||
|
||||
if (!TerracottaManager.validate(path)) {
|
||||
Controllers.dialog(
|
||||
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
|
||||
i18n("message.error"),
|
||||
MessageDialogPane.MessageType.ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
TerracottaState state = UI_STATE.get(), next;
|
||||
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
|
||||
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
|
||||
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5,
|
||||
MessageDialogPane.MessageType.INFO, () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, null);
|
||||
return;
|
||||
}
|
||||
|
||||
next = TerracottaManager.install(path);
|
||||
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||
next = TerracottaManager.recover(path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (next != null) {
|
||||
UI_STATE.set(next);
|
||||
}
|
||||
});
|
||||
|
||||
ChangeListener<TerracottaState> listener = (_uiState, legacyState, state) -> {
|
||||
if (legacyState != null && legacyState.isUIFakeState() && !state.isUIFakeState() && legacyState.getClass() == state.getClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
progressProperty.unbind();
|
||||
|
||||
if (state instanceof TerracottaState.Bootstrap) {
|
||||
statusProperty.set(i18n("terracotta.status.bootstrap"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Uninitialized uninitialized) {
|
||||
String fork = uninitialized.hasLegacy() ? "update" : "not_exist";
|
||||
|
||||
statusProperty.set(i18n("terracotta.status.uninitialized." + fork));
|
||||
progressProperty.set(0);
|
||||
|
||||
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||
body.setLineSpacing(4);
|
||||
|
||||
LineButton download = LineButton.of();
|
||||
download.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||
download.setTitle(i18n(String.format("terracotta.status.uninitialized.%s.title", fork)));
|
||||
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
|
||||
download.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(download, () -> {
|
||||
if (globalConfig().getTerracottaAgreementVersion() >= 1) {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
|
||||
if (uninitialized.hasLegacy() && I18n.isUseChinese()) {
|
||||
Object feedback = config().getShownTips().get(FEEDBACK_TIP);
|
||||
if (!(feedback instanceof Number number) || number.intValue() < 1) {
|
||||
Controllers.confirm(i18n("terracotta.feedback.desc"), i18n("terracotta.feedback.title"), () -> {
|
||||
FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK);
|
||||
config().getShownTips().put(FEEDBACK_TIP, 1);
|
||||
}, () -> {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, MessageDialogPane.MessageType.INFO, () -> {
|
||||
globalConfig().setTerracottaAgreementVersion(1);
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, () -> {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(body, download, getThirdPartyDownloadNodes());
|
||||
} else if (state instanceof TerracottaState.Preparing) {
|
||||
statusProperty.set(i18n("terracotta.status.preparing"));
|
||||
progressProperty.bind(((TerracottaState.Preparing) state).progressProperty());
|
||||
nodesProperty.setAll(getThirdPartyDownloadNodes());
|
||||
} else if (state instanceof TerracottaState.Launching) {
|
||||
statusProperty.set(i18n("terracotta.status.launching"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Unknown) {
|
||||
statusProperty.set(i18n("terracotta.status.unknown"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Waiting) {
|
||||
statusProperty.set(i18n("terracotta.status.waiting"));
|
||||
progressProperty.set(1);
|
||||
|
||||
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||
flow.setLineSpacing(4);
|
||||
|
||||
LineButton host = LineButton.of();
|
||||
host.setLeftIcon(SVG.HOST);
|
||||
host.setTitle(i18n("terracotta.status.waiting.host.title"));
|
||||
host.setSubtitle(i18n("terracotta.status.waiting.host.desc"));
|
||||
host.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(host, () -> {
|
||||
if (LauncherHelper.countMangedProcesses() >= 1) {
|
||||
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||
if (s1 != null) {
|
||||
UI_STATE.set(s1);
|
||||
}
|
||||
} else {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("terracotta.status.waiting.host.launch.desc"),
|
||||
i18n("terracotta.status.waiting.host.launch.title"),
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
).addAction(i18n("version.launch"), () -> {
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
|
||||
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||
if (s1 != null) {
|
||||
UI_STATE.set(s1);
|
||||
}
|
||||
}).addCancel(() -> {
|
||||
}).build());
|
||||
}
|
||||
});
|
||||
|
||||
LineButton guest = LineButton.of();
|
||||
guest.setLeftIcon(SVG.ADD_CIRCLE);
|
||||
guest.setTitle(i18n("terracotta.status.waiting.guest.title"));
|
||||
guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc"));
|
||||
guest.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(guest, () -> {
|
||||
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
|
||||
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
|
||||
if (task != null) {
|
||||
task.whenComplete(Schedulers.javafx(), (s, e) -> {
|
||||
if (e != null) {
|
||||
reject.accept(i18n("terracotta.status.waiting.guest.prompt.invalid"));
|
||||
} else {
|
||||
resolve.run();
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||
} else {
|
||||
resolve.run();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (ThreadLocalRandom.current().nextDouble() < 0.02D) {
|
||||
LineButton feedback = LineButton.of();
|
||||
feedback.setLeftIcon(SVG.FEEDBACK);
|
||||
feedback.setTitle(i18n("terracotta.feedback.title"));
|
||||
feedback.setSubtitle(i18n("terracotta.feedback.desc"));
|
||||
feedback.setRightIcon(SVG.OPEN_IN_NEW);
|
||||
FXUtils.onClicked(feedback, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
|
||||
|
||||
nodesProperty.setAll(flow, host, guest, feedback);
|
||||
} else {
|
||||
nodesProperty.setAll(flow, host, guest);
|
||||
}
|
||||
} else if (state instanceof TerracottaState.HostScanning) {
|
||||
statusProperty.set(i18n("terracotta.status.scanning"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
|
||||
body.setLineSpacing(4);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.scanning.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(body, room);
|
||||
} else if (state instanceof TerracottaState.HostStarting) {
|
||||
statusProperty.set(i18n("terracotta.status.host_starting"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.host_starting.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.HostOK hostOK) {
|
||||
if (hostOK.isForkOf(legacyState)) {
|
||||
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
|
||||
profileUI.updateProfiles(hostOK.getProfiles());
|
||||
} else { // Should NOT happen
|
||||
nodesProperty.add(new PlayerProfileUI(hostOK.getProfiles()));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
String cs = hostOK.getCode();
|
||||
copyCode(cs);
|
||||
|
||||
statusProperty.set(i18n("terracotta.status.host_ok"));
|
||||
progressProperty.set(1);
|
||||
|
||||
VBox code = new VBox(4);
|
||||
code.setAlignment(Pos.CENTER);
|
||||
{
|
||||
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
|
||||
desc.setMouseTransparent(true);
|
||||
|
||||
Label label = new Label(cs);
|
||||
label.setMouseTransparent(true);
|
||||
label.setStyle("-fx-font-size: 24");
|
||||
label.setAlignment(Pos.CENTER);
|
||||
VBox.setMargin(label, new Insets(10, 0, 10, 0));
|
||||
|
||||
code.getChildren().setAll(desc, label);
|
||||
}
|
||||
code.setCursor(Cursor.HAND);
|
||||
FXUtils.onClicked(code, () -> copyCode(cs));
|
||||
|
||||
LineButton copy = LineButton.of();
|
||||
copy.setLeftIcon(SVG.CONTENT_COPY);
|
||||
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
|
||||
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
|
||||
FXUtils.onClicked(copy, () -> copyCode(cs));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.host_ok.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
if (hostOK.getProfiles().isEmpty()) {
|
||||
nodesProperty.setAll(code, copy, back);
|
||||
} else {
|
||||
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
|
||||
}
|
||||
}
|
||||
} else if (state instanceof TerracottaState.GuestStarting) {
|
||||
statusProperty.set(i18n("terracotta.status.guest_starting"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.guest_starting.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.GuestOK guestOK) {
|
||||
if (guestOK.isForkOf(legacyState)) {
|
||||
if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) {
|
||||
profileUI.updateProfiles(guestOK.getProfiles());
|
||||
} else { // Should NOT happen
|
||||
nodesProperty.add(new PlayerProfileUI(guestOK.getProfiles()));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
statusProperty.set(i18n("terracotta.status.guest_ok"));
|
||||
progressProperty.set(1);
|
||||
|
||||
LineButton tutorial = LineButton.of();
|
||||
tutorial.setTitle(i18n("terracotta.status.guest_ok.title"));
|
||||
tutorial.setSubtitle(i18n("terracotta.status.guest_ok.desc", guestOK.getUrl()));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.guest_ok.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
if (guestOK.getProfiles().isEmpty()) {
|
||||
nodesProperty.setAll(tutorial, back);
|
||||
} else {
|
||||
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
|
||||
}
|
||||
}
|
||||
} else if (state instanceof TerracottaState.Exception exception) {
|
||||
statusProperty.set(i18n("terracotta.status.exception.desc." + exception.getType().name().toLowerCase(Locale.ROOT)));
|
||||
progressProperty.set(1);
|
||||
nodesProperty.setAll();
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.exception.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
SpinnerPane exportLog = new SpinnerPane();
|
||||
LineButton exportLogInner = LineButton.of();
|
||||
exportLogInner.setLeftIcon(SVG.OUTPUT);
|
||||
exportLogInner.setTitle(i18n("terracotta.export_log"));
|
||||
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
|
||||
exportLog.setContent(exportLogInner);
|
||||
exportLog.getProperties().put("ComponentList.noPadding", true);
|
||||
// FIXME: SpinnerPane loses its content width in loading state.
|
||||
exportLog.minHeightProperty().bind(back.heightProperty());
|
||||
|
||||
FXUtils.onClicked(exportLogInner, () -> {
|
||||
exportLog.setLoading(true);
|
||||
|
||||
TerracottaManager.exportLogs().thenAcceptAsync(Schedulers.io(), data -> {
|
||||
if (data == null || data.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path path = Path.of("terracotta-log-" + LocalDateTime.now().format(
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")
|
||||
) + ".zip").toAbsolutePath();
|
||||
try (Zipper zipper = new Zipper(path)) {
|
||||
zipper.putTextFile(data, StandardCharsets.UTF_8, "terracotta.log");
|
||||
try (OutputStream os = zipper.putStream("hmcl-latest.log")) {
|
||||
Logger.LOG.exportLogs(os);
|
||||
}
|
||||
}
|
||||
FXUtils.showFileInExplorer(path);
|
||||
}).thenRunAsync(
|
||||
() -> Thread.sleep(3000)
|
||||
).whenComplete(
|
||||
Schedulers.javafx(),
|
||||
e -> exportLog.setLoading(false)
|
||||
).start();
|
||||
});
|
||||
|
||||
nodesProperty.setAll(back, exportLog);
|
||||
} else if (state instanceof TerracottaState.Fatal fatal) {
|
||||
String message = i18n("terracotta.status.fatal." + fatal.getType().name().toLowerCase(Locale.ROOT));
|
||||
|
||||
statusProperty.set(message);
|
||||
progressProperty.set(1);
|
||||
|
||||
if (fatal.isRecoverable()) {
|
||||
LineButton retry = LineButton.of();
|
||||
retry.setLeftIcon(SVG.RESTORE);
|
||||
retry.setTitle(i18n("terracotta.status.fatal.retry"));
|
||||
retry.setSubtitle(message);
|
||||
FXUtils.onClicked(retry, () -> {
|
||||
TerracottaState s = TerracottaManager.recover(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
if (fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||
nodesProperty.setAll(retry, getThirdPartyDownloadNodes());
|
||||
} else {
|
||||
nodesProperty.setAll(retry);
|
||||
}
|
||||
} else {
|
||||
nodesProperty.setAll();
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError(state.getClass().getName());
|
||||
}
|
||||
|
||||
ComponentList components = new ComponentList();
|
||||
{
|
||||
VBox statusPane = new VBox(8);
|
||||
VBox.setMargin(statusPane, new Insets(0, 0, 0, 4));
|
||||
{
|
||||
Label status = new Label();
|
||||
status.textProperty().bind(statusProperty);
|
||||
JFXProgressBar progress = new JFXProgressBar();
|
||||
progress.progressProperty().bind(progressProperty);
|
||||
progress.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
statusPane.getChildren().setAll(status, progress);
|
||||
}
|
||||
|
||||
ObservableList<Node> children = components.getContent();
|
||||
children.add(statusPane);
|
||||
children.addAll(nodesProperty);
|
||||
}
|
||||
|
||||
transition.setContent(components, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||
};
|
||||
listener.changed(UI_STATE, null, UI_STATE.get());
|
||||
holder.add(listener);
|
||||
UI_STATE.addListener(new WeakChangeListener<>(listener));
|
||||
|
||||
VBox content = new VBox(10);
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("terracotta.status")), transition);
|
||||
content.setPadding(new Insets(10));
|
||||
content.setFillWidth(true);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane(content);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
|
||||
getChildren().setAll(scrollPane);
|
||||
}
|
||||
|
||||
private ComponentList getThirdPartyDownloadNodes() {
|
||||
ComponentSublist locals = new ComponentSublist();
|
||||
locals.setComponentPadding(false);
|
||||
|
||||
LineButton header = LineButton.of(false);
|
||||
header.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||
header.setTitle(i18n("terracotta.from_local.title"));
|
||||
header.setSubtitle(i18n("terracotta.from_local.desc"));
|
||||
locals.setHeaderLeft(header);
|
||||
|
||||
for (TerracottaMetadata.Link link : TerracottaMetadata.PACKAGE_LINKS) {
|
||||
HBox node = new HBox();
|
||||
node.setAlignment(Pos.CENTER_LEFT);
|
||||
node.setPadding(new Insets(10, 16, 10, 16));
|
||||
|
||||
Label description = new Label(link.description().getText(I18n.getLocale().getCandidateLocales()));
|
||||
HBox placeholder = new HBox();
|
||||
HBox.setHgrow(placeholder, Priority.ALWAYS);
|
||||
Node icon = SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16);
|
||||
node.getChildren().setAll(description, placeholder, icon);
|
||||
|
||||
String url = link.link();
|
||||
RipplerContainer container = new RipplerContainer(node);
|
||||
container.setOnMouseClicked(ev -> Controllers.dialog(
|
||||
i18n("terracotta.from_local.guide", TerracottaMetadata.PACKAGE_NAME),
|
||||
i18n("message.info"), MessageDialogPane.MessageType.INFO,
|
||||
() -> FXUtils.openLink(url)
|
||||
));
|
||||
container.getProperties().put("ComponentList.noPadding", true);
|
||||
locals.getContent().add(container);
|
||||
}
|
||||
return locals;
|
||||
}
|
||||
|
||||
private void copyCode(String code) {
|
||||
FXUtils.copyText(code, i18n("terracotta.status.host_ok.code.copy.toast"));
|
||||
}
|
||||
|
||||
private static final class LineButton extends RipplerContainer {
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
private final ObjectProperty<Node> left = new SimpleObjectProperty<>(this, "left");
|
||||
private final ObjectProperty<Node> right = new SimpleObjectProperty<>(this, "right");
|
||||
private final StringProperty title = new SimpleStringProperty(this, "title", "");
|
||||
private final StringProperty subTitle = new SimpleStringProperty(this, "subTitle", "");
|
||||
|
||||
public static LineButton of() {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
public static LineButton of(boolean padding) {
|
||||
HBox container = new HBox();
|
||||
if (padding) {
|
||||
container.setPadding(new Insets(10, 16, 10, 16));
|
||||
}
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
container.setCursor(Cursor.HAND);
|
||||
container.setSpacing(16);
|
||||
|
||||
LineButton button = new LineButton(container);
|
||||
VBox spacing = new VBox();
|
||||
HBox.setHgrow(spacing, Priority.ALWAYS);
|
||||
button.holder.add(FXUtils.observeWeak(() -> {
|
||||
List<Node> nodes = new ArrayList<>(4);
|
||||
Node left = button.left.get();
|
||||
if (left != null) {
|
||||
nodes.add(left);
|
||||
}
|
||||
|
||||
{
|
||||
// FIXME: It's sucked to have the following TwoLineListItem-liked logic whose subtitle is a TextFlow.
|
||||
VBox middle = new VBox();
|
||||
middle.getStyleClass().add("two-line-list-item");
|
||||
middle.setMouseTransparent(true);
|
||||
{
|
||||
HBox firstLine = new HBox();
|
||||
firstLine.getStyleClass().add("first-line");
|
||||
{
|
||||
Label lblTitle = new Label(button.title.get());
|
||||
lblTitle.getStyleClass().add("title");
|
||||
firstLine.getChildren().setAll(lblTitle);
|
||||
}
|
||||
|
||||
HBox secondLine = new HBox();
|
||||
{
|
||||
Text text = new Text(button.subTitle.get());
|
||||
text.setFill(new Color(0, 0, 0, 0.5));
|
||||
|
||||
TextFlow lblSubtitle = new TextFlow(text);
|
||||
lblSubtitle.getStyleClass().add("subtitle");
|
||||
secondLine.getChildren().setAll(lblSubtitle);
|
||||
}
|
||||
|
||||
middle.getChildren().setAll(firstLine, secondLine);
|
||||
}
|
||||
nodes.add(middle);
|
||||
}
|
||||
|
||||
nodes.add(spacing);
|
||||
|
||||
Node right = button.right.get();
|
||||
if (right != null) {
|
||||
nodes.add(right);
|
||||
}
|
||||
|
||||
container.getChildren().setAll(nodes);
|
||||
}, button.title, button.subTitle, button.left, button.right));
|
||||
button.getProperties().put("ComponentList.noPadding", true);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private LineButton(Node container) {
|
||||
super(container);
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title.set(title);
|
||||
}
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
this.subTitle.set(subtitle);
|
||||
}
|
||||
|
||||
public void setLeftImage(Image left) {
|
||||
this.left.set(new ImageView(left));
|
||||
}
|
||||
|
||||
public void setLeftIcon(SVG left) {
|
||||
this.left.set(left.createIcon(Theme.blackFill(), 28));
|
||||
}
|
||||
|
||||
public void setRightIcon(SVG right) {
|
||||
this.right.set(right.createIcon(Theme.blackFill(), 28));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PlayerProfileUI extends VBox {
|
||||
private final TransitionPane transition;
|
||||
|
||||
public PlayerProfileUI(List<TerracottaProfile> profiles) {
|
||||
super(8);
|
||||
VBox.setMargin(this, new Insets(0, 0, 0, 4));
|
||||
{
|
||||
Label status = new Label();
|
||||
status.setText(i18n("terracotta.player_list"));
|
||||
|
||||
transition = new TransitionPane();
|
||||
getChildren().setAll(status, transition);
|
||||
|
||||
updateProfiles(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProfiles(List<TerracottaProfile> profiles) {
|
||||
VBox pane = new VBox(8);
|
||||
|
||||
for (TerracottaProfile profile : profiles) {
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.setTitle(profile.getName());
|
||||
item.setSubtitle(profile.getVendor());
|
||||
item.addTag(i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)));
|
||||
pane.getChildren().add(item);
|
||||
}
|
||||
|
||||
this.transition.setContent(pane, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.terracotta;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
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.AdvancedListBox;
|
||||
import org.jackhuang.hmcl.ui.construct.PageAware;
|
||||
import org.jackhuang.hmcl.ui.construct.TabHeader;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.main.MainPage;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta")));
|
||||
private final TabHeader tab;
|
||||
private final TabHeader.Tab<TerracottaControllerPage> statusPage = new TabHeader.Tab<>("statusPage");
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ChangeListener<String> instanceChangeListenerHolder;
|
||||
|
||||
public TerracottaPage() {
|
||||
statusPage.setNodeSupplier(TerracottaControllerPage::new);
|
||||
tab = new TabHeader(statusPage);
|
||||
tab.select(statusPage);
|
||||
|
||||
transitionPane.setContent(statusPage.getNode(), ContainerAnimations.NONE);
|
||||
FXUtils.onChange(tab.getSelectionModel().selectedItemProperty(), newValue -> {
|
||||
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
BorderPane left = new BorderPane();
|
||||
FXUtils.setLimitWidth(left, 200);
|
||||
VBox.setVgrow(left, Priority.ALWAYS);
|
||||
setLeft(left);
|
||||
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE);
|
||||
left.setTop(sideBar);
|
||||
|
||||
AdvancedListBox toolbar = new AdvancedListBox()
|
||||
.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> {
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
}, item -> {
|
||||
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(Profiles.selectedVersionProperty(),
|
||||
instanceName -> item.setSubtitle(StringUtils.isNotBlank(instanceName) ? instanceName : i18n("version.empty"))
|
||||
);
|
||||
|
||||
MainPage mainPage = Controllers.getRootPage().getMainPage();
|
||||
FXUtils.onScroll(item, mainPage.getVersions(), list -> {
|
||||
String currentId = mainPage.getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, it -> mainPage.getProfile().setSelectedVersion(it.getId()));
|
||||
})
|
||||
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK))
|
||||
.addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/"))
|
||||
.addNavigationDrawerItem(i18n("chat"), SVG.CHAT, () -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
left.setBottom(toolbar);
|
||||
|
||||
setCenter(transitionPane);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageShown() {
|
||||
tab.onPageShown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageHidden() {
|
||||
tab.onPageHidden();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,9 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
pane.getChildren().add(container);
|
||||
|
||||
imageView.setFitWidth(40);
|
||||
imageView.setFitHeight(40);
|
||||
|
||||
container.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), content);
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
}
|
||||
@@ -547,7 +550,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
|
||||
.map(category -> getSkinnable().getLocalizedCategory(category))
|
||||
.forEach(content::addTag);
|
||||
if (StringUtils.isNotBlank(dataItem.getIconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 40, 40, true, true));
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(dataItem.getIconUrl(), 80, 80, true, true));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,8 +219,10 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
BorderPane.setMargin(descriptionPane, new Insets(11, 11, 0, 11));
|
||||
{
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setFitWidth(40);
|
||||
imageView.setFitHeight(40);
|
||||
if (StringUtils.isNotBlank(getSkinnable().addon.getIconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 40, 40, true, true));
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(getSkinnable().addon.getIconUrl(), 80, 80, true, true));
|
||||
}
|
||||
descriptionPane.getChildren().add(FXUtils.limitingSize(imageView, 40, 40));
|
||||
|
||||
@@ -341,6 +343,8 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
HBox.setHgrow(content, Priority.ALWAYS);
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setFitWidth(40);
|
||||
imageView.setFitHeight(40);
|
||||
pane.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), content);
|
||||
|
||||
RipplerContainer container = new RipplerContainer(pane);
|
||||
@@ -358,12 +362,12 @@ public class DownloadPage extends Control implements DecoratorPage {
|
||||
.map(page::getLocalizedCategory)
|
||||
.forEach(content::addTag);
|
||||
if (StringUtils.isNotBlank(addon.getIconUrl())) {
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 40, 40, true, true));
|
||||
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true));
|
||||
}
|
||||
} else {
|
||||
content.setTitle(i18n("mods.broken_dependency.title"));
|
||||
content.setSubtitle(i18n("mods.broken_dependency.desc"));
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/icon@8x.png", 40, 40, true, true));
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/icon@4x.png"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.ObservableList;
|
||||
@@ -27,6 +26,7 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.HMCLGameRepository;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
@@ -44,19 +44,22 @@ import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObject> implements VersionPage.VersionLoadable, PageAware {
|
||||
private final BooleanProperty modded = new SimpleBooleanProperty(this, "modded", false);
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
private ModManager modManager;
|
||||
private LibraryAnalyzer libraryAnalyzer;
|
||||
private Profile profile;
|
||||
private String versionId;
|
||||
private String instanceId;
|
||||
private String gameVersion;
|
||||
|
||||
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
|
||||
|
||||
public ModListPage() {
|
||||
FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "zip", "litemod").contains(FileUtils.getExtension(it)), mods -> {
|
||||
@@ -83,34 +86,82 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
@Override
|
||||
public void loadVersion(Profile profile, String id) {
|
||||
this.profile = profile;
|
||||
this.versionId = id;
|
||||
this.instanceId = id;
|
||||
|
||||
HMCLGameRepository repository = profile.getRepository();
|
||||
Version resolved = repository.getResolvedPreservingPatchesVersion(id);
|
||||
libraryAnalyzer = LibraryAnalyzer.analyze(resolved, repository.getGameVersion(resolved).orElse(null));
|
||||
modded.set(libraryAnalyzer.hasModLoader());
|
||||
this.gameVersion = repository.getGameVersion(resolved).orElse(null);
|
||||
LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolved, gameVersion);
|
||||
modded.set(analyzer.hasModLoader());
|
||||
loadMods(profile.getRepository().getModManager(id));
|
||||
}
|
||||
|
||||
private CompletableFuture<?> loadMods(ModManager modManager) {
|
||||
private void loadMods(ModManager modManager) {
|
||||
setLoading(true);
|
||||
|
||||
this.modManager = modManager;
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
lock.lock();
|
||||
try {
|
||||
synchronized (ModListPage.this) {
|
||||
runInFX(() -> loadingProperty().set(true));
|
||||
modManager.refreshMods();
|
||||
return new ArrayList<>(modManager.getMods());
|
||||
}
|
||||
modManager.refreshMods();
|
||||
return modManager.getMods().stream().map(ModListPageSkin.ModInfoObject::new).toList();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}, Schedulers.defaultScheduler()).whenCompleteAsync((list, exception) -> {
|
||||
loadingProperty().set(false);
|
||||
if (exception == null)
|
||||
itemsProperty().setAll(list.stream().map(ModListPageSkin.ModInfoObject::new).sorted().collect(Collectors.toList()));
|
||||
else
|
||||
getProperties().remove(ModListPage.class);
|
||||
}, Platform::runLater);
|
||||
}, Schedulers.io()).whenCompleteAsync((list, exception) -> {
|
||||
updateSupportedLoaders(modManager);
|
||||
|
||||
if (exception == null) {
|
||||
getItems().setAll(list);
|
||||
} else {
|
||||
LOG.warning("Failed to load mods", exception);
|
||||
getItems().clear();
|
||||
}
|
||||
setLoading(false);
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
|
||||
private void updateSupportedLoaders(ModManager modManager) {
|
||||
supportedLoaders.clear();
|
||||
|
||||
LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer();
|
||||
if (analyzer == null) {
|
||||
Collections.addAll(supportedLoaders, ModLoaderType.values());
|
||||
return;
|
||||
}
|
||||
|
||||
for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) {
|
||||
if (type.isModLoader() && analyzer.has(type)) {
|
||||
ModLoaderType modLoaderType = type.getModLoaderType();
|
||||
if (modLoaderType != null) {
|
||||
supportedLoaders.add(modLoaderType);
|
||||
|
||||
if (modLoaderType == ModLoaderType.CLEANROOM)
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
|
||||
supportedLoaders.add(ModLoaderType.FORGE);
|
||||
supportedLoaders.add(ModLoaderType.NEO_FORGED);
|
||||
}
|
||||
|
||||
// Sinytra Connector
|
||||
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGED)
|
||||
|| "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
|
||||
supportedLoaders.add(ModLoaderType.FABRIC);
|
||||
}
|
||||
}
|
||||
|
||||
public void add() {
|
||||
@@ -148,7 +199,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void removeSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void removeSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
try {
|
||||
modManager.removeMods(selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
@@ -160,14 +211,14 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
}
|
||||
|
||||
public void enableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void enableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModListPageSkin.ModInfoObject::getModInfo)
|
||||
.forEach(info -> info.setActive(true));
|
||||
}
|
||||
|
||||
public void disableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
void disableSelected(ObservableList<ModListPageSkin.ModInfoObject> selectedItems) {
|
||||
selectedItems.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModListPageSkin.ModInfoObject::getModInfo)
|
||||
@@ -175,13 +226,13 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
|
||||
public void openModFolder() {
|
||||
FXUtils.openFolder(profile.getRepository().getRunDirectory(versionId).resolve("mods"));
|
||||
FXUtils.openFolder(profile.getRepository().getRunDirectory(instanceId).resolve("mods"));
|
||||
}
|
||||
|
||||
public void checkUpdates() {
|
||||
Runnable action = () -> Controllers.taskDialog(Task
|
||||
.composeAsync(() -> {
|
||||
Optional<String> gameVersion = profile.getRepository().getGameVersion(versionId);
|
||||
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
|
||||
if (gameVersion.isPresent()) {
|
||||
return new ModCheckUpdatesTask(gameVersion.get(), modManager.getMods());
|
||||
}
|
||||
@@ -199,7 +250,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
.withStagesHint(Collections.singletonList("mods.check_updates")),
|
||||
i18n("update.checking"), TaskCancellationAction.NORMAL);
|
||||
|
||||
if (profile.getRepository().isModpack(versionId)) {
|
||||
if (profile.getRepository().isModpack(instanceId)) {
|
||||
Controllers.confirm(
|
||||
i18n("mods.update_modpack_mod.warning"), null,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
@@ -210,7 +261,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
}
|
||||
|
||||
public void download() {
|
||||
Controllers.getDownloadPage().showModDownloads().selectVersion(versionId);
|
||||
Controllers.getDownloadPage().showModDownloads().selectVersion(instanceId);
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
}
|
||||
|
||||
@@ -239,7 +290,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
return this.profile;
|
||||
}
|
||||
|
||||
public String getVersionId() {
|
||||
return this.versionId;
|
||||
public String getInstanceId() {
|
||||
return this.instanceId;
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,17 @@
|
||||
package org.jackhuang.hmcl.ui.versions;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.KeyCode;
|
||||
@@ -36,6 +36,7 @@ import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
@@ -45,6 +46,7 @@ import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
@@ -53,35 +55,32 @@ 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.Holder;
|
||||
import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
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.jetbrains.annotations.NotNull;
|
||||
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 java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
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.StringUtils.isNotBlank;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
@@ -94,6 +93,9 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
// FXThread
|
||||
private boolean isSearching = false;
|
||||
|
||||
@SuppressWarnings({"FieldCanBeLocal", "unused"})
|
||||
private final ChangeListener<Boolean> holder;
|
||||
|
||||
ModListPageSkin(ModListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
@@ -105,6 +107,12 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
root.getStyleClass().add("no-padding");
|
||||
listView = new JFXListView<>();
|
||||
|
||||
this.holder = FXUtils.onWeakChange(skinnable.loadingProperty(), loading -> {
|
||||
if (!loading) {
|
||||
listView.scrollTo(0);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
toolbarPane = new TransitionPane();
|
||||
|
||||
@@ -272,139 +280,180 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|| predicate.test(modInfo.getGameVersion())
|
||||
|| predicate.test(modInfo.getId())
|
||||
|| predicate.test(Objects.toString(modInfo.getModLoaderType()))
|
||||
|| predicate.test((item.getMod() != null ? item.getMod().getDisplayName() : null))) {
|
||||
|| predicate.test((item.getModTranslations() != null ? item.getModTranslations().getDisplayName() : null))) {
|
||||
listView.getItems().add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class ModInfoObject extends RecursiveTreeObject<ModInfoObject> implements Comparable<ModInfoObject> {
|
||||
static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final String title;
|
||||
private final String message;
|
||||
private final ModTranslations.Mod mod;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
|
||||
private SoftReference<CompletableFuture<Image>> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
StringBuilder title = new StringBuilder(localModFile.getName());
|
||||
if (isNotBlank(localModFile.getVersion()))
|
||||
title.append(" ").append(localModFile.getVersion());
|
||||
this.title = title.toString();
|
||||
|
||||
StringBuilder message = new StringBuilder(localModFile.getFileName());
|
||||
if (isNotBlank(localModFile.getGameVersion()))
|
||||
message.append(", ").append(i18n("mods.game.version")).append(": ").append(localModFile.getGameVersion());
|
||||
if (isNotBlank(localModFile.getAuthors()))
|
||||
message.append(", ").append(i18n("archive.author")).append(": ").append(localModFile.getAuthors());
|
||||
this.message = message.toString();
|
||||
|
||||
this.mod = ModTranslations.MOD.getModById(localModFile.getId());
|
||||
}
|
||||
|
||||
String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
String getSubtitle() {
|
||||
return message;
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
}
|
||||
|
||||
LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public ModTranslations.Mod getMod() {
|
||||
return mod;
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NotNull ModListPageSkin.ModInfoObject o) {
|
||||
return localModFile.getFileName().toLowerCase().compareTo(o.localModFile.getFileName().toLowerCase());
|
||||
@FXThread
|
||||
private Image loadIcon() {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
|
||||
iconPaths.add(this.localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
iconPaths.addAll(List.of(
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"mod_logo.png",
|
||||
"pack.png",
|
||||
"logoFile.png",
|
||||
"assets/icon.png",
|
||||
"assets/logo.png",
|
||||
"assets/mod_icon.png",
|
||||
"assets/mod_logo.png",
|
||||
"META-INF/icon.png",
|
||||
"META-INF/logo.png",
|
||||
"META-INF/mod_icon.png",
|
||||
"textures/icon.png",
|
||||
"textures/logo.png",
|
||||
"textures/mod_icon.png",
|
||||
"resources/icon.png",
|
||||
"resources/logo.png",
|
||||
"resources/mod_icon.png"
|
||||
));
|
||||
|
||||
String modId = this.localModFile.getId();
|
||||
if (StringUtils.isNotBlank(modId)) {
|
||||
iconPaths.addAll(List.of(
|
||||
"assets/" + modId + "/icon.png",
|
||||
"assets/" + modId + "/logo.png",
|
||||
"assets/" + modId.replace("-", "") + "/icon.png",
|
||||
"assets/" + modId.replace("-", "") + "/logo.png",
|
||||
modId + ".png",
|
||||
modId + "-logo.png",
|
||||
modId + "-icon.png",
|
||||
modId + "_logo.png",
|
||||
modId + "_icon.png",
|
||||
"textures/" + modId + "/icon.png",
|
||||
"textures/" + modId + "/logo.png",
|
||||
"resources/" + modId + "/icon.png",
|
||||
"resources/" + modId + "/logo.png"
|
||||
));
|
||||
}
|
||||
|
||||
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 VersionIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
|
||||
}
|
||||
|
||||
public void loadIcon(ImageView imageView, @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) {
|
||||
imageView.setImage(image);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io());
|
||||
this.iconCache = new SoftReference<>(imageFuture);
|
||||
}
|
||||
imageView.setImage(VersionIconType.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;
|
||||
}
|
||||
}
|
||||
|
||||
imageView.setImage(image);
|
||||
}, Schedulers.javafx());
|
||||
}
|
||||
}
|
||||
|
||||
class ModInfoDialog extends JFXDialogLayout {
|
||||
final class ModInfoDialog extends JFXDialogLayout {
|
||||
|
||||
ModInfoDialog(ModInfoObject modInfo) {
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
|
||||
Stage stage = Controllers.getStage();
|
||||
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
|
||||
|
||||
ImageView imageView = new ImageView();
|
||||
Task.supplyAsync(() -> {
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modInfo.getModInfo().getFile())) {
|
||||
String logoPath = modInfo.getModInfo().getLogoPath();
|
||||
if (StringUtils.isNotBlank(logoPath)) {
|
||||
Path iconPath = fs.getPath(logoPath);
|
||||
if (Files.exists(iconPath)) {
|
||||
try {
|
||||
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
|
||||
if (!image.isError() && image.getWidth() == image.getHeight())
|
||||
return image;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load image " + logoPath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> defaultPaths = new ArrayList<>(Arrays.asList(
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"mod_logo.png",
|
||||
"pack.png",
|
||||
"logoFile.png"
|
||||
));
|
||||
|
||||
String id = modInfo.getModInfo().getId();
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
defaultPaths.addAll(Arrays.asList(
|
||||
"assets/" + id + "/icon.png",
|
||||
"assets/" + id.replace("-", "") + "/icon.png",
|
||||
id + ".png",
|
||||
id + "-logo.png",
|
||||
id + "-icon.png",
|
||||
id + "_logo.png",
|
||||
id + "_icon.png"
|
||||
));
|
||||
}
|
||||
|
||||
for (String path : defaultPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
|
||||
if (!image.isError() && image.getWidth() == image.getHeight())
|
||||
return image;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load icon", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}).whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
if (image != null) {
|
||||
imageView.setImage(image);
|
||||
} else {
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 40, 40, true, true));
|
||||
}
|
||||
}).start();
|
||||
FXUtils.limitSize(imageView, 40, 40);
|
||||
modInfo.loadIcon(imageView, null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
title.addTag(modInfo.getModInfo().getVersion());
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
StringJoiner subtitle = new StringJoiner(" | ");
|
||||
subtitle.add(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitle.add(modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
title.setSubtitle(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitle.add(modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(subtitle.toString());
|
||||
|
||||
titleContainer.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(modInfo.getModInfo().getDescription().toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
setBody(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 = stage.getHeight() * 0.5;
|
||||
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
|
||||
descriptionPane.setPrefViewportHeight(targetHeight);
|
||||
});
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteModRepository> item : Arrays.asList(
|
||||
@@ -454,7 +503,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
Controllers.navigate(new DownloadPage(
|
||||
repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
|
||||
remoteMod,
|
||||
new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getVersionId()),
|
||||
new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()),
|
||||
(profile, version, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods")
|
||||
));
|
||||
});
|
||||
@@ -477,7 +526,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
getActions().add(officialPageButton);
|
||||
}
|
||||
|
||||
if (modInfo.getMod() == null || StringUtils.isBlank(modInfo.getMod().getMcmod())) {
|
||||
if (modInfo.getModTranslations() == null || StringUtils.isBlank(modInfo.getModTranslations().getMcmod())) {
|
||||
JFXHyperlink searchButton = new JFXHyperlink(i18n("mods.mcmod.search"));
|
||||
searchButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
@@ -492,7 +541,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
JFXHyperlink mcmodButton = new JFXHyperlink(i18n("mods.mcmod.page"));
|
||||
mcmodButton.setOnAction(e -> {
|
||||
fireEvent(new DialogCloseEvent());
|
||||
FXUtils.openLink(ModTranslations.MOD.getMcmodUrl(modInfo.getMod()));
|
||||
FXUtils.openLink(ModTranslations.MOD.getMcmodUrl(modInfo.getModTranslations()));
|
||||
});
|
||||
getActions().add(mcmodButton);
|
||||
}
|
||||
@@ -511,16 +560,23 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
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();
|
||||
ImageView imageView = new ImageView();
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
JFXButton restoreButton = new JFXButton();
|
||||
JFXButton infoButton = new JFXButton();
|
||||
JFXButton revealButton = new JFXButton();
|
||||
BooleanProperty booleanProperty;
|
||||
|
||||
Tooltip warningTooltip;
|
||||
|
||||
ModInfoListCell(JFXListView<ModInfoObject> listView, Holder<Object> lastCell) {
|
||||
super(listView, lastCell);
|
||||
|
||||
this.getStyleClass().add("mod-info-list-cell");
|
||||
|
||||
HBox container = new HBox(8);
|
||||
container.setPickOnBounds(false);
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
@@ -528,6 +584,11 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
|
||||
imageView.setFitWidth(24);
|
||||
imageView.setFitHeight(24);
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.setImage(VersionIconType.COMMAND.getIcon());
|
||||
|
||||
restoreButton.getStyleClass().add("toggle-icon4");
|
||||
restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(Theme.blackFill(), 24), 24, 24));
|
||||
|
||||
@@ -539,7 +600,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
infoButton.getStyleClass().add("toggle-icon4");
|
||||
infoButton.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(Theme.blackFill(), 24), 24, 24));
|
||||
|
||||
container.getChildren().setAll(checkBox, content, restoreButton, revealButton, infoButton);
|
||||
container.getChildren().setAll(checkBox, imageView, content, restoreButton, revealButton, infoButton);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
@@ -547,50 +608,113 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
pseudoClassStateChanged(WARNING, false);
|
||||
if (warningTooltip != null) {
|
||||
Tooltip.uninstall(this, warningTooltip);
|
||||
warningTooltip = null;
|
||||
}
|
||||
|
||||
if (empty) return;
|
||||
content.setTitle(dataItem.getTitle());
|
||||
|
||||
List<String> warning = new ArrayList<>();
|
||||
|
||||
content.getTags().clear();
|
||||
switch (dataItem.getModInfo().getModLoaderType()) {
|
||||
case FORGE:
|
||||
content.addTag(i18n("install.installer.forge"));
|
||||
break;
|
||||
case CLEANROOM:
|
||||
content.addTag(i18n("install.installer.cleanroom"));
|
||||
break;
|
||||
case NEO_FORGED:
|
||||
content.addTag(i18n("install.installer.neoforge"));
|
||||
break;
|
||||
case FABRIC:
|
||||
content.addTag(i18n("install.installer.fabric"));
|
||||
break;
|
||||
case LITE_LOADER:
|
||||
content.addTag(i18n("install.installer.liteloader"));
|
||||
break;
|
||||
case QUILT:
|
||||
content.addTag(i18n("install.installer.quilt"));
|
||||
break;
|
||||
|
||||
LocalModFile modInfo = dataItem.getModInfo();
|
||||
ModTranslations.Mod modTranslations = dataItem.getModTranslations();
|
||||
|
||||
ModLoaderType modLoaderType = modInfo.getModLoaderType();
|
||||
|
||||
dataItem.loadIcon(imageView, 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 + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dataItem.getMod() != null && I18n.isUseChinese()) {
|
||||
content.addTag(dataItem.getMod().getDisplayName());
|
||||
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"));
|
||||
break;
|
||||
case CLEANROOM:
|
||||
content.addTagWarning(i18n("install.installer.cleanroom"));
|
||||
break;
|
||||
case NEO_FORGED:
|
||||
content.addTagWarning(i18n("install.installer.neoforge"));
|
||||
break;
|
||||
case FABRIC:
|
||||
content.addTagWarning(i18n("install.installer.fabric"));
|
||||
break;
|
||||
case LITE_LOADER:
|
||||
content.addTagWarning(i18n("install.installer.liteloader"));
|
||||
break;
|
||||
case QUILT:
|
||||
content.addTagWarning(i18n("install.installer.quilt"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
|
||||
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(!dataItem.getModInfo().getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
|
||||
restoreButton.setOnAction(e -> {
|
||||
menu.get().getContent().setAll(dataItem.getModInfo().getMod().getOldFiles().stream()
|
||||
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
|
||||
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
|
||||
() -> getSkinnable().rollback(dataItem.getModInfo(), localModFile),
|
||||
() -> getSkinnable().rollback(modInfo, localModFile),
|
||||
popup.get()))
|
||||
.collect(Collectors.toList())
|
||||
.toList()
|
||||
);
|
||||
|
||||
popup.get().show(restoreButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, 0, restoreButton.getHeight());
|
||||
});
|
||||
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(dataItem.getModInfo().getFile()));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ package org.jackhuang.hmcl.ui.versions;
|
||||
import org.jackhuang.hmcl.mod.RemoteModRepository;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
@@ -57,54 +57,220 @@ public enum ModTranslations {
|
||||
};
|
||||
|
||||
public static ModTranslations getTranslationsByRepositoryType(RemoteModRepository.Type type) {
|
||||
switch (type) {
|
||||
case MOD:
|
||||
return MOD;
|
||||
case MODPACK:
|
||||
return MODPACK;
|
||||
default:
|
||||
return EMPTY;
|
||||
return switch (type) {
|
||||
case MOD -> MOD;
|
||||
case MODPACK -> MODPACK;
|
||||
default -> EMPTY;
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("StatementWithEmptyBody")
|
||||
private static String cleanSubname(String subname) {
|
||||
if (StringUtils.isBlank(subname))
|
||||
return "";
|
||||
|
||||
StringBuilder builder = new StringBuilder(subname.length());
|
||||
for (int i = 0; i < subname.length(); ) {
|
||||
int ch = subname.codePointAt(i);
|
||||
if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
||||
|| ".+\\".indexOf(ch) >= 0) {
|
||||
builder.appendCodePoint(ch);
|
||||
} else if (Character.isWhitespace(ch)
|
||||
|| "':_-/&()[]{}|,!?~•".indexOf(ch) >= 0
|
||||
|| ch >= 0x1F300 && ch <= 0x1FAFF) {
|
||||
// Remove these unnecessary characters from subname
|
||||
} else {
|
||||
// The subname contains unsupported characters, so we do not use this subname to match the mod
|
||||
return "";
|
||||
}
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
return builder.length() == subname.length() ? subname : builder.toString();
|
||||
}
|
||||
|
||||
private final String resourceName;
|
||||
private List<Mod> mods;
|
||||
private Map<String, Mod> modIdMap; // mod id -> mod
|
||||
private Map<String, Mod> curseForgeMap; // curseforge id -> mod
|
||||
private List<Pair<String, Mod>> keywords;
|
||||
private int maxKeywordLength = -1;
|
||||
private volatile List<Mod> mods;
|
||||
private volatile Map<String, Mod> modIdMap; // mod id -> mod
|
||||
private volatile Map<String, Mod> subnameMap;
|
||||
private volatile Map<String, Mod> curseForgeMap; // curseforge id -> mod
|
||||
private volatile List<Pair<String, Mod>> keywords;
|
||||
private volatile int maxKeywordLength = -1;
|
||||
|
||||
ModTranslations(String resourceName) {
|
||||
this.resourceName = resourceName;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getModByCurseForgeId(String id) {
|
||||
if (StringUtils.isBlank(id) || !loadCurseForgeMap()) return null;
|
||||
public @NotNull List<Mod> getMods() {
|
||||
List<Mod> mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
return curseForgeMap.get(id);
|
||||
synchronized (this) {
|
||||
mods = this.mods;
|
||||
if (mods != null)
|
||||
return mods;
|
||||
|
||||
if (StringUtils.isBlank(resourceName)) {
|
||||
return this.mods = List.of();
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8))) {
|
||||
return this.mods = reader.lines().filter(line -> !line.startsWith("#")).map(Mod::new).toList();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load " + resourceName, e);
|
||||
return this.mods = List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getModIdMap() {
|
||||
Map<String, Mod> modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
synchronized (this) {
|
||||
modIdMap = this.modIdMap;
|
||||
if (modIdMap != null)
|
||||
return modIdMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
modIdMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
modIdMap.putIfAbsent(id, mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.modIdMap = modIdMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getSubnameMap() {
|
||||
Map<String, Mod> subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
synchronized (this) {
|
||||
subnameMap = this.subnameMap;
|
||||
if (subnameMap != null)
|
||||
return subnameMap;
|
||||
|
||||
subnameMap = new HashMap<>();
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
for (Mod mod : mods) {
|
||||
String subname = cleanSubname(mod.getSubname());
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
subnameMap.putIfAbsent(subname, mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.subnameMap = subnameMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Mod> getCurseForgeMap() {
|
||||
Map<String, Mod> curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
synchronized (this) {
|
||||
curseForgeMap = this.curseForgeMap;
|
||||
if (curseForgeMap != null)
|
||||
return curseForgeMap;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
curseForgeMap = new HashMap<>(mods.size());
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getCurseforge())) {
|
||||
curseForgeMap.putIfAbsent(mod.getCurseforge(), mod);
|
||||
}
|
||||
}
|
||||
|
||||
return this.curseForgeMap = curseForgeMap;
|
||||
}
|
||||
}
|
||||
|
||||
private @NotNull List<Pair<String, Mod>> getKeywords() {
|
||||
List<Pair<String, Mod>> keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
synchronized (this) {
|
||||
keywords = this.keywords;
|
||||
if (keywords != null)
|
||||
return keywords;
|
||||
|
||||
List<Mod> mods = getMods();
|
||||
|
||||
keywords = new ArrayList<>();
|
||||
int maxKeywordLength = -1;
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getName())) {
|
||||
keywords.add(pair(mod.getName(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getName().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getSubname())) {
|
||||
keywords.add(pair(mod.getSubname(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getSubname().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getAbbr())) {
|
||||
keywords.add(pair(mod.getAbbr(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getAbbr().length());
|
||||
}
|
||||
}
|
||||
|
||||
this.maxKeywordLength = maxKeywordLength;
|
||||
return this.keywords = keywords;
|
||||
}
|
||||
}
|
||||
|
||||
private int getMaxKeywordLength() {
|
||||
int maxKeywordLength = this.maxKeywordLength;
|
||||
if (maxKeywordLength >= 0)
|
||||
return maxKeywordLength;
|
||||
|
||||
// Ensure maxKeywordLength is initialized
|
||||
getKeywords();
|
||||
return this.maxKeywordLength;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getModById(String id) {
|
||||
if (StringUtils.isBlank(id) || !loadModIdMap()) return null;
|
||||
public Mod getModByCurseForgeId(String id) {
|
||||
if (StringUtils.isBlank(id)) return null;
|
||||
|
||||
return modIdMap.get(id);
|
||||
return getCurseForgeMap().get(id);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Mod getMod(String id, String subname) {
|
||||
subname = cleanSubname(subname);
|
||||
if (StringUtils.isNotBlank(subname)) {
|
||||
Mod mod = getSubnameMap().get(subname);
|
||||
if (mod != null && (StringUtils.isBlank(id) || mod.getModIds().contains(id)))
|
||||
return mod;
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(id))
|
||||
return getModIdMap().get(id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract String getMcmodUrl(Mod mod);
|
||||
|
||||
public List<Mod> searchMod(String query) {
|
||||
if (!loadKeywords()) return Collections.emptyList();
|
||||
|
||||
StringBuilder newQuery = ((CharSequence) query).chars()
|
||||
StringBuilder newQuery = query.chars()
|
||||
.filter(ch -> !Character.isSpaceChar(ch))
|
||||
.collect(StringBuilder::new, (sb, value) -> sb.append((char) value), StringBuilder::append);
|
||||
query = newQuery.toString();
|
||||
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), maxKeywordLength);
|
||||
StringUtils.LongestCommonSubsequence lcs = new StringUtils.LongestCommonSubsequence(query.length(), getMaxKeywordLength());
|
||||
List<Pair<Integer, Mod>> modList = new ArrayList<>();
|
||||
for (Pair<String, Mod> keyword : keywords) {
|
||||
for (Pair<String, Mod> keyword : getKeywords()) {
|
||||
int value = lcs.calc(query, keyword.getKey());
|
||||
if (value >= Math.max(1, query.length() - 3)) {
|
||||
modList.add(pair(value, keyword.getValue()));
|
||||
@@ -113,92 +279,7 @@ public enum ModTranslations {
|
||||
return modList.stream()
|
||||
.sorted((a, b) -> -a.getKey().compareTo(b.getKey()))
|
||||
.map(Pair::getValue)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private boolean loadFromResource() {
|
||||
if (mods != null) return true;
|
||||
if (StringUtils.isBlank(resourceName)) {
|
||||
mods = Collections.emptyList();
|
||||
return true;
|
||||
}
|
||||
|
||||
//noinspection DataFlowIssue
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
ModTranslations.class.getResourceAsStream(resourceName), StandardCharsets.UTF_8))) {
|
||||
mods = reader.lines().filter(line -> !line.startsWith("#")).map(Mod::new).collect(Collectors.toList());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load " + resourceName, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean loadCurseForgeMap() {
|
||||
if (curseForgeMap != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mods == null) {
|
||||
if (!loadFromResource()) return false;
|
||||
}
|
||||
|
||||
curseForgeMap = new HashMap<>();
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getCurseforge())) {
|
||||
curseForgeMap.put(mod.getCurseforge(), mod);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean loadModIdMap() {
|
||||
if (modIdMap != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mods == null) {
|
||||
if (!loadFromResource()) return false;
|
||||
}
|
||||
|
||||
modIdMap = new HashMap<>();
|
||||
for (Mod mod : mods) {
|
||||
for (String id : mod.getModIds()) {
|
||||
if (StringUtils.isNotBlank(id) && !"examplemod".equals(id)) {
|
||||
modIdMap.put(id, mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean loadKeywords() {
|
||||
if (keywords != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mods == null) {
|
||||
if (!loadFromResource()) return false;
|
||||
}
|
||||
|
||||
keywords = new ArrayList<>();
|
||||
maxKeywordLength = -1;
|
||||
for (Mod mod : mods) {
|
||||
if (StringUtils.isNotBlank(mod.getName())) {
|
||||
keywords.add(pair(mod.getName(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getName().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getSubname())) {
|
||||
keywords.add(pair(mod.getSubname(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getSubname().length());
|
||||
}
|
||||
if (StringUtils.isNotBlank(mod.getAbbr())) {
|
||||
keywords.add(pair(mod.getAbbr(), mod));
|
||||
maxKeywordLength = Math.max(maxKeywordLength, mod.getAbbr().length());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static final class Mod {
|
||||
@@ -217,7 +298,7 @@ public enum ModTranslations {
|
||||
|
||||
curseforge = items[0];
|
||||
mcmod = items[1];
|
||||
modIds = Collections.unmodifiableList(Arrays.asList(items[2].split(",")));
|
||||
modIds = List.of(items[2].split(","));
|
||||
name = items[3];
|
||||
subname = items[4];
|
||||
abbr = items[5];
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.Optional;
|
||||
|
||||
public final class RemoteVersion {
|
||||
|
||||
public static RemoteVersion fetch(UpdateChannel channel, String url) throws IOException {
|
||||
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
|
||||
try {
|
||||
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
|
||||
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
|
||||
@@ -37,7 +37,7 @@ public final class RemoteVersion {
|
||||
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
|
||||
boolean force = Optional.ofNullable(response.get("force")).map(JsonElement::getAsBoolean).orElse(false);
|
||||
if (jarUrl != null && jarHash != null) {
|
||||
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), force);
|
||||
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), preview, force);
|
||||
} else {
|
||||
throw new IOException("No download url is available");
|
||||
}
|
||||
@@ -51,14 +51,16 @@ public final class RemoteVersion {
|
||||
private final String url;
|
||||
private final Type type;
|
||||
private final IntegrityCheck integrityCheck;
|
||||
private final boolean preview;
|
||||
private final boolean force;
|
||||
|
||||
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean force) {
|
||||
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean preview, boolean force) {
|
||||
this.channel = channel;
|
||||
this.version = version;
|
||||
this.url = url;
|
||||
this.type = type;
|
||||
this.integrityCheck = integrityCheck;
|
||||
this.preview = preview;
|
||||
this.force = force;
|
||||
}
|
||||
|
||||
@@ -82,6 +84,10 @@ public final class RemoteVersion {
|
||||
return integrityCheck;
|
||||
}
|
||||
|
||||
public boolean isPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public boolean isForce() {
|
||||
return force;
|
||||
}
|
||||
|
||||
@@ -27,14 +27,15 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.thread;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.Lang.*;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
public final class UpdateChecker {
|
||||
private UpdateChecker() {}
|
||||
private UpdateChecker() {
|
||||
}
|
||||
|
||||
private static final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>();
|
||||
private static final BooleanBinding outdated = Bindings.createBooleanBinding(
|
||||
@@ -55,7 +56,7 @@ public final class UpdateChecker {
|
||||
private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false);
|
||||
|
||||
public static void init() {
|
||||
requestCheckUpdate(UpdateChannel.getChannel());
|
||||
requestCheckUpdate(UpdateChannel.getChannel(), config().isAcceptPreviewUpdate());
|
||||
}
|
||||
|
||||
public static RemoteVersion getLatestVersion() {
|
||||
@@ -82,16 +83,17 @@ public final class UpdateChecker {
|
||||
return checkingUpdate.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
private static RemoteVersion checkUpdate(UpdateChannel channel) throws IOException {
|
||||
private static RemoteVersion checkUpdate(UpdateChannel channel, boolean preview) throws IOException {
|
||||
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK && !IntegrityChecker.isSelfVerified()) {
|
||||
throw new IOException("Self verification failed");
|
||||
}
|
||||
|
||||
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, mapOf(
|
||||
pair("version", Metadata.VERSION),
|
||||
pair("channel", channel.channelName)));
|
||||
var query = new LinkedHashMap<String, String>();
|
||||
query.put("version", Metadata.VERSION);
|
||||
query.put("channel", preview ? channel.channelName + "-preview" : channel.channelName);
|
||||
|
||||
return RemoteVersion.fetch(channel, url);
|
||||
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, query);
|
||||
return RemoteVersion.fetch(channel, preview, url);
|
||||
}
|
||||
|
||||
private static boolean isDevelopmentVersion(String version) {
|
||||
@@ -99,7 +101,7 @@ public final class UpdateChecker {
|
||||
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
|
||||
}
|
||||
|
||||
public static void requestCheckUpdate(UpdateChannel channel) {
|
||||
public static void requestCheckUpdate(UpdateChannel channel, boolean preview) {
|
||||
Platform.runLater(() -> {
|
||||
if (isCheckingUpdate())
|
||||
return;
|
||||
@@ -108,8 +110,8 @@ public final class UpdateChecker {
|
||||
thread(() -> {
|
||||
RemoteVersion result = null;
|
||||
try {
|
||||
result = checkUpdate(channel);
|
||||
LOG.info("Latest version (" + channel + ") is " + result);
|
||||
result = checkUpdate(channel, preview);
|
||||
LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to check for update", e);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class NativePatcher {
|
||||
private NativePatcher() {
|
||||
}
|
||||
|
||||
private static final Library NONEXISTENT_LIBRARY = new Library(null);
|
||||
|
||||
@@ -196,4 +194,84 @@ public final class NativePatcher {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static SupportStatus checkSupportedStatus(GameVersionNumber gameVersion, Platform platform,
|
||||
OSVersion systemVersion) {
|
||||
if (platform.equals(Platform.WINDOWS_X86_64)) {
|
||||
if (!systemVersion.isAtLeast(OSVersion.WINDOWS_10) && gameVersion.isAtLeast("1.20.5", "24w14a"))
|
||||
return SupportStatus.UNSUPPORTED;
|
||||
|
||||
return SupportStatus.OFFICIAL_SUPPORTED;
|
||||
}
|
||||
|
||||
if (platform.equals(Platform.MACOS_X86_64) || platform.equals(Platform.LINUX_X86_64))
|
||||
return SupportStatus.OFFICIAL_SUPPORTED;
|
||||
|
||||
if (platform.equals(Platform.WINDOWS_X86) || platform.equals(Platform.LINUX_X86)) {
|
||||
if (gameVersion.isAtLeast("1.20.5", "24w14a"))
|
||||
return SupportStatus.UNSUPPORTED;
|
||||
else
|
||||
return SupportStatus.OFFICIAL_SUPPORTED;
|
||||
}
|
||||
|
||||
if (platform.equals(Platform.WINDOWS_ARM64) || platform.equals(Platform.MACOS_ARM64)) {
|
||||
if (gameVersion.compareTo("1.19") >= 0)
|
||||
return SupportStatus.OFFICIAL_SUPPORTED;
|
||||
|
||||
String minVersion = platform.getOperatingSystem() == OperatingSystem.WINDOWS
|
||||
? "1.8"
|
||||
: "1.6";
|
||||
|
||||
return gameVersion.compareTo(minVersion) >= 0
|
||||
? SupportStatus.LAUNCHER_SUPPORTED
|
||||
: SupportStatus.TRANSLATION_SUPPORTED;
|
||||
}
|
||||
|
||||
String minVersion = null;
|
||||
String maxVersion = null;
|
||||
|
||||
if (platform.equals(Platform.FREEBSD_X86_64)) {
|
||||
minVersion = "1.13";
|
||||
} else if (platform.equals(Platform.LINUX_ARM64)) {
|
||||
minVersion = "1.6";
|
||||
} else if (platform.equals(Platform.LINUX_RISCV64)) {
|
||||
minVersion = "1.8";
|
||||
} else if (platform.equals(Platform.LINUX_LOONGARCH64)) {
|
||||
minVersion = "1.6";
|
||||
} else if (platform.equals(Platform.LINUX_LOONGARCH64_OW)) {
|
||||
minVersion = "1.6";
|
||||
maxVersion = "1.20.1";
|
||||
} else if (platform.equals(Platform.LINUX_MIPS64EL) || platform.equals(Platform.LINUX_ARM32)) {
|
||||
minVersion = "1.8";
|
||||
maxVersion = "1.20.1";
|
||||
}
|
||||
|
||||
if (minVersion != null) {
|
||||
if (gameVersion.compareTo(minVersion) >= 0) {
|
||||
if (maxVersion != null && gameVersion.compareTo(maxVersion) >= 0)
|
||||
return SupportStatus.UNSUPPORTED;
|
||||
|
||||
String[] defaultGameVersions = GameVersionNumber.getDefaultGameVersions();
|
||||
if (defaultGameVersions.length > 0 && gameVersion.compareTo(defaultGameVersions[0]) > 0) {
|
||||
return SupportStatus.UNTESTED;
|
||||
}
|
||||
return SupportStatus.LAUNCHER_SUPPORTED;
|
||||
} else {
|
||||
return SupportStatus.UNSUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
return SupportStatus.UNTESTED;
|
||||
}
|
||||
|
||||
public enum SupportStatus {
|
||||
OFFICIAL_SUPPORTED,
|
||||
LAUNCHER_SUPPORTED,
|
||||
TRANSLATION_SUPPORTED,
|
||||
UNTESTED,
|
||||
UNSUPPORTED,
|
||||
}
|
||||
|
||||
private NativePatcher() {
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
|
||||
import javax.swing.*;
|
||||
@@ -62,7 +63,6 @@ import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
@@ -101,14 +101,14 @@ public final class SelfDependencyPatcher {
|
||||
|
||||
private static final class DependencyDescriptor {
|
||||
private static final String DEPENDENCIES_LIST_FILE = "/assets/openjfx-dependencies.json";
|
||||
private static final Path DEPENDENCIES_DIR_PATH = Metadata.DEPENDENCIES_DIRECTORY.resolve(Platform.getPlatform().toString()).resolve("openjfx");
|
||||
private static final Path DEPENDENCIES_DIR_PATH = Metadata.DEPENDENCIES_DIRECTORY.resolve(Platform.CURRENT_PLATFORM.toString()).resolve("openjfx");
|
||||
|
||||
static List<DependencyDescriptor> readDependencies() {
|
||||
//noinspection ConstantConditions
|
||||
try (Reader reader = new InputStreamReader(SelfDependencyPatcher.class.getResourceAsStream(DEPENDENCIES_LIST_FILE), UTF_8)) {
|
||||
Map<String, Map<String, List<DependencyDescriptor>>> allDependencies =
|
||||
JsonUtils.GSON.fromJson(reader, mapTypeOf(String.class, mapTypeOf(String.class, listTypeOf(DependencyDescriptor.class))));
|
||||
Map<String, List<DependencyDescriptor>> platform = allDependencies.get(Platform.getPlatform().toString());
|
||||
Map<String, List<DependencyDescriptor>> platform = allDependencies.get(Platform.CURRENT_PLATFORM.toString());
|
||||
if (platform == null)
|
||||
return null;
|
||||
|
||||
@@ -253,14 +253,7 @@ public final class SelfDependencyPatcher {
|
||||
.map(DependencyDescriptor::localPath)
|
||||
.toArray(Path[]::new);
|
||||
|
||||
String addOpens = null;
|
||||
try (InputStream input = SelfDependencyPatcher.class.getResourceAsStream("/META-INF/MANIFEST.MF")) {
|
||||
if (input != null)
|
||||
addOpens = new Manifest(input).getMainAttributes().getValue("Add-Opens");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read MANIFEST.MF file", e);
|
||||
}
|
||||
|
||||
String addOpens = JarUtils.getAttribute("hmcl.add-opens", null);
|
||||
JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jackhuang.hmcl.util.i18n.translator.Translator;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -55,6 +55,10 @@ public final class I18n {
|
||||
return locale.getResourceBundle();
|
||||
}
|
||||
|
||||
public static Translator getTranslator() {
|
||||
return locale.getTranslator();
|
||||
}
|
||||
|
||||
public static String i18n(String key, Object... formatArgs) {
|
||||
return locale.i18n(key, formatArgs);
|
||||
}
|
||||
@@ -64,22 +68,11 @@ public final class I18n {
|
||||
}
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
return locale.formatDateTime(time);
|
||||
return getTranslator().formatDateTime(time);
|
||||
}
|
||||
|
||||
public static String getDisplaySelfVersion(RemoteVersion version) {
|
||||
if (locale.getLocale().getLanguage().equals("lzh")) {
|
||||
if (version instanceof GameRemoteVersion)
|
||||
return WenyanUtils.translateGameVersion(GameVersionNumber.asGameVersion(version.getSelfVersion()));
|
||||
else
|
||||
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
|
||||
}
|
||||
|
||||
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
|
||||
return UpsideDownUtils.translate(version.getSelfVersion());
|
||||
}
|
||||
|
||||
return version.getSelfVersion();
|
||||
return getTranslator().getDisplayVersion(version);
|
||||
}
|
||||
|
||||
/// Find the builtin localized resource with given name and suffix.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.translator.Translator_lzh;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -36,9 +37,9 @@ public final class MinecraftWiki {
|
||||
if (wikiVersion.startsWith("2.0"))
|
||||
translatedVersion = "二點〇";
|
||||
else if (wikiVersion.startsWith("1.0.0-rc2"))
|
||||
translatedVersion = WenyanUtils.translateGameVersion(GameVersionNumber.asGameVersion("1.0.0-rc2"));
|
||||
translatedVersion = Translator_lzh.translateGameVersion(GameVersionNumber.asGameVersion("1.0.0-rc2"));
|
||||
else
|
||||
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
|
||||
translatedVersion = Translator_lzh.translateGameVersion(gameVersion);
|
||||
|
||||
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
|
||||
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
@@ -24,12 +23,13 @@ import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.translator.Translator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
@@ -72,10 +72,12 @@ public final class SupportedLocale {
|
||||
private final boolean isDefault;
|
||||
private final String name;
|
||||
private final Locale locale;
|
||||
private final TextDirection textDirection;
|
||||
|
||||
private ResourceBundle resourceBundle;
|
||||
private ResourceBundle localeNamesBundle;
|
||||
private DateTimeFormatter dateTimeFormatter;
|
||||
private List<Locale> candidateLocales;
|
||||
private Translator translator;
|
||||
|
||||
SupportedLocale() {
|
||||
this.isDefault = true;
|
||||
@@ -85,12 +87,14 @@ public final class SupportedLocale {
|
||||
this.locale = StringUtils.isBlank(language)
|
||||
? LocaleUtils.SYSTEM_DEFAULT
|
||||
: Locale.forLanguageTag(language);
|
||||
this.textDirection = LocaleUtils.getTextDirection(locale);
|
||||
}
|
||||
|
||||
SupportedLocale(Locale locale) {
|
||||
this.isDefault = false;
|
||||
this.name = locale.toLanguageTag();
|
||||
this.locale = locale;
|
||||
this.textDirection = LocaleUtils.getTextDirection(locale);
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
@@ -105,6 +109,10 @@ public final class SupportedLocale {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public TextDirection getTextDirection() {
|
||||
return textDirection;
|
||||
}
|
||||
|
||||
public String getDisplayName(SupportedLocale inLocale) {
|
||||
if (isDefault()) {
|
||||
try {
|
||||
@@ -200,23 +208,6 @@ public final class SupportedLocale {
|
||||
}
|
||||
}
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
|
||||
return UpsideDownUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
if (locale.getLanguage().equals("lzh")) {
|
||||
return WenyanUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String getFcMatchPattern() {
|
||||
String language = locale.getLanguage();
|
||||
String region = locale.getCountry();
|
||||
@@ -252,6 +243,31 @@ public final class SupportedLocale {
|
||||
return region.isEmpty() ? language : language + "-" + region;
|
||||
}
|
||||
|
||||
public Translator getTranslator() {
|
||||
Translator translator = this.translator;
|
||||
if (translator != null)
|
||||
return translator;
|
||||
|
||||
List<Locale> candidateLocales = getCandidateLocales();
|
||||
|
||||
for (Locale candidateLocale : candidateLocales) {
|
||||
String className = DefaultResourceBundleControl.INSTANCE.toBundleName(Translator.class.getSimpleName(), candidateLocale);
|
||||
if (Translator.class.getResource(className + ".class") != null) {
|
||||
try {
|
||||
Class<?> clazz = Class.forName(Translator.class.getPackageName() + "." + className);
|
||||
|
||||
MethodHandle constructor = MethodHandles.publicLookup()
|
||||
.findConstructor(clazz, MethodType.methodType(void.class, SupportedLocale.class));
|
||||
|
||||
return this.translator = (Translator) constructor.invoke(this);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to create instance for " + className, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.translator = new Translator(this);
|
||||
}
|
||||
|
||||
public boolean isSameLanguage(SupportedLocale other) {
|
||||
return LocaleUtils.getRootLanguage(this.getLocale())
|
||||
.equals(LocaleUtils.getRootLanguage(other.getLocale()));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.Locale;
|
||||
|
||||
/// @author Glavo
|
||||
public class Translator {
|
||||
protected final SupportedLocale supportedLocale;
|
||||
protected final Locale locale;
|
||||
|
||||
public Translator(SupportedLocale supportedLocale) {
|
||||
this.supportedLocale = supportedLocale;
|
||||
this.locale = supportedLocale.getLocale();
|
||||
}
|
||||
|
||||
public final SupportedLocale getSupportedLocale() {
|
||||
return supportedLocale;
|
||||
}
|
||||
|
||||
public final Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
return remoteVersion.getSelfVersion();
|
||||
}
|
||||
|
||||
/// @see [#formatDateTime(TemporalAccessor)]
|
||||
protected DateTimeFormatter dateTimeFormatter;
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(supportedLocale.getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
}
|
||||
+19
-9
@@ -15,7 +15,10 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -30,13 +33,16 @@ import java.util.Map;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public final class UpsideDownUtils {
|
||||
public class Translator_en_Qabs extends Translator {
|
||||
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
private static final Map<Integer, Integer> MAPPER = loadMap();
|
||||
|
||||
private static Map<Integer, Integer> loadMap() {
|
||||
var map = new LinkedHashMap<Integer, Integer>();
|
||||
|
||||
InputStream inputStream = UpsideDownUtils.class.getResourceAsStream("/assets/lang/upside_down.txt");
|
||||
InputStream inputStream = Translator_en_Qabs.class.getResourceAsStream("/assets/lang/upside_down.txt");
|
||||
if (inputStream != null) {
|
||||
try (inputStream) {
|
||||
new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
@@ -65,13 +71,17 @@ public final class UpsideDownUtils {
|
||||
return builder.reverse().toString();
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
public Translator_en_Qabs(SupportedLocale locale) {
|
||||
super(locale);
|
||||
}
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
@Override
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
return translate(remoteVersion.getSelfVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
return translate(BASE_FORMATTER.format(time));
|
||||
}
|
||||
|
||||
private UpsideDownUtils() {
|
||||
}
|
||||
}
|
||||
+61
-68
@@ -15,8 +15,11 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -27,10 +30,8 @@ import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class WenyanUtils {
|
||||
/// @author Glavo
|
||||
public class Translator_lzh extends Translator {
|
||||
private static final String DOT = "點";
|
||||
|
||||
private static final String[] NUMBERS = {
|
||||
@@ -79,37 +80,8 @@ public final class WenyanUtils {
|
||||
builder.append(hour % 2 == 0 ? '正' : '初');
|
||||
}
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
LocalDateTime localDateTime;
|
||||
if (time instanceof Instant)
|
||||
localDateTime = ((Instant) time).atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
else
|
||||
localDateTime = LocalDateTime.from(time);
|
||||
|
||||
StringBuilder builder = new StringBuilder(16);
|
||||
|
||||
appendYear(builder, localDateTime.getYear());
|
||||
builder.append('年');
|
||||
builder.append(numberToString(localDateTime.getMonthValue()));
|
||||
builder.append('月');
|
||||
builder.append(numberToString(localDateTime.getDayOfMonth()));
|
||||
builder.append('日');
|
||||
|
||||
builder.append(' ');
|
||||
|
||||
appendHour(builder, localDateTime.getHour());
|
||||
builder.append(numberToString(localDateTime.getMinute()));
|
||||
builder.append('分');
|
||||
builder.append(numberToString(localDateTime.getSecond()));
|
||||
builder.append('秒');
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static String translateGameVersion(GameVersionNumber gameVersion) {
|
||||
if (gameVersion instanceof GameVersionNumber.Release) {
|
||||
var release = (GameVersionNumber.Release) gameVersion;
|
||||
|
||||
if (gameVersion instanceof GameVersionNumber.Release release) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendDigitByDigit(builder, String.valueOf(release.getMajor()));
|
||||
builder.append(DOT);
|
||||
@@ -120,6 +92,7 @@ public final class WenyanUtils {
|
||||
appendDigitByDigit(builder, String.valueOf(release.getPatch()));
|
||||
}
|
||||
|
||||
//noinspection StatementWithEmptyBody
|
||||
if (release.getEaType() == GameVersionNumber.Release.TYPE_GA) {
|
||||
// do nothing
|
||||
} else if (release.getEaType() == GameVersionNumber.Release.TYPE_PRE) {
|
||||
@@ -134,9 +107,7 @@ public final class WenyanUtils {
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
} else if (gameVersion instanceof GameVersionNumber.Snapshot) {
|
||||
var snapshot = (GameVersionNumber.Snapshot) gameVersion;
|
||||
|
||||
} else if (gameVersion instanceof GameVersionNumber.Snapshot snapshot) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
appendDigitByDigit(builder, String.valueOf(snapshot.getYear()));
|
||||
@@ -152,35 +123,20 @@ public final class WenyanUtils {
|
||||
return builder.toString();
|
||||
} else if (gameVersion instanceof GameVersionNumber.Special) {
|
||||
String version = gameVersion.toString();
|
||||
switch (version.toLowerCase(Locale.ROOT)) {
|
||||
case "2.0":
|
||||
return "二點〇";
|
||||
case "2.0_blue":
|
||||
return "二點〇藍";
|
||||
case "2.0_red":
|
||||
return "二點〇赤";
|
||||
case "2.0_purple":
|
||||
return "二點〇紫";
|
||||
case "1.rv-pre1":
|
||||
return "一點真視之預一";
|
||||
case "3d shareware v1.34":
|
||||
return "躍然享件一點三四";
|
||||
case "20w14infinite":
|
||||
case "20w14~":
|
||||
case "20w14∞":
|
||||
return "二〇週一四宇";
|
||||
case "22w13oneblockatatime":
|
||||
return "二二週一三典";
|
||||
case "23w13a_or_b":
|
||||
return "二三週一三暨";
|
||||
case "24w14potato":
|
||||
return "二四週一四芋";
|
||||
case "25w14craftmine":
|
||||
return "二五週一四礦";
|
||||
default:
|
||||
return version;
|
||||
}
|
||||
|
||||
return switch (version.toLowerCase(Locale.ROOT)) {
|
||||
case "2.0" -> "二點〇";
|
||||
case "2.0_blue" -> "二點〇藍";
|
||||
case "2.0_red" -> "二點〇赤";
|
||||
case "2.0_purple" -> "二點〇紫";
|
||||
case "1.rv-pre1" -> "一點真視之預一";
|
||||
case "3d shareware v1.34" -> "躍然享件一點三四";
|
||||
case "20w14infinite", "20w14~", "20w14∞" -> "二〇週一四宇";
|
||||
case "22w13oneblockatatime" -> "二二週一三典";
|
||||
case "23w13a_or_b" -> "二三週一三暨";
|
||||
case "24w14potato" -> "二四週一四芋";
|
||||
case "25w14craftmine" -> "二五週一四礦";
|
||||
default -> version;
|
||||
};
|
||||
} else {
|
||||
return gameVersion.toString();
|
||||
}
|
||||
@@ -211,6 +167,43 @@ public final class WenyanUtils {
|
||||
return version;
|
||||
}
|
||||
|
||||
private WenyanUtils() {
|
||||
public Translator_lzh(SupportedLocale locale) {
|
||||
super(locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
if (remoteVersion instanceof GameRemoteVersion)
|
||||
return translateGameVersion(GameVersionNumber.asGameVersion(remoteVersion.getSelfVersion()));
|
||||
else
|
||||
return translateGenericVersion(remoteVersion.getSelfVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
LocalDateTime localDateTime;
|
||||
if (time instanceof Instant instant)
|
||||
localDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
else
|
||||
localDateTime = LocalDateTime.from(time);
|
||||
|
||||
StringBuilder builder = new StringBuilder(16);
|
||||
|
||||
appendYear(builder, localDateTime.getYear());
|
||||
builder.append('年');
|
||||
builder.append(numberToString(localDateTime.getMonthValue()));
|
||||
builder.append('月');
|
||||
builder.append(numberToString(localDateTime.getDayOfMonth()));
|
||||
builder.append('日');
|
||||
|
||||
builder.append(' ');
|
||||
|
||||
appendHour(builder, localDateTime.getHour());
|
||||
builder.append(numberToString(localDateTime.getMinute()));
|
||||
builder.append('分');
|
||||
builder.append(numberToString(localDateTime.getSecond()));
|
||||
builder.append('秒');
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -68,5 +68,15 @@
|
||||
"title" : "Java Animated PNG",
|
||||
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
|
||||
"externalLink" : "https://github.com/aellerton/japng"
|
||||
},
|
||||
{
|
||||
"title": "Terracotta",
|
||||
"subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.",
|
||||
"externalLink": "https://github.com/burningtnt/Terracotta"
|
||||
},
|
||||
{
|
||||
"title": "EasyTier",
|
||||
"subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy",
|
||||
"externalLink": "https://github.com/EasyTier/EasyTier"
|
||||
}
|
||||
]
|
||||
@@ -293,6 +293,14 @@
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
|
||||
.two-line-list-item > .first-line > .tag-warning {
|
||||
-fx-text-fill: #d34336;;
|
||||
-fx-background-color: #f1aeb5;
|
||||
-fx-padding: 2;
|
||||
-fx-font-weight: normal;
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
|
||||
.two-line-item-second-large {
|
||||
|
||||
}
|
||||
@@ -918,6 +926,10 @@
|
||||
-fx-background-color: derive(-fx-base-color, 60%);
|
||||
}
|
||||
|
||||
.mod-info-list-cell:warning {
|
||||
-fx-background-color: #F8D7DA;
|
||||
}
|
||||
|
||||
.options-sublist {
|
||||
-fx-background-color: white;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -717,10 +717,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=Incompatible with %s
|
||||
install.installer.install=Installing %s
|
||||
install.installer.install_offline=Install/Update from Local File
|
||||
install.installer.install_offline.extension=(Neo)Forge/OptiFine installer
|
||||
install.installer.install_offline.tooltip=We support using the local (Neo)Forge/OptiFine installer.
|
||||
install.installer.install_offline.extension=(Neo)Forge/Cleanroom/OptiFine installer
|
||||
install.installer.install_offline.tooltip=We support using the local (Neo)Forge, Cleanroom, and OptiFine installer.
|
||||
install.installer.install_online=Online Install
|
||||
install.installer.install_online.tooltip=We currently support Forge, NeoForge, OptiFine, Fabric, Quilt, and LiteLoader.
|
||||
install.installer.install_online.tooltip=We currently support Forge, NeoForge, Cleanroom, OptiFine, Fabric, Quilt, and LiteLoader.
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=Not installed
|
||||
install.installer.optifine=OptiFine
|
||||
@@ -1090,8 +1090,10 @@ mods.not_modded=You must install a modloader (Forge, NeoForge, Fabric, Quilt, or
|
||||
mods.restore=Restore
|
||||
mods.url=Official Page
|
||||
mods.update_modpack_mod.warning=Updating mods in a modpack can lead to irreparable results, possibly corrupting the modpack so that it cannot launch. Are you sure you want to update?
|
||||
mods.warning.loader_mismatch=Mod loader mismatch
|
||||
mods.install=Install
|
||||
mods.save_as=Save As
|
||||
mods.unknown=Unknown Mod
|
||||
|
||||
nbt.entries=%s entries
|
||||
nbt.open.failed=Failed to open file
|
||||
@@ -1413,6 +1415,79 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows
|
||||
system.architecture=Architecture
|
||||
system.operating_system=Operating System
|
||||
|
||||
terracotta=Multiplayer
|
||||
terracotta.easytier=About EasyTier
|
||||
terracotta.terracotta=Terracotta | Multiplayer
|
||||
terracotta.status=Lobby
|
||||
terracotta.back=Exit
|
||||
terracotta.feedback.title=Fill Out Feedback Form
|
||||
terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
|
||||
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
|
||||
terracotta.from_local.title=Third-party download channels for Multiplayer Core
|
||||
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
|
||||
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
|
||||
terracotta.from_local.file_name_mismatch=You should download the Multiplayer Core package named %1$s instead of %2$s
|
||||
terracotta.export_log=Exports the Multiplayer Core log
|
||||
terracotta.export_log.desc=Gathering more information for analysis
|
||||
terracotta.status.bootstrap=Gathering information
|
||||
terracotta.status.uninitialized.not_exist=Multiplayer Core: Not Downloaded
|
||||
terracotta.status.uninitialized.not_exist.title=Download Multiplayer Core (~ 8MiB)
|
||||
terracotta.status.uninitialized.update=Multiplayer Core: Update Available
|
||||
terracotta.status.uninitialized.update.title=Update Multiplayer Core (~ 8MiB)
|
||||
terracotta.status.uninitialized.desc=You legally promise to strictly abide by all laws and regulations of your country or region during the multiplayer process.
|
||||
terracotta.confirm.title=User Notice
|
||||
terracotta.confirm.desc=Terracotta is a third-party open source free software. Please provide feedback on any issues you encounter through the relevant channels during use.\n\
|
||||
Terracotta is based on P2P, so that the final experience depends greatly on your network condition.\n\
|
||||
You promise to strictly abide by all laws and regulations in your country or region when playing online.
|
||||
terracotta.status.preparing=Multiplayer Core: Downloading (DO NOT exit HMCL)
|
||||
terracotta.status.launching=Multiplayer Core: Initializing
|
||||
terracotta.status.unknown=Multiplayer Core: Initializing
|
||||
terracotta.status.waiting=Multiplayer Core: Ready
|
||||
terracotta.status.waiting.host.title=I want to host a session
|
||||
terracotta.status.waiting.host.desc=Create a room and generate an invite code to play with friends
|
||||
terracotta.status.waiting.host.launch.title=You seem to have forgotten to launch the game
|
||||
terracotta.status.waiting.host.launch.desc=No running game found
|
||||
terracotta.status.waiting.host.launch.skip=Game has launched
|
||||
terracotta.status.waiting.guest.title=I want to join a session
|
||||
terracotta.status.waiting.guest.desc=Enter the invite code from the host player to join the game world
|
||||
terracotta.status.waiting.guest.prompt.title=Please enter the invite code from the host
|
||||
terracotta.status.waiting.guest.prompt.invalid=Invalid invite code
|
||||
terracotta.status.scanning=Scanning LAN worlds
|
||||
terracotta.status.scanning.desc=Please <a href="hmcl://game/launch">start the game</a>, open a world, press ESC, select "Open to LAN", then select "Start LAN World".
|
||||
terracotta.status.scanning.back=This will also stop scanning LAN worlds.
|
||||
terracotta.status.host_starting=Room Creating
|
||||
terracotta.status.host_starting.back=This will stop creating the room.
|
||||
terracotta.status.host_ok=Room created
|
||||
terracotta.status.host_ok.code=Invitation code (Copied)
|
||||
terracotta.status.host_ok.code.copy=Copy invitation code
|
||||
terracotta.status.host_ok.code.copy.toast=Invitation code has been copied to clipboard
|
||||
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
|
||||
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
|
||||
terracotta.status.guest_starting=Joining room
|
||||
terracotta.status.guest_starting.back=This will not stop other guests from joining the room.
|
||||
terracotta.status.guest_ok=Room Joined
|
||||
terracotta.status.guest_ok.back=This will not stop other guests from joining the room.
|
||||
terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby.
|
||||
terracotta.status.guest_ok.desc=Backup address: %s
|
||||
terracotta.status.exception.back=Please try again
|
||||
terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable
|
||||
terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable
|
||||
terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers
|
||||
terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers
|
||||
terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers
|
||||
terracotta.status.fatal.retry=Retry
|
||||
terracotta.status.fatal.os=Sorry, HMCL cannot enable Terracotta | Multiplayer on your operating system or architecture. Please use a more modern operating system.
|
||||
terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again.
|
||||
terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core.
|
||||
terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core.
|
||||
terracotta.status.fatal.unknown=Fatal Error: Unknown.
|
||||
terracotta.player_list=Player List
|
||||
terracotta.player_anonymous=Anonymous Player
|
||||
terracotta.player_kind.host=Host
|
||||
terracotta.player_kind.local=Yourself
|
||||
terracotta.player_kind.guest=Guest
|
||||
|
||||
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
|
||||
|
||||
update=Update
|
||||
@@ -1443,6 +1518,8 @@ update.note=Beta and Nightly channels may have more features or fixes, but they
|
||||
update.latest=This is the latest version
|
||||
update.no_browser=Cannot open in system browser. But we copied the link to your clipboard, and you can open it manually.
|
||||
update.tooltip=Update
|
||||
update.preview=Preview HMCL releases early
|
||||
update.preview.tooltip=Enable this option to receive new versions of HMCL early for testing before their official release.
|
||||
|
||||
version=Games
|
||||
version.name=Instance Name
|
||||
@@ -1458,6 +1535,8 @@ version.game.release=Release
|
||||
version.game.releases=Releases
|
||||
version.game.snapshot=Snapshot
|
||||
version.game.snapshots=Snapshots
|
||||
version.game.support_status.unsupported=Unsupported
|
||||
version.game.support_status.untested=Untested
|
||||
version.game.type=Type
|
||||
version.launch=Launch Game
|
||||
version.launch.empty=Start Game
|
||||
|
||||
@@ -723,10 +723,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=Incompatible con %s
|
||||
install.installer.install=Instalando %s
|
||||
install.installer.install_offline=Instalar/actualizar desde archivo local
|
||||
install.installer.install_offline.extension=Instalador de (Neo)Forge/OptiFine
|
||||
install.installer.install_offline.tooltip=Apoyamos el uso del instalador local de (Neo)Forge/OptiFine.
|
||||
install.installer.install_offline.extension=Instalador de (Neo)Forge/Cleanroom/OptiFine
|
||||
install.installer.install_offline.tooltip=Apoyamos el uso del instalador local de (Neo)Forge/Cleanroom/OptiFine.
|
||||
install.installer.install_online=Instalación en línea
|
||||
install.installer.install_online.tooltip=Actualmente soportamos Forge, NeoForge, OptiFine, Fabric, Quilt y LiteLoader.
|
||||
install.installer.install_online.tooltip=Actualmente soportamos Forge, NeoForge, Cleanroom, Fabric, Quilt, LiteLoader y OptiFine.
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=No está instalado
|
||||
install.installer.optifine=OptiFine
|
||||
@@ -1444,6 +1444,8 @@ update.note=Advertencia: Las versiones beta y las versiones nocturnas pueden ten
|
||||
update.latest=Esta es la última versión.
|
||||
update.no_browser=No se puede abrir en el navegador del sistema. Pero, hemos copiado el enlace a su portapapeles y puede abrirlo manualmente.
|
||||
update.tooltip=Actualización
|
||||
update.preview=Vista previa de actualizaciones anticipadas
|
||||
update.preview.tooltip=Activa esta opción para recibir nuevas versiones del lanzador antes de su lanzamiento oficial para probarlas
|
||||
|
||||
version=Juegos
|
||||
version.name=Nombre de instancia
|
||||
|
||||
@@ -532,10 +532,10 @@ install.installer.game=礦藝
|
||||
install.installer.incompatible=與 %s 不兼也
|
||||
install.installer.install=裝 %s
|
||||
install.installer.install_offline=取私案而置
|
||||
install.installer.install_offline.extension= (新) 鍛/OptiFine 裝者
|
||||
install.installer.install_offline.tooltip=可錄 (新) 鍛/Optifine 裝者
|
||||
install.installer.install_offline.extension= (新) 鍛/Cleanroom/OptiFine 裝者
|
||||
install.installer.install_offline.tooltip=可錄 (新) 鍛/Cleanroom/OptiFine 裝者
|
||||
install.installer.install_online=網取而置
|
||||
install.installer.install_online.tooltip=可置鍛、新鍛、緞、褥、輕載與 Optifine
|
||||
install.installer.install_online.tooltip=可置鍛、新鍛、Cleanroom、緞、褥、輕載與 OptiFine
|
||||
install.installer.liteloader=輕載
|
||||
install.installer.not_installed=未裝
|
||||
install.installer.optifine=OptiFine
|
||||
|
||||
@@ -714,10 +714,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=Несовместимо с %s
|
||||
install.installer.install=Установить %s
|
||||
install.installer.install_offline=Установить/обновить из локального файла
|
||||
install.installer.install_offline.extension=Установщик (Neo)Forge/OptiFine
|
||||
install.installer.install_offline.tooltip=Поддерживаются установщики (Neo)Forge/OptiFine.
|
||||
install.installer.install_offline.extension=Установщик (Neo)Forge/Cleanroom/OptiFine
|
||||
install.installer.install_offline.tooltip=Поддерживаются установщики (Neo)Forge/Cleanroom/OptiFine.
|
||||
install.installer.install_online=Установить с интернета
|
||||
install.installer.install_online.tooltip=Поддерживаются Forge, NeoForge, OptiFine, Fabric, Quilt и LiteLoader.
|
||||
install.installer.install_online.tooltip=Поддерживаются Forge, NeoForge, Cleanroom, Fabric, Quilt, LiteLoader и OptiFine.
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=Не установлено
|
||||
install.installer.optifine=OptiFine
|
||||
|
||||
@@ -659,10 +659,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=Несумісний з %s
|
||||
install.installer.install=Встановлення %s
|
||||
install.installer.install_offline=Встановити/Оновити з локального файлу
|
||||
install.installer.install_offline.extension=Інсталятор (Neo)Forge/OptiFine
|
||||
install.installer.install_offline.tooltip=Ми підтримуємо використання локального інсталятора (Neo)Forge/OptiFine.
|
||||
install.installer.install_offline.extension=Інсталятор (Neo)Forge/Cleanroom/OptiFine
|
||||
install.installer.install_offline.tooltip=Ми підтримуємо використання локального інсталятора (Neo)Forge/Cleanroom/OptiFine.
|
||||
install.installer.install_online=Онлайн встановлення
|
||||
install.installer.install_online.tooltip=Наразі ми підтримуємо Forge, NeoForge, OptiFine, Fabric, Quilt та LiteLoader.
|
||||
install.installer.install_online.tooltip=Наразі ми підтримуємо Forge, NeoForge, Cleanroom, OptiFine, Fabric, Quilt та LiteLoader.
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=Не встановлено
|
||||
install.installer.optifine=OptiFine
|
||||
|
||||
@@ -524,10 +524,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=與 %s 不相容
|
||||
install.installer.install=安裝 %s
|
||||
install.installer.install_offline=從本機檔案安裝或升級
|
||||
install.installer.install_offline.extension=(Neo)Forge/OptiFine 安裝器
|
||||
install.installer.install_offline.tooltip=支援匯入已經下載好的 (Neo)Forge/OptiFine 安裝器
|
||||
install.installer.install_offline.extension=(Neo)Forge/Cleanroom/OptiFine 安裝器
|
||||
install.installer.install_offline.tooltip=支援匯入已經下載好的 (Neo)Forge、Cleanroom 及 OptiFine 安裝器
|
||||
install.installer.install_online=線上安裝
|
||||
install.installer.install_online.tooltip=支援安裝 Forge、NeoForge、Fabric、Quilt、LiteLoader 和 OptiFine
|
||||
install.installer.install_online.tooltip=支援安裝 Forge、NeoForge、Cleanroom、Fabric、Quilt、LiteLoader 和 OptiFine
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=未安裝
|
||||
install.installer.optifine=OptiFine
|
||||
@@ -538,7 +538,7 @@ install.installer.external_version=%s [由外部安裝的版本,無法解除
|
||||
install.installing=安裝
|
||||
install.modpack=安裝模組包
|
||||
install.modpack.installation=安裝模組包
|
||||
install.name.invalid=名稱中包含特殊字元(如 Emoji 表情或中文字元)。\n建議修改名稱。名稱建議僅包含英文字母、數字和底線,以防啟動遊戲時出現問題。是否繼續安裝?
|
||||
install.name.invalid=名稱中包含特殊字元 (如 Emoji 表情或中文字元)。\n建議修改名稱。名稱建議僅包含英文字母、數字和底線,以防啟動遊戲時出現問題。是否繼續安裝?
|
||||
install.new_game=安裝新實例
|
||||
install.new_game.already_exists=此實例已經存在,請重新命名
|
||||
install.new_game.current_game_version=目前遊戲實例
|
||||
@@ -748,7 +748,7 @@ modpack.wizard.step.3.title=選取模組包匯出類型
|
||||
modpack.wizard.step.initialization.exported_version=要匯出的遊戲實例
|
||||
modpack.wizard.step.initialization.force_update=強制升級模組包至最新版本 (需要自建伺服器)
|
||||
modpack.wizard.step.initialization.include_launcher=包含啟動器
|
||||
modpack.wizard.step.initialization.modrinth.info=在模組包建立過程中,啟動器將匹配 CurseForge/Modrinth 遠端資源替代本機檔案(包括模組、資源包和光影包)以縮減模組包大小,並將副檔名為「.disabled」的檔案標註為「安裝時可選項」。
|
||||
modpack.wizard.step.initialization.modrinth.info=在模組包建立過程中,啟動器將匹配 CurseForge/Modrinth 遠端資源替代本機檔案 (包括模組、資源包和光影包) 以縮減模組包大小,並將副檔名為「.disabled」的檔案標註為「安裝時可選項」。
|
||||
modpack.wizard.step.initialization.no_create_remote_files=不匹配遠端檔案
|
||||
modpack.wizard.step.initialization.save=選取要匯出到的遊戲模組包位置
|
||||
modpack.wizard.step.initialization.skip_curseforge_remote_files=不匹配 CurseForge 遠端資源
|
||||
@@ -887,8 +887,10 @@ mods.not_modded=你需要先在「自動安裝」頁面安裝 Forge、NeoForge
|
||||
mods.restore=回退
|
||||
mods.url=官方頁面
|
||||
mods.update_modpack_mod.warning=更新模組包中的模組可能導致模組包損壞,使模組包無法正常啟動。該操作不可逆,確定要更新嗎?
|
||||
mods.warning.loader_mismatch=模組載入器不匹配
|
||||
mods.install=安裝到目前實例
|
||||
mods.save_as=下載到本機目錄
|
||||
mods.unknown=未知模組
|
||||
|
||||
nbt.entries=%s 個條目
|
||||
nbt.open.failed=開啟檔案失敗
|
||||
@@ -1201,6 +1203,79 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine
|
||||
system.architecture=架構
|
||||
system.operating_system=作業系統
|
||||
|
||||
terracotta=多人遊戲
|
||||
terracotta.easytier=關於 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦聯機
|
||||
terracotta.status=聯機大廳
|
||||
terracotta.back=退出
|
||||
terracotta.feedback.title=填寫回饋表
|
||||
terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
|
||||
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
|
||||
terracotta.from_local.title=線上核心第三方下載管道
|
||||
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
|
||||
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
|
||||
terracotta.from_local.file_name_mismatch=您應該下載名為 %1$s 的線上核心包,而非 %2$s
|
||||
terracotta.export_log=匯出線上核心日誌
|
||||
terracotta.export_log.desc=為分析錯誤提供更多信息
|
||||
terracotta.status.bootstrap=正在收集資訊
|
||||
terracotta.status.uninitialized.not_exist=未下載聯機核心
|
||||
terracotta.status.uninitialized.not_exist.title=下載聯機核心 (約 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新聯機核心
|
||||
terracotta.status.uninitialized.update.title=更新聯機核心 (約 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
|
||||
terracotta.confirm.title=使用者須知
|
||||
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,您在使用過程中所遇到的問題請通過相關管道進行回饋。\n\
|
||||
多人連線基於 p2p,最終線上體驗和您的網路情況有較大關係。\n\
|
||||
您承諾,在多人連線全過程中,您將嚴格遵守您所在國家或地區的全部法律法規。
|
||||
terracotta.status.preparing=正在下載聯機核心 (請勿退出啟動器)
|
||||
terracotta.status.launching=正在初始化聯機核心
|
||||
terracotta.status.unknown=正在初始化聯機核心
|
||||
terracotta.status.waiting=聯機核心已就緒
|
||||
terracotta.status.waiting.host.title=我想當房主
|
||||
terracotta.status.waiting.host.desc=建立房間並產生邀請碼,與好友一起暢玩
|
||||
terracotta.status.waiting.host.launch.title=您似乎忘記啟動遊戲了
|
||||
terracotta.status.waiting.host.launch.desc=未能找到正在執行的遊戲
|
||||
terracotta.status.waiting.host.launch.skip=遊戲已啟動
|
||||
terracotta.status.waiting.guest.title=我想當房客
|
||||
terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲世界
|
||||
terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼
|
||||
terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤
|
||||
terracotta.status.scanning=正在掃描區域網路世界
|
||||
terracotta.status.scanning.desc=請<a href="hmcl://game/launch">啟動遊戲</a>,進入單人存檔,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。
|
||||
terracotta.status.scanning.back=這將同時停止掃描區域網路世界。
|
||||
terracotta.status.host_starting=正在建立房間
|
||||
terracotta.status.host_starting.back=這將會取消建立房間。
|
||||
terracotta.status.host_ok=已建立房間
|
||||
terracotta.status.host_ok.code=邀請碼 (已自動複製到剪貼簿)
|
||||
terracotta.status.host_ok.code.copy=複製邀請碼
|
||||
terracotta.status.host_ok.code.copy.toast=已將邀請碼複製到剪貼簿
|
||||
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
|
||||
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
|
||||
terracotta.status.guest_starting=正在加入房間
|
||||
terracotta.status.guest_starting.back=這不會影響其他房客加入目前房間。
|
||||
terracotta.status.guest_ok=已加入房間
|
||||
terracotta.status.guest_ok.back=這不會影響其他房客加入目前房間。
|
||||
terracotta.status.guest_ok.title=請啟動遊戲,選擇多人遊戲,雙擊進入陶瓦聯機大廳。
|
||||
terracotta.status.guest_ok.desc=備用連線位址:%s
|
||||
terracotta.status.exception.back=可再試一次
|
||||
terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已關閉或網路不穩定
|
||||
terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定
|
||||
terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題
|
||||
terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題
|
||||
terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲存檔,房間已自動關閉
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題
|
||||
terracotta.status.fatal.retry=重試
|
||||
terracotta.status.fatal.os=抱歉,HMCL 不能在您的作業系統或架構上啟用多人連線。請使用更主流的作業系統
|
||||
terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次
|
||||
terracotta.status.fatal.install=嚴重錯誤:無法安裝線上核心
|
||||
terracotta.status.fatal.terracotta=嚴重錯誤:無法與線上核心通訊
|
||||
terracotta.status.fatal.unknown=嚴重錯誤:原因未知
|
||||
terracotta.player_list=玩家列表
|
||||
terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
|
||||
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
|
||||
|
||||
update=啟動器更新
|
||||
@@ -1227,6 +1302,8 @@ update.note=開發版與預覽版包含更多的功能以及錯誤修復,但
|
||||
update.latest=目前版本為最新版本
|
||||
update.no_browser=無法開啟瀏覽器。網址已經複製到剪貼簿了,你可以手動複製網址開啟頁面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前測試 HMCL 預覽版本
|
||||
update.preview.tooltip=啟用此選項,你將可以提前取得 HMCL 的新版本,以便在正式發布前進行測試。
|
||||
|
||||
version=遊戲
|
||||
version.name=遊戲實例名稱
|
||||
@@ -1242,6 +1319,8 @@ version.game.release=正式版
|
||||
version.game.releases=正式版
|
||||
version.game.snapshot=快照
|
||||
version.game.snapshots=快照
|
||||
version.game.support_status.unsupported=不支援
|
||||
version.game.support_status.untested=未經測試
|
||||
version.game.type=版本類型
|
||||
version.launch=啟動遊戲
|
||||
version.launch.empty=開始遊戲
|
||||
|
||||
@@ -534,10 +534,10 @@ install.installer.game=Minecraft
|
||||
install.installer.incompatible=与 %s 不兼容
|
||||
install.installer.install=安装 %s
|
||||
install.installer.install_offline=从本地文件安装/升级
|
||||
install.installer.install_offline.extension=(Neo)Forge/OptiFine 安装器
|
||||
install.installer.install_offline.tooltip=支持导入已经下载好的 (Neo)Forge/OptiFine 安装器
|
||||
install.installer.install_offline.extension=(Neo)Forge/Cleanroom/OptiFine 安装器
|
||||
install.installer.install_offline.tooltip=支持导入已经下载好的 (Neo)Forge、Cleanroom 和 OptiFine 安装器
|
||||
install.installer.install_online=在线安装
|
||||
install.installer.install_online.tooltip=支持安装 Forge、NeoForge、Fabric、Quilt、LiteLoader 和 OptiFine
|
||||
install.installer.install_online.tooltip=支持安装 Forge、NeoForge、Cleanroom、Fabric、Quilt、LiteLoader 和 OptiFine
|
||||
install.installer.liteloader=LiteLoader
|
||||
install.installer.not_installed=未安装
|
||||
install.installer.optifine=OptiFine
|
||||
@@ -548,7 +548,7 @@ install.installer.external_version=%s (由外部安装的版本,无法卸载
|
||||
install.installing=安装
|
||||
install.modpack=安装整合包
|
||||
install.modpack.installation=安装整合包
|
||||
install.name.invalid=名称中包含特殊字符(如 Emoji 表情或中文字符)。\n建议修改名称。名称建议仅包含英文字母、数字和下划线,以防启动游戏时出现问题。是否继续安装?
|
||||
install.name.invalid=名称中包含特殊字符 (如 Emoji 表情或中文字符)。\n建议修改名称。名称建议仅包含英文字母、数字和下划线,以防启动游戏时出现问题。是否继续安装?
|
||||
install.new_game=安装新游戏
|
||||
install.new_game.already_exists=此实例已经存在,请换一个名字
|
||||
install.new_game.current_game_version=当前游戏实例
|
||||
@@ -758,7 +758,7 @@ modpack.wizard.step.3.title=选择整合包导出类型
|
||||
modpack.wizard.step.initialization.exported_version=要导出的游戏实例
|
||||
modpack.wizard.step.initialization.force_update=强制升级整合包至最新版本 (需要自建服务器)
|
||||
modpack.wizard.step.initialization.include_launcher=包含启动器
|
||||
modpack.wizard.step.initialization.modrinth.info=在整合包创建过程中,启动器将匹配 CurseForge/Modrinth 远程资源替代本地文件(包括模组、资源包和光影包)以缩减整合包体积,并将扩展名为“.disabled”的文件标注为“安装时可选项”。
|
||||
modpack.wizard.step.initialization.modrinth.info=在整合包创建过程中,启动器将匹配 CurseForge/Modrinth 远程资源替代本地文件 (包括模组、资源包和光影包) 以缩减整合包体积,并将扩展名为“.disabled”的文件标注为“安装时可选项”。
|
||||
modpack.wizard.step.initialization.no_create_remote_files=不匹配远程文件
|
||||
modpack.wizard.step.initialization.save=选择要导出到的游戏整合包位置
|
||||
modpack.wizard.step.initialization.skip_curseforge_remote_files=不匹配 CurseForge 远程资源
|
||||
@@ -897,8 +897,10 @@ mods.not_modded=你需要先在“自动安装”页面安装 Forge、NeoForge
|
||||
mods.restore=回退
|
||||
mods.url=官方页面
|
||||
mods.update_modpack_mod.warning=更新整合包中的模组可能导致整合包损坏,使整合包无法正常启动。该操作不可逆,确定要更新吗?
|
||||
mods.warning.loader_mismatch=模组加载器不匹配
|
||||
mods.install=安装到当前实例
|
||||
mods.save_as=下载到本地文件夹
|
||||
mods.unknown=未知模组
|
||||
|
||||
nbt.entries=%s 个条目
|
||||
nbt.open.failed=打开文件失败
|
||||
@@ -1211,6 +1213,79 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代
|
||||
system.architecture=架构
|
||||
system.operating_system=操作系统
|
||||
|
||||
terracotta=多人联机
|
||||
terracotta.easytier=关于 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦联机
|
||||
terracotta.status=联机大厅
|
||||
terracotta.back=退出
|
||||
terracotta.feedback.title=填写反馈表
|
||||
terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
|
||||
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
|
||||
terracotta.from_local.title=联机核心第三方下载渠道
|
||||
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
|
||||
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
|
||||
terracotta.from_local.file_name_mismatch=您应当下载名为 %1$s 的联机核心包,而非 %2$s
|
||||
terracotta.export_log=导出联机核心日志
|
||||
terracotta.export_log.desc=为分析错误提供更多信息
|
||||
terracotta.status.bootstrap=正在收集信息
|
||||
terracotta.status.uninitialized.not_exist=未下载联机核心
|
||||
terracotta.status.uninitialized.not_exist.title=下载联机核心 (约 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新联机核心
|
||||
terracotta.status.uninitialized.update.title=更新联机核心 (约 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
|
||||
terracotta.confirm.title=用户须知
|
||||
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,您在使用过程中所遇到的问题请通过相关渠道进行反馈。\n\
|
||||
多人联机基于 p2p,最终联机体验和您的网络情况有较大关系。\n\
|
||||
在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规。
|
||||
terracotta.status.preparing=正在下载联机核心 (请勿退出启动器)
|
||||
terracotta.status.launching=正在初始化联机核心
|
||||
terracotta.status.unknown=正在初始化联机核心
|
||||
terracotta.status.waiting=联机核心已就绪
|
||||
terracotta.status.waiting.host.title=我想当房主
|
||||
terracotta.status.waiting.host.desc=创建房间并生成邀请码,与好友一起畅玩
|
||||
terracotta.status.waiting.host.launch.title=您似乎忘记启动游戏了
|
||||
terracotta.status.waiting.host.launch.desc=未能找到正在运行的游戏
|
||||
terracotta.status.waiting.host.launch.skip=游戏已启动
|
||||
terracotta.status.waiting.guest.title=我想当房客
|
||||
terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏世界
|
||||
terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码
|
||||
terracotta.status.waiting.guest.prompt.invalid=邀请码错误
|
||||
terracotta.status.scanning=正在扫描局域网世界
|
||||
terracotta.status.scanning.desc=请<a href="hmcl://game/launch">启动游戏</a>,进入单人存档,按下 ESC 键,选择对局域网开放,点击创建局域网世界。
|
||||
terracotta.status.scanning.back=这将同时停止扫描局域网世界。
|
||||
terracotta.status.host_starting=正在启动房间
|
||||
terracotta.status.host_starting.back=这将会取消创建房间。
|
||||
terracotta.status.host_ok=已启动房间
|
||||
terracotta.status.host_ok.code=邀请码 (已自动复制到剪贴板)
|
||||
terracotta.status.host_ok.code.copy=复制邀请码
|
||||
terracotta.status.host_ok.code.copy.toast=已将邀请码复制到剪贴板
|
||||
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
|
||||
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
|
||||
terracotta.status.guest_starting=正在加入房间
|
||||
terracotta.status.guest_starting.back=这不会影响其他房客加入当前房间。
|
||||
terracotta.status.guest_ok=已加入房间
|
||||
terracotta.status.guest_ok.back=这不会影响其他房客加入当前房间。
|
||||
terracotta.status.guest_ok.title=请启动游戏,选择多人游戏,双击进入陶瓦联机大厅。
|
||||
terracotta.status.guest_ok.desc=备用联机地址:%s
|
||||
terracotta.status.exception.back=可再试一次
|
||||
terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已关闭或网络不稳定
|
||||
terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定
|
||||
terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||
terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||
terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏存档,房间已自动关闭
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题
|
||||
terracotta.status.fatal.retry=重试
|
||||
terracotta.status.fatal.os=抱歉,HMCL 不能在您的操作系统或架构上启用多人联机。请使用更主流的操作系统
|
||||
terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次
|
||||
terracotta.status.fatal.install=严重错误:无法安装联机核心
|
||||
terracotta.status.fatal.terracotta=严重错误:无法与联机核心通讯
|
||||
terracotta.status.fatal.unknown=严重错误:原因未知
|
||||
terracotta.player_list=玩家列表
|
||||
terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
|
||||
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
|
||||
|
||||
update=启动器更新
|
||||
@@ -1237,6 +1312,8 @@ update.note=开发版与预览版包含更多的功能以及错误修复,但
|
||||
update.latest=当前版本为最新版本
|
||||
update.no_browser=无法打开浏览器。网址已经复制到剪贴板,你可以手动粘贴网址打开页面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前预览 HMCL 版本
|
||||
update.preview.tooltip=启用此选项,你将可以提前获取 HMCL 的新版本,以便在正式发布前进行测试。
|
||||
|
||||
version=游戏
|
||||
version.name=游戏实例名称
|
||||
@@ -1252,6 +1329,8 @@ version.game.release=正式版
|
||||
version.game.releases=正式版
|
||||
version.game.snapshot=快照
|
||||
version.game.snapshots=快照
|
||||
version.game.support_status.unsupported=不支持
|
||||
version.game.support_status.untested=未经测试
|
||||
version.game.type=版本类型
|
||||
version.launch=启动游戏
|
||||
version.launch.empty=开始游戏
|
||||
|
||||
@@ -100,7 +100,7 @@ craftguide;106;craftguide;G键合成表;CraftGuide;
|
||||
;107;mobdictionary;生物图鉴;Mob Dictionary;
|
||||
biomes-o-plenty;108;biomesoplenty;超多生物群系;Biomes O' Plenty;BOP
|
||||
;109;BambooMod;竹;Bamboo;
|
||||
;110;tdmg;立体机动装置(Ibemu版);立体機動装置MOD / Three-Dimensional Maneuver Gear;TDMG
|
||||
;110;tdmg;立体机动装置 (Ibemu版);立体機動装置MOD / Three-Dimensional Maneuver Gear;TDMG
|
||||
enhanced-portals-3;111;enhancedportals;增强传送门3;Enhanced Portals 3;
|
||||
compact-solars;112;compactsolars,CompactSolars;压缩太阳能阵列;CompactSolars / Compact Solar Arrays;
|
||||
bibliocraft;113;BiblioCraft,bibliocraft;收藏馆;BiblioCraft;
|
||||
@@ -662,7 +662,7 @@ compact-machines;712;compactmachines3,cm2,compactmachines,CompactMachines;更多
|
||||
ex-nihilo-omnia;714;exnihiloomnia;无中生有-世间万物;Ex Nihilo Omnia;
|
||||
last-sword-you-will-ever-need-mo;715;theonlychaosmodforyou;最终之剑;The Last Sword You Will Ever Need Mod;
|
||||
the-last-sword-you-will-ever-need-reboot;716;tlsywen;最终之剑:重生;The Last Sword You Will Ever Need Reboot;
|
||||
ftb-library-legacy-forge;718;ftblib,FTBL,ftbl;FTB Library(旧版);FTB Library (Forge) (Legacy);FTBL
|
||||
ftb-library-legacy-forge;718;ftblib,FTBL,ftbl;FTB Library (旧版);FTB Library (Forge) (Legacy);FTBL
|
||||
;719;InventoryScan;库存扫描;InventoryScan;IS
|
||||
;720;godzillamod;哥斯拉;Godzilla;
|
||||
thaumic-potatoes-2;721;thaumicpotatoes;神秘土豆2;Thaumic Potatoes 2;TP2
|
||||
@@ -684,7 +684,7 @@ aether-aspects;737;AetherAspects;天境要素扫描;Aether Aspects;
|
||||
arcane-arteries;738;arcanearteries;奥法献祭学;Arcane Arteries;
|
||||
thaumic-palmistry;739;thaumicpalmistry;Thaumic Palmistry;;
|
||||
one-click-crafting;740;oneclickcrafting;一键合成;One click crafting;OCC
|
||||
essential-thaumaturgy-unofficial;741;;源质神秘学(非官方版);Essential Thaumaturgy Unofficial;
|
||||
essential-thaumaturgy-unofficial;741;;源质神秘学 (非官方版);Essential Thaumaturgy Unofficial;
|
||||
wireless-crafting-terminal;742;wct,ae2wct;无线合成终端;Wireless Crafting Terminal;WCT
|
||||
waila-plugins;743;wailaplugins;Waila 插件;Waila Plugins;WP
|
||||
appleskin;744;appleskin;苹果皮;AppleSkin;AS
|
||||
@@ -704,7 +704,7 @@ hammer-lib;756;hammercore,hammerlib;锤子核心;Hammer Core/Hammer Lib;
|
||||
thermal-casting;758;ThermalCasting;热力铸造室;Thermal Casting;
|
||||
thermal-smeltery-redux;759;;热力冶炼厂重置版;Thermal Smeltery Redux;
|
||||
brcore;760;BRCore;BRCore;;
|
||||
industrial-expansion-te-addon;761;industrialexpansion;工业扩充(TE附属);Industrial Expansion [TE Addon];
|
||||
industrial-expansion-te-addon;761;industrialexpansion;工业扩充 (TE附属);Industrial Expansion [TE Addon];
|
||||
;762;gtveinlocator,detravscannermod;格雷科技矿脉定位器;GTVeinLocator;
|
||||
;763;gtneioreplugin;格雷矿物NEI插件;GTNEIOrePlugin;GTNOP
|
||||
spmodapi;764;spmodapi;SpmodAPI;;
|
||||
@@ -2219,7 +2219,7 @@ lollipop;2328;lollipop;棒棒糖;Lollipop;
|
||||
enemyz;2329;enemyz;敌意显示;Enemyz;
|
||||
thaumic-restoration;2330;thaumicrestoration;神秘复辟;Thaumic Restoration;
|
||||
;2331;vimi;连锁矿工数据包;Vein Miner Datapack;
|
||||
;2332;;快速落叶(伪);Fast Leaf Decay;
|
||||
;2332;;快速落叶 (伪);Fast Leaf Decay;
|
||||
primal-boat;2333;primalboat;原始船舶;Primal Boat;
|
||||
eyes-in-the-darkness;2334;eyesinthedarkness;Eyes in the Darkness;;
|
||||
thaumic-augmentation;2335;thaumicaugmentation;神秘进阶;Thaumic Augmentation;TA
|
||||
@@ -2910,7 +2910,7 @@ riddle-chests;3041;riddlechests;谜题箱;Riddle Chests;
|
||||
;3042;cinfinitelib;CinfiniteLib;;CiL
|
||||
totem-plus;3043;;图腾增强;Totem Plus;
|
||||
blockmeterfabric;3044;blockmeter;方块计量表;Block Meter Fabric;
|
||||
;3045;kamenrider3dmod;假面骑士(3D版);KamenRider3D;KR3D
|
||||
;3045;kamenrider3dmod;假面骑士 (3D版);KamenRider3D;KR3D
|
||||
fluid-drawers;3046;fluiddrawers;储液抽屉;Fluid Drawers;
|
||||
creepypastacraft-reborn;3047;creepypastacraft;蠕动意面重置版;CreepyPastaCraft Reborn;
|
||||
itemzoom;3048;itemzoom;物品缩放;ItemZoom;
|
||||
@@ -3324,7 +3324,7 @@ eidolon;3469;eidolon;幻梦;Eidolon;
|
||||
rats-ratlantis;3470;ratlantis;老鼠:鼠西洲;Rats: Ratlantis;
|
||||
wthit;3471;wthit;What The Hell Is That;;WTHIT
|
||||
modern-industrialization;3472;modern_industrialization;现代工业化;Modern Industrialization;MI
|
||||
chunk-pregenerator-fabric;3473;chunkpregen;区块预生成器(Fabric);Fabric Chunk Pregenerator;
|
||||
chunk-pregenerator-fabric;3473;chunkpregen;区块预生成器 (Fabric);Fabric Chunk Pregenerator;
|
||||
jumploader;3474;jumploader;Jumploader;;
|
||||
unloader;3475;unloader;Unloader;;
|
||||
better-animal-models;3476;betteranimals;更真实的动物;Better animal models;
|
||||
@@ -3920,7 +3920,7 @@ gender;4082;gender;Gender & Age Mod;;
|
||||
;4083;mtm;Magical Talismans Mod;;
|
||||
ticktock;4084;servertick;Server Tick;;
|
||||
;4085;synthesis_formula_added;行尸走肉弹夹合成配方添加;;
|
||||
fps-reducre-for-fabric;4086;;FPS减速器(Fabric);FPS Reducer (For Fabric);
|
||||
fps-reducre-for-fabric;4086;;FPS减速器 (Fabric);FPS Reducer (For Fabric);
|
||||
art-artillery;4087;art_artillery,aa2;艺术?火炮!;Art? Artillery!;AA
|
||||
minemenufabric;4088;modmenu;我的菜单Fabric版;MineMenuFabric;
|
||||
;4089;tabtps-fabric;TabTPS;;
|
||||
@@ -4061,7 +4061,7 @@ firmalife;4226;firmalife;FirmaLife;;
|
||||
supermartijn642s-core-lib;4227;supermartijn642corelib;SuperMartijn642's Core Lib;;
|
||||
cherished-worlds;4228;cherishedworlds;存档置顶;Cherished Worlds;
|
||||
ironfist;4229;IronFist;铁拳;Ironfist;
|
||||
;4230;mining_dimension_cave_version;挖矿维度(矿洞版);Mining dimension(cave version);
|
||||
;4230;mining_dimension_cave_version;挖矿维度 (矿洞版);Mining dimension(cave version);
|
||||
simply-acceleration;4231;simplyacceleration;简单加速;Simply Acceleration;
|
||||
creeper-habitats;4232;creeperhabitats;Creeper Habitats;;
|
||||
husbandry;4233;husbandry;Husbandry;;
|
||||
@@ -4269,9 +4269,9 @@ dungeons-mobs;4436;dungeons_mobs;地下城生物;Dungeons Mobs;
|
||||
;4439;meteorrandomidea;流年年的随想作;MeteorRandomIdea;
|
||||
;4440;fortyyears;四十年;Miyazaki Hayao Forty Years;
|
||||
;4441;flycycle;Flycycle;;
|
||||
;4442;civilizationswars;三体工业(TeaCon预览版);Civilizations Wars;
|
||||
;4442;civilizationswars;三体工业 (TeaCon预览版);Civilizations Wars;
|
||||
;4443;king_and_knight;明日方舟;King and knight;
|
||||
;4444;ashihara;苇原(芝之章~百里乡野行纪);Ashihara;ASHR
|
||||
;4444;ashihara;苇原 (芝之章~百里乡野行纪);Ashihara;ASHR
|
||||
uusi-aurinko;4445;uusi-aurinko;新日;Uusi Aurinko;
|
||||
;4446;serializableconsciousness;Serializable Consciousness;;SeriCons
|
||||
;4448;elytra_booster;推进鞘翅;Elytra Booster;
|
||||
@@ -4332,7 +4332,7 @@ capsule;4503;capsule;Capsule;;
|
||||
isometric-renders;4504;isometric-renders;等轴渲染;Isometric Renders;IR
|
||||
;4505;;Abstract's Recraft;;
|
||||
enchanting-secretaire;4506;enchantnote;附魔记录;Enchanting Secretaire;
|
||||
sync-fabric;4507;sync;克隆(Fabric);Sync (Fabric);
|
||||
sync-fabric;4507;sync;克隆 (Fabric);Sync (Fabric);
|
||||
tale-of-kingdoms-a-new-conquest;4508;taleofkingdoms;王国:新征程;Tale of Kingdoms: A new Conquest;
|
||||
;4509;networktitle;网络窗口标题;NetworkTitle;
|
||||
schwarz;4510;schwarz;黑;Schwarz;
|
||||
@@ -6502,7 +6502,7 @@ uncle-js;6698;;Uncle J's;;
|
||||
wither-bones;6699;wither_bones;凋灵之骨;Wither Bones;
|
||||
sleep-warp;6700;sleepwarp;SleepWarp;;
|
||||
diagonal-fences;6701;diagonalfences;Diagonal Fences;;DF
|
||||
jumpy-boats-fabric;6702;jumpyboat;跳跃的船(Fabric/Quilt);Jumpy Boats(Fabric/Quilt);
|
||||
jumpy-boats-fabric;6702;jumpyboat;跳跃的船 (Fabric/Quilt);Jumpy Boats(Fabric/Quilt);
|
||||
dyeable-redstone-signal;6703;dyeable_redstone_signal;染色红石信号;Dyeable Redstone Signal;DRS
|
||||
velocity-based-damage;6704;veldmg;Velocity Based Damage;;
|
||||
no-telemetry;6705;no-telemetry;No Telemetry;;
|
||||
@@ -7925,8 +7925,8 @@ fallout-wastelands;8133;fallout_wastelands;Fallout Wastelands;;
|
||||
;8136;letitsnow;Let It Snow;;
|
||||
dons-lightning-rod-mod;8137;dlr;Don's Lightning Rod;;
|
||||
rainglow;8138;rainglow;Rainglow;;
|
||||
nethers-delight-fabric;8139;nethersdelight;下界乐事(Fabric 版);Nether's Delight (Fabric);
|
||||
quicksand-neoforge;8140;quicksand;流沙(NeoForge);Quicksand (NeoForge);
|
||||
nethers-delight-fabric;8139;nethersdelight;下界乐事 (Fabric 版);Nether's Delight (Fabric);
|
||||
quicksand-neoforge;8140;quicksand;流沙 (NeoForge);Quicksand (NeoForge);
|
||||
axolotl-bucket-fix-forge;8141;axolotlbucketfix;美西螈桶修复 Forge 版;Axolotl Bucket Fix (Forge);
|
||||
scape-and-run-parasites-survival-addon-by-nocube;8142;nocubessrpsurvival;Scape and Run: Parasites Survival Addon;;
|
||||
scape-and-run-parasites-nests-addon-by-nocube;8143;nocubessrpnests;Scape and Run: Parasites Nests Addon;;
|
||||
@@ -8587,7 +8587,7 @@ vanity-metallurgical;8803;vanity_metallurgical;Vanity: Metallurgical;;
|
||||
minespaceex;8804;minespaceex;MineSpaceEX;;MSEX
|
||||
gregious-maximus;8805;gregiousmaximus;Gregious Maximus;;
|
||||
shut-up-model-loader;8806;shutupmodelloader;Shut Up, Model Loader!;;SUML
|
||||
cultural-delights-fabric;8807;culturaldelights;多元乐事(Fabric 版);Cultural Delights [Fabric];
|
||||
cultural-delights-fabric;8807;culturaldelights;多元乐事 (Fabric 版);Cultural Delights [Fabric];
|
||||
collectors-reap;8808;collectorsreap;Collector's Reap;;
|
||||
bones-and-swords;8809;bonesandswords;Bones And Swords;;
|
||||
vanity-shedds-mystical-toolsets-pack;8810;sheddmersopal;Vanity: Shedd's Mystical Toolsets Pack;;
|
||||
@@ -8669,7 +8669,7 @@ waymaker;8888;waymaker;Waystone Waypoint Maker;;
|
||||
;8889;mixingasm;Mixingasm;;
|
||||
dmod;8890;dmod;D-Mod;;
|
||||
extra-redstone-craft;8891;extracraft;更多红石合成;Extra Redstone Craft;EXRS
|
||||
farmers-respite-fabric;8892;farmersrespite;农夫暇事(Fabric 版);Farmer's Respite (Fabric);
|
||||
farmers-respite-fabric;8892;farmersrespite;农夫暇事 (Fabric 版);Farmer's Respite (Fabric);
|
||||
wizards;8893;wizards;魔法师;Wizards;
|
||||
basketcase;8894;basketcase;Basketcase;;
|
||||
primal-lib;8895;primallib;Primal Lib;;
|
||||
@@ -9027,7 +9027,7 @@ universal-enchants-forge;9252;universalenchants;通用附魔;Universal Enchants;
|
||||
aa-farmer-compat;9253;farmeradditions;AA Farmer Compat;;
|
||||
;9254;sctz_ywsj;只要听话就不会死!;;
|
||||
;9255;configurable-firework-explosion;可配置的烟花爆炸;Configurable Firework Explosion;CFE
|
||||
;9256;;内修炼体(属性加点);;
|
||||
;9256;;内修炼体 (属性加点);;
|
||||
better-crates;9257;bettercrates;Better Crates;;
|
||||
vanillaicecreamfix;9259;vanillaicecreamfix;VanillaIcecreamFix;;
|
||||
actually-unbreaking-forge;9260;actuallyunbreaking;Actually Unbreaking;;
|
||||
@@ -9235,7 +9235,7 @@ cute-hermit-crabs;9466;cutehermitcrabs;Cute Hermit Crabs;;
|
||||
;9467;ulib;uLib;;
|
||||
receiver-mod;9468;rm;Receiver Gun Mod;;
|
||||
chargepads;9469;ChargePads;充电座;ChargePads;
|
||||
snow-real-magic-potato-edition;9470;;雪!真实的魔法!(土豆版);Snow Real Magic Potato Edition;
|
||||
snow-real-magic-potato-edition;9470;;雪!真实的魔法!(土豆版);Snow Real Magic Potato Edition;
|
||||
;9471;depthstrider;Depth Strider;;
|
||||
ravenous-void;9472;rv;Ravenous Void;;
|
||||
immersive-engineering-blueprint-tweaker;9473;iebpt;Immersive Engineering Blueprint Tweaker;;IEBPT
|
||||
@@ -9434,7 +9434,7 @@ splatcraft;9665;splatcraft;斯普拉遁;Splatcraft;
|
||||
beneath-the-snow;9666;beneath_thesnow_mr;Beneath The Snow;;
|
||||
sea-creeps;9667;aquacreepermod;Sea Creeps;;
|
||||
skills-v;9668;skillsvanilla;技能:香草;Skills: Vanilla;
|
||||
;9669;teleportation;瞬身术(Fabric);Teleportation (Fabric);
|
||||
;9669;teleportation;瞬身术 (Fabric);Teleportation (Fabric);
|
||||
flower-foxes;9670;flower_foxes;Flower Foxes!;;
|
||||
pack-it-up;9671;pack_it_up;Pack It Up!;;
|
||||
mine-trading-cards-open-source-edition;9672;is_mtc;Mine Trading Cards Open Source Edition;;
|
||||
@@ -11746,7 +11746,7 @@ stairdoors-extended;11999;stairdoors;StairDoors Extended;;
|
||||
into-the-rend;12000;therenddimension;Into The Rendv / The Rend Dimension;;
|
||||
buff-frog-mod;12001;buff_frog_mod;Buff Frogs;;
|
||||
asphere;12002;asphere;Asphere;;
|
||||
autoclick-machine;12003;clickmachine;自动连点器(Fabric);Click Machine (Fabric);
|
||||
autoclick-machine;12003;clickmachine;自动连点器 (Fabric);Click Machine (Fabric);
|
||||
harvestcraft-tweaker;12004;harvestcrafttweaker;HarvestCraft Tweaker;;
|
||||
the-legend-of-the-mudman;12005;legend_of_the_mudman;The Legend of the Mudman;;
|
||||
mutagenesis;12006;mutagenesis;Mutagenesis;;
|
||||
@@ -11842,7 +11842,7 @@ the-digimod-beta;12095;thedigimod;方块数码宝贝;The Digimod;
|
||||
ex-deorum;12096;exdeorum;无中生有:神赐;Ex Deorum;
|
||||
entity-detectors;12097;entitydetectors;Entity Detectors;;
|
||||
;12098;horsearmorstandmod;Horse Armor Stand;;
|
||||
decorative-blocks-fork;12099;decorative_blocks;装饰方块(分支版);Decorative Blocks (Fork);
|
||||
decorative-blocks-fork;12099;decorative_blocks;装饰方块 (分支版);Decorative Blocks (Fork);
|
||||
above-and-below;12100;above_and_below;Above and Below;;
|
||||
;12101;ppcore;PPcore;;
|
||||
freeze-it-and-heat-it;12102;fiahi;冷汗:食物冷冻与加热;Freeze It, and Heat It!;FIAHI
|
||||
@@ -11974,10 +11974,10 @@ rustic-furniture-compat;12227;rusticcompat;Rustic Furniture Compat;;
|
||||
;12228;norealmsbutton;Realms按钮移除;No Realms Button;
|
||||
;12229;benchantments;BEnchantments;;
|
||||
epic-fight-indestructible;12230;indestructible;坚不可摧;Epic Fight - Indestructible;
|
||||
;12231;set_bonus;Set Bonus(TeamPancake);;
|
||||
;12232;chat_bar_time_display;聊天栏时间显示(TeamPancake 版);When Was That Again(TeamPancke);
|
||||
;12231;set_bonus;Set Bonus (TeamPancake);;
|
||||
;12232;chat_bar_time_display;聊天栏时间显示 (TeamPancake 版);When Was That Again(TeamPancke);
|
||||
;12233;;逃离塔块夫-重铸;Escape From Tarcraft Reforged;EFTR
|
||||
;12234;;右键文本清除(TeamPancake 版);Right Click Clear(TeamPancake);
|
||||
;12234;;右键文本清除 (TeamPancake 版);Right Click Clear(TeamPancake);
|
||||
catwalks-llc;12235;catwalksinc;Catwalks LLC.;;
|
||||
divine-quest-rpg;12236;tales_of_sunnrya,divine_quest;Tales of Sunnrya / Divine Quest RPG;;
|
||||
;12237;carpet-org-addition;Carpet Org Addition;;
|
||||
@@ -12021,7 +12021,7 @@ rusticwitchcraft;12274;rusticwitchcraft;Rustic Witchcraft;;
|
||||
;12275;unif.logger;UniF-Logger;;
|
||||
sterling-and-black;12276;sterlingblack;Sterling and Black;;
|
||||
the-devourer-of-skulls-datapack;12277;;The Devourer of Skulls 『𝔹𝕠𝕤𝕤 𝔽𝕚𝕘𝕙𝕥』;;
|
||||
;12278;birds_nests;鸟巢(TeamPancake 版);Birds Nests(Team Pancake);
|
||||
;12278;birds_nests;鸟巢 (TeamPancake 版);Birds Nests(Team Pancake);
|
||||
boss-and-magic-raid-compat;12279;;Boss And Magic: Raid Compat;;
|
||||
animated-mobs;12280;animatedmobsmod;Boss And Magic / AME's Mobs / Animated Mobs;;
|
||||
;12281;GTQTCore;量子跃迁:纠缠核心;GTQTcore;GTC
|
||||
@@ -13701,12 +13701,12 @@ the-super-god-road;13962;TheSuperGodRoad;超神之路;TheSuperGodRoad;SGR
|
||||
;13965;exblades;ExBlades;;
|
||||
storage-delight-forge;13966;storagedelight;存储乐事;Storage Delight;
|
||||
lightshield;13967;lightshield;光之护盾;LightShield;LShield
|
||||
sophisticated-core-unofficial-fabric-port;13968;;精妙核心(非官方 Fabric 移植);Sophisticated Core (Unofficial Fabric port);
|
||||
sophisticated-backpacks-unofficial-fabric-port;13969;;精妙背包(非官方 Fabric 分支);Sophisticated Backpacks (Unofficial Fabric port);
|
||||
sophisticated-core-unofficial-fabric-port;13968;;精妙核心 (非官方 Fabric 移植);Sophisticated Core (Unofficial Fabric port);
|
||||
sophisticated-backpacks-unofficial-fabric-port;13969;;精妙背包 (非官方 Fabric 分支);Sophisticated Backpacks (Unofficial Fabric port);
|
||||
red-power;13970;red_power;Red Power;;
|
||||
;13971;hdykwd;你怎么知道我做了什么;How do you know what I done;HDYKWD
|
||||
;13972;minihudextra;MiniHUD Extra;;MHEx
|
||||
sophisticated-storage-unofficial-fabric-port;13973;;精妙存储(非官方 Fabric 分支);Sophisticated Storage (Unofficial Fabric port);
|
||||
sophisticated-storage-unofficial-fabric-port;13973;;精妙存储 (非官方 Fabric 分支);Sophisticated Storage (Unofficial Fabric port);
|
||||
;13974;oldblockdrops,oldoredropsforge;Old Ore Drops;;
|
||||
rage-mod;13975;rage;兴怒;Rage Mod;
|
||||
;13976;disableallstructers;Disable All Structures;;
|
||||
@@ -14500,7 +14500,7 @@ the-rings-of-power;14766;trop;力量之戒/魔戒众戒;The Rings of Power;TRoP
|
||||
suspicious-zombification;14770;suszombification;Suspicious Zombification;;
|
||||
;14771;wmis;Where Is My Stuff-Forge;;WIMSF
|
||||
ivtoolkit-arcana-rpg-continuation-edition;14772;IvToolkit;IvToolkit:延续;IvToolkit Arcana Rpg Continuation Edition;
|
||||
farmer-s-delight-bedrock-unofficial;14773;FarmersDelightBedrockUnofficial;农夫乐事基岩版(Unofficial);Farmer's Delight Bedrock (Unofficial);
|
||||
farmer-s-delight-bedrock-unofficial;14773;FarmersDelightBedrockUnofficial;农夫乐事基岩版 (Unofficial);Farmer's Delight Bedrock (Unofficial);
|
||||
;14774;byzh-picgen;byzh's 麦块画图;BYZH's Picgen;
|
||||
tropicraft-continuation;14775;tropicraft;热带世界:延续;Tropicraft Continuation;
|
||||
battle-tower-continuation;14776;AS_BattleTowers;战斗高塔:延续;Battle Tower Continuation;
|
||||
@@ -14676,9 +14676,9 @@ skeletalband;14947;skeletalband;骷髅乐队乐谱;SkeletalBand;
|
||||
alexs-caves-entropy;14948;entropy;Alex 的洞穴:熵;Alex's Caves:Entropy;ACE
|
||||
mekanism-explosives;14949;mekanismexplosives;通用机械:炸药;Mekanism Explosives;
|
||||
;14950;minimalcoordshud;Minimal Coords HUD;;
|
||||
;14951;;存储乐事基岩版(Unofficial);Storage Delight Bedrock (Unofficial);
|
||||
;14952;;蟹农乐事基岩版(Unofficial);Crabber's Delight Bedrock (Unofficial);
|
||||
;14953;;末地乐事基岩版(Unofficial);End's Delight Bedrock (Unofficial);
|
||||
;14951;;存储乐事基岩版 (Unofficial);Storage Delight Bedrock (Unofficial);
|
||||
;14952;;蟹农乐事基岩版 (Unofficial);Crabber's Delight Bedrock (Unofficial);
|
||||
;14953;;末地乐事基岩版 (Unofficial);End's Delight Bedrock (Unofficial);
|
||||
avaritiareborn;14954;avaritia;无尽贪婪:复兴;AvaritiaReborn;
|
||||
cherry-shrimp;14955;cherry_shrimp;Cherry Shrimp;;
|
||||
pick-command;14956;pickcommand;俺拾的指令;Pick Command;PiC
|
||||
@@ -18132,7 +18132,7 @@ deimos-fabric-forge-neoforge;18436;deimos;Deimos;;
|
||||
excavation;18438;excavation;挖掘;Excavation;
|
||||
basic-end-ores;18439;beo;基础末地矿石;Basic End Ores;BEO
|
||||
;18440;portable_crafting;Portable Crafting;;
|
||||
;18441;gofish;一起钓鱼吧!(Forge 版);Go Fish Forge;
|
||||
;18441;gofish;一起钓鱼吧!(Forge 版);Go Fish Forge;
|
||||
simple-bbq-unofficial;18442;simplebbq;简易烧烤非官方版;Simple BBQ Unofficial;
|
||||
;18443;carystanley_extremefarming;Extreme Farming;;
|
||||
eggy-goodness;18444;EggyGoodness;Eggy Goodness;;
|
||||
@@ -18332,7 +18332,7 @@ auto-pillar-generator;18637;apg;一键柱子生成;Auto Pillar Generator;APG
|
||||
vanilla-delight;18638;vanilladelight;Vanilla Delight;;
|
||||
jade-vs;18639;jade_vs;Jade VS;;
|
||||
;18640;custom_ender_dragon;Draconic Ascension - Custom Ender Dragon Fight;;
|
||||
vics-modern-warfare-patched;18641;;维克的现代战争(修复版);Vic's Modern Warfare (Patched);VMW
|
||||
vics-modern-warfare-patched;18641;;维克的现代战争 (修复版);Vic's Modern Warfare (Patched);VMW
|
||||
waterframesbackported;18642;waterframes;WaterFramesBackported;;
|
||||
;18643;sihywtcamc-unofficial-port;So I heard you were talking crap about Minecraft's combat? - 非官方移植;sihywtcamc unofficial port;sihywtcamc
|
||||
tweakeroo-update-port;18644;tweakeroo;Tweakeroo Update Port;;
|
||||
@@ -18777,8 +18777,8 @@ exporbrecall;19084;exporbrecall;经验球召回;ExpOrbRecall;
|
||||
functional-economy;19085;functional_economy;功能性钱币;FunctionalEconomy;FE
|
||||
ranckors-rings-of-power;19086;the_rings_of_power;Ranckor的力量之戒;Ranckor's Rings of Power;
|
||||
lotr-improvements-lite;19087;lotrimprovementslite;魔戒改进:轻量版;LOTR Improvements Lite;
|
||||
;19088;;海洋乐事基岩版(Unofficial);Ocean's Delight Bedrock (Unofficial);
|
||||
;19089;;幽匿乐事基岩版(unofficial);Silent's Delight Bedrock (Unofficial);
|
||||
;19088;;海洋乐事基岩版 (Unofficial);Ocean's Delight Bedrock (Unofficial);
|
||||
;19089;;幽匿乐事基岩版 (unofficial);Silent's Delight Bedrock (Unofficial);
|
||||
astounding;19090;astounding;Astounding;;AUD
|
||||
carpets-core;19091;carpetscore;Carpets Core;;
|
||||
carpet-variants;19092;carpetvariants;Carpet Variants;;
|
||||
@@ -19206,7 +19206,7 @@ baublesreforked;19516;baublesreforked;饰品重铸;BaublesReforked;
|
||||
tmrv;19518;tmrv;TooManyRecipeViewers;;TMRV
|
||||
;19519;codmodern;COD击杀信息显示;COD Modern Warfare Information;CMW
|
||||
twilight-treehouses;19520;twilight_treehouses;Twilight Treehouses;;
|
||||
improved-rails;19521;irails;更好的铁轨(可转弯の动力铁轨!);Improved Rail (TURNABLE POWERED RAILS);IRail
|
||||
improved-rails;19521;irails;更好的铁轨 (可转弯の动力铁轨!);Improved Rail (TURNABLE POWERED RAILS);IRail
|
||||
redstone-signal-bind-wand;19522;wlrt;非Create版无线红石;Redstone Signal Bind Wand;RSBW
|
||||
;19523;mr_custom_packetaggregates;定制数据包集合体;Custom packet aggregates;CPA
|
||||
;19524;trator;游戏内聊天翻译;In game Translator;IGTL
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|10)",
|
||||
"version_recent": [
|
||||
"0.3.10"
|
||||
],
|
||||
"version_latest": "0.3.11",
|
||||
"classifiers": {
|
||||
"linux-arm64": "sha256:cd321f22b200b212ba21eb7582bfe0e1bcadb528d19c70307a2aea9596118d33",
|
||||
"linux-x86_64": "sha256:d4b15ec7cba7c9332b84f6acbf247e779543f958f9b240e9707bd6f37973e12a",
|
||||
"macos-arm64": "sha256:8456ad2c016e7c5c6f5814d98203a89ec38d13cdaafe94d328e313404ea16a2f",
|
||||
"macos-arm64.pkg": "sha256:f85f86c563d4460ba97ed048fee22fa0e1398454443576c56b0afeebb98e8691",
|
||||
"macos-x86_64": "sha256:cb1f7f5d5876a9177c426bece5ad0845e1b25d3430533646b2b9c93d97d37d79",
|
||||
"macos-x86_64.pkg": "sha256:e54bc35be7c8c543362b1b428e7d40d2116f1a4e57ade1c09cbef6239efb67ae",
|
||||
"windows-arm64.exe": "sha256:40c628bafb48d51c90f47037f8c4caedfadb52421e64b70011f448d1d2f553a3",
|
||||
"windows-x86_64.exe": "sha256:de518b2b4e60336fd272092326799966efc4e72acd65576d83e61d4d242fd61d"
|
||||
},
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||
],
|
||||
"downloads_CN": [
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"desc": {
|
||||
"default": "GitHub Release",
|
||||
"zh": "GitHub 发布页",
|
||||
"zh-Hant": "GitHub 發布頁"
|
||||
},
|
||||
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
|
||||
},
|
||||
{
|
||||
"desc": {
|
||||
"default": "Tencent QQ Group",
|
||||
"zh": "QQ 群",
|
||||
"zh-Hant": "QQ 群"
|
||||
},
|
||||
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-12
@@ -15,7 +15,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
package org.jackhuang.hmcl.util.i18n.translator;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -26,33 +26,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class WenyanUtilsTest {
|
||||
public final class TranslatorTest {
|
||||
|
||||
private static void assertYearToString(String value, int year) {
|
||||
//region lzh
|
||||
|
||||
private static void assertYearToLZH(String value, int year) {
|
||||
StringBuilder builder = new StringBuilder(2);
|
||||
WenyanUtils.appendYear(builder, year);
|
||||
Translator_lzh.appendYear(builder, year);
|
||||
assertEquals(value, builder.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testYearToString() {
|
||||
assertYearToString("甲子", 1984);
|
||||
assertYearToString("乙巳", 2025);
|
||||
assertYearToString("甲子", -2996);
|
||||
assertYearToString("庚子", 1000);
|
||||
public void testYearToLZH() {
|
||||
assertYearToLZH("甲子", 1984);
|
||||
assertYearToLZH("乙巳", 2025);
|
||||
assertYearToLZH("甲子", -2996);
|
||||
assertYearToLZH("庚子", 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHourToString() {
|
||||
|
||||
public void testHourToLZH() {
|
||||
List<String> list = List.of(
|
||||
"子正", "丑初", "丑正", "寅初", "寅正", "卯初", "卯正", "辰初", "辰正", "巳初", "巳正", "午初",
|
||||
"午正", "未初", "未正", "申初", "申正", "酉初", "酉正", "戌初", "戌正", "亥初", "亥正", "子初"
|
||||
);
|
||||
for (int hour = 0; hour < list.size(); hour++) {
|
||||
StringBuilder builder = new StringBuilder(2);
|
||||
WenyanUtils.appendHour(builder, hour);
|
||||
Translator_lzh.appendHour(builder, hour);
|
||||
assertEquals(list.get(hour), builder.toString());
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.download;
|
||||
|
||||
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeInstallTask;
|
||||
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
|
||||
import org.jackhuang.hmcl.download.game.GameDownloadTask;
|
||||
@@ -182,6 +183,11 @@ public class DefaultDependencyManager extends AbstractDependencyManager {
|
||||
|
||||
return Task
|
||||
.composeAsync(() -> {
|
||||
try {
|
||||
return CleanroomInstallTask.install(this, oldVersion, installer);
|
||||
} catch (IOException ignore) {
|
||||
}
|
||||
|
||||
try {
|
||||
return NeoForgeInstallTask.install(this, oldVersion, installer);
|
||||
} catch (IOException ignore) {
|
||||
|
||||
@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.download;
|
||||
import org.intellij.lang.annotations.Language;
|
||||
import org.jackhuang.hmcl.game.*;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionRange;
|
||||
@@ -172,6 +171,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
|
||||
String mainClass = resolvedVersion.getMainClass();
|
||||
return mainClass != null && (LAUNCH_WRAPPER_MAIN.equals(mainClass)
|
||||
|| mainClass.startsWith("net.minecraftforge")
|
||||
|| mainClass.startsWith("net.neoforged")
|
||||
|| mainClass.startsWith("top.outlands") //Cleanroom
|
||||
|| mainClass.startsWith("net.fabricmc")
|
||||
|| mainClass.startsWith("org.quiltmc")
|
||||
@@ -361,14 +361,16 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
|
||||
public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher";
|
||||
public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||
public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap";
|
||||
public static final String NEO_FORGED_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client";
|
||||
|
||||
public static final Set<String> FORGE_OPTIFINE_MAIN = new HashSet<>(Lang.immutableListOf(
|
||||
public static final Set<String> FORGE_OPTIFINE_MAIN = Set.of(
|
||||
LibraryAnalyzer.VANILLA_MAIN,
|
||||
LibraryAnalyzer.LAUNCH_WRAPPER_MAIN,
|
||||
LibraryAnalyzer.MOD_LAUNCHER_MAIN,
|
||||
LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN,
|
||||
LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN
|
||||
));
|
||||
LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN,
|
||||
LibraryAnalyzer.NEO_FORGED_BOOTSTRAP_MAIN
|
||||
);
|
||||
|
||||
public static final VersionRange<VersionNumber> FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50");
|
||||
|
||||
|
||||
+55
-10
@@ -21,30 +21,48 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.download.UnsupportedInstallationException;
|
||||
import org.jackhuang.hmcl.download.VersionMismatchException;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile;
|
||||
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
|
||||
import org.jackhuang.hmcl.game.Version;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class CleanroomInstallTask extends Task<Version> {
|
||||
|
||||
private final DefaultDependencyManager dependencyManager;
|
||||
private final Version version;
|
||||
private Path installer;
|
||||
private final CleanroomRemoteVersion remote;
|
||||
private Path installer;
|
||||
private FileDownloadTask dependent;
|
||||
private Task<Version> task;
|
||||
private String selfVersion;
|
||||
|
||||
public CleanroomInstallTask(DefaultDependencyManager dependencyManager, Version version, CleanroomRemoteVersion remoteVersion) {
|
||||
this.dependencyManager = dependencyManager;
|
||||
this.version = version;
|
||||
this.remote = remoteVersion;
|
||||
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
public CleanroomInstallTask(DefaultDependencyManager dependencyManager, Version version, String selfVersion, Path installer) {
|
||||
this.dependencyManager = dependencyManager;
|
||||
this.version = version;
|
||||
this.selfVersion = selfVersion;
|
||||
this.remote = null;
|
||||
this.installer = installer;
|
||||
|
||||
setSignificance(TaskSignificance.MODERATE);
|
||||
}
|
||||
|
||||
@@ -55,14 +73,16 @@ public final class CleanroomInstallTask extends Task<Version> {
|
||||
|
||||
@Override
|
||||
public void preExecute() throws Exception {
|
||||
installer = Files.createTempFile("cleanroom-installer", ".jar");
|
||||
if (installer == null) {
|
||||
installer = Files.createTempFile("cleanroom-installer", ".jar");
|
||||
|
||||
dependent = new FileDownloadTask(
|
||||
dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()),
|
||||
installer, null);
|
||||
dependent.setCacheRepository(dependencyManager.getCacheRepository());
|
||||
dependent.setCaching(true);
|
||||
dependent.addIntegrityCheckHandler(FileDownloadTask.ZIP_INTEGRITY_CHECK_HANDLER);
|
||||
dependent = new FileDownloadTask(
|
||||
dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()),
|
||||
installer, null);
|
||||
dependent.setCacheRepository(dependencyManager.getCacheRepository());
|
||||
dependent.setCaching(true);
|
||||
dependent.addIntegrityCheckHandler(FileDownloadTask.ZIP_INTEGRITY_CHECK_HANDLER);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -78,7 +98,7 @@ public final class CleanroomInstallTask extends Task<Version> {
|
||||
|
||||
@Override
|
||||
public Collection<Task<?>> getDependents() {
|
||||
return Collections.singleton(dependent);
|
||||
return dependent == null ? Collections.emptySet() : Collections.singleton(dependent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -88,6 +108,31 @@ public final class CleanroomInstallTask extends Task<Version> {
|
||||
|
||||
@Override
|
||||
public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException {
|
||||
task = new ForgeNewInstallTask(dependencyManager, version, remote.getSelfVersion(), installer).thenApplyAsync((version) -> version.setId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId()));
|
||||
if (selfVersion == null) {
|
||||
task = new ForgeNewInstallTask(dependencyManager, version, remote.getSelfVersion(), installer).thenApplyAsync((version) -> version.setId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId()));
|
||||
} else {
|
||||
task = new ForgeNewInstallTask(dependencyManager, version, selfVersion, installer).thenApplyAsync((version) -> version.setId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId()));
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<Version> install(DefaultDependencyManager dependencyManager, Version version, Path installer) throws IOException, VersionMismatchException {
|
||||
Optional<String> gameVersion = dependencyManager.getGameRepository().getGameVersion(version);
|
||||
if (gameVersion.isEmpty()) throw new IOException();
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) {
|
||||
String installProfileText = Files.readString(fs.getPath("install_profile.json"));
|
||||
Map<?, ?> installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class);
|
||||
if (LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) {
|
||||
ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class);
|
||||
if (!gameVersion.get().equals(profile.getMinecraft()))
|
||||
throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get());
|
||||
return new CleanroomInstallTask(dependencyManager, version, modifyVersion(profile.getVersion()), installer);
|
||||
} else {
|
||||
throw new IOException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String modifyVersion(String version) {
|
||||
return version.replace("cleanroom-", "");
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,11 @@ public class DefaultGameRepository implements GameRepository {
|
||||
return getVersionRoot(id).resolve("natives-" + platform);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getModsDirectory(String id) {
|
||||
return getRunDirectory(id).resolve("mods");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getVersionRoot(String id) {
|
||||
return getBaseDirectory().resolve("versions/" + id);
|
||||
|
||||
@@ -131,6 +131,12 @@ public interface GameRepository extends VersionProvider {
|
||||
*/
|
||||
Path getNativeDirectory(String id, Platform platform);
|
||||
|
||||
/// Get the directory for placing mod files.
|
||||
///
|
||||
/// @param id instance id
|
||||
/// @return the mods directory
|
||||
Path getModsDirectory(String id);
|
||||
|
||||
/**
|
||||
* Get minecraft jar
|
||||
*
|
||||
|
||||
@@ -189,7 +189,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
|
||||
|
||||
@Override
|
||||
public int compareTo(LocalModFile o) {
|
||||
return getFileName().compareTo(o.getFileName());
|
||||
return getFileName().compareToIgnoreCase(o.getFileName());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.mod;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.download.LibraryAnalyzer;
|
||||
import org.jackhuang.hmcl.game.GameRepository;
|
||||
import org.jackhuang.hmcl.mod.modinfo.*;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
@@ -25,39 +26,46 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ModManager {
|
||||
@FunctionalInterface
|
||||
private interface ModMetadataReader {
|
||||
LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException;
|
||||
}
|
||||
|
||||
private static final Map<String, Pair<ModMetadataReader[], String>> READERS;
|
||||
private static final Map<String, List<Pair<ModMetadataReader, ModLoaderType>>> READERS;
|
||||
|
||||
static {
|
||||
TreeMap<String, Pair<ModMetadataReader[], String>> readers = new TreeMap<>();
|
||||
readers.put("zip", Pair.pair(new ModMetadataReader[]{
|
||||
ForgeOldModMetadata::fromFile,
|
||||
ForgeNewModMetadata::fromFile,
|
||||
FabricModMetadata::fromFile,
|
||||
QuiltModMetadata::fromFile,
|
||||
PackMcMeta::fromFile,
|
||||
}, ""));
|
||||
readers.put("jar", readers.get("zip"));
|
||||
readers.put("litemod", Pair.pair(new ModMetadataReader[]{
|
||||
LiteModMetadata::fromFile
|
||||
}, "LiteLoader Mod"));
|
||||
READERS = Collections.unmodifiableMap(readers);
|
||||
var map = new HashMap<String, List<Pair<ModMetadataReader, ModLoaderType>>>();
|
||||
var zipReaders = List.<Pair<ModMetadataReader, ModLoaderType>>of(
|
||||
pair(ForgeNewModMetadata::fromForgeFile, ModLoaderType.FORGE),
|
||||
pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGED),
|
||||
pair(ForgeOldModMetadata::fromFile, ModLoaderType.FORGE),
|
||||
pair(FabricModMetadata::fromFile, ModLoaderType.FABRIC),
|
||||
pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT),
|
||||
pair(PackMcMeta::fromFile, ModLoaderType.PACK)
|
||||
);
|
||||
|
||||
map.put("zip", zipReaders);
|
||||
map.put("jar", zipReaders);
|
||||
map.put("litemod", List.of(pair(LiteModMetadata::fromFile, ModLoaderType.LITE_LOADER)));
|
||||
|
||||
READERS = map;
|
||||
}
|
||||
|
||||
private final GameRepository repository;
|
||||
private final String id;
|
||||
private final TreeSet<LocalModFile> localModFiles = new TreeSet<>();
|
||||
private final HashMap<LocalMod, LocalMod> localMods = new HashMap<>();
|
||||
private final HashMap<Pair<String, ModLoaderType>, LocalMod> localMods = new HashMap<>();
|
||||
private LibraryAnalyzer analyzer;
|
||||
|
||||
private boolean loaded = false;
|
||||
|
||||
@@ -70,57 +78,104 @@ public final class ModManager {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
public String getInstanceId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Path getModsDirectory() {
|
||||
return repository.getRunDirectory(id).resolve("mods");
|
||||
return repository.getModsDirectory(id);
|
||||
}
|
||||
|
||||
public LocalMod getLocalMod(String id, ModLoaderType modLoaderType) {
|
||||
return localMods.computeIfAbsent(new LocalMod(id, modLoaderType), x -> x);
|
||||
public LibraryAnalyzer getLibraryAnalyzer() {
|
||||
return analyzer;
|
||||
}
|
||||
|
||||
public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) {
|
||||
return localMods.computeIfAbsent(pair(modId, modLoaderType),
|
||||
x -> new LocalMod(x.getKey(), x.getValue()));
|
||||
}
|
||||
|
||||
public boolean hasMod(String modId, ModLoaderType modLoaderType) {
|
||||
return localMods.containsKey(pair(modId, modLoaderType));
|
||||
}
|
||||
|
||||
private void addModInfo(Path file) {
|
||||
try {
|
||||
LocalModFile localModFile = getModInfo(file);
|
||||
if (!localModFile.isOld()) {
|
||||
localModFiles.add(localModFile);
|
||||
}
|
||||
} catch (IllegalArgumentException ignore) {
|
||||
}
|
||||
}
|
||||
|
||||
public LocalModFile getModInfo(Path modFile) {
|
||||
String fileName = StringUtils.removeSuffix(FileUtils.getName(modFile), DISABLED_EXTENSION, OLD_EXTENSION);
|
||||
String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
|
||||
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
|
||||
Pair<ModMetadataReader[], String> currentReader = READERS.get(extension);
|
||||
if (currentReader == null) {
|
||||
throw new IllegalArgumentException("File " + modFile + " is not a mod file.");
|
||||
|
||||
List<Pair<ModMetadataReader, ModLoaderType>> readersMap = READERS.get(extension);
|
||||
if (readersMap == null) {
|
||||
// Is not a mod file.
|
||||
return;
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modFile)) {
|
||||
for (ModMetadataReader reader : currentReader.getKey()) {
|
||||
Set<ModLoaderType> modLoaderTypes = analyzer.getModLoaders();
|
||||
|
||||
var supportedReaders = new ArrayList<ModMetadataReader>();
|
||||
var unsupportedReaders = new ArrayList<ModMetadataReader>();
|
||||
|
||||
for (Pair<ModMetadataReader, ModLoaderType> reader : readersMap) {
|
||||
if (modLoaderTypes.contains(reader.getValue())) {
|
||||
supportedReaders.add(reader.getKey());
|
||||
} else {
|
||||
unsupportedReaders.add(reader.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
LocalModFile modInfo = null;
|
||||
|
||||
List<Exception> exceptions = new ArrayList<>();
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file)) {
|
||||
for (ModMetadataReader reader : supportedReaders) {
|
||||
try {
|
||||
return reader.fromFile(this, modFile, fs);
|
||||
} catch (Exception ignore) {
|
||||
modInfo = reader.fromFile(this, file, fs);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
exceptions.add(e);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
|
||||
if (modInfo == null) {
|
||||
for (ModMetadataReader reader : unsupportedReaders) {
|
||||
try {
|
||||
modInfo = reader.fromFile(this, file, fs);
|
||||
break;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to open mod file " + file, e);
|
||||
}
|
||||
|
||||
return new LocalModFile(this,
|
||||
getLocalMod(FileUtils.getNameWithoutExtension(modFile), ModLoaderType.UNKNOWN),
|
||||
modFile,
|
||||
FileUtils.getNameWithoutExtension(modFile),
|
||||
new LocalModFile.Description(currentReader.getValue())
|
||||
);
|
||||
if (modInfo == null) {
|
||||
Exception exception = new Exception("Failed to read mod metadata");
|
||||
for (Exception e : exceptions) {
|
||||
exception.addSuppressed(e);
|
||||
}
|
||||
LOG.warning("Failed to read mod metadata", exception);
|
||||
|
||||
String fileNameWithoutExtension = FileUtils.getNameWithoutExtension(file);
|
||||
|
||||
modInfo = new LocalModFile(this,
|
||||
getLocalMod(fileNameWithoutExtension, ModLoaderType.UNKNOWN),
|
||||
file,
|
||||
fileNameWithoutExtension,
|
||||
new LocalModFile.Description("litemod".equals(extension) ? "LiteLoader Mod" : "")
|
||||
);
|
||||
}
|
||||
|
||||
if (!modInfo.isOld()) {
|
||||
localModFiles.add(modInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshMods() throws IOException {
|
||||
localModFiles.clear();
|
||||
localMods.clear();
|
||||
|
||||
analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null);
|
||||
|
||||
if (Files.isDirectory(getModsDirectory())) {
|
||||
try (DirectoryStream<Path> modsDirectoryStream = Files.newDirectoryStream(getModsDirectory())) {
|
||||
for (Path subitem : modsDirectoryStream) {
|
||||
@@ -140,10 +195,10 @@ public final class ModManager {
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
public Collection<LocalModFile> getMods() throws IOException {
|
||||
public @Unmodifiable List<LocalModFile> getMods() throws IOException {
|
||||
if (!loaded)
|
||||
refreshMods();
|
||||
return localModFiles;
|
||||
return List.copyOf(localModFiles);
|
||||
}
|
||||
|
||||
public void addMod(Path file) throws IOException {
|
||||
|
||||
@@ -138,7 +138,7 @@ public final class CurseCompletionTask extends Task<Void> {
|
||||
.collect(Collectors.toList()));
|
||||
JsonUtils.writeToJsonFile(root.resolve("manifest.json"), newManifest);
|
||||
|
||||
Path versionRoot = repository.getVersionRoot(modManager.getVersion());
|
||||
Path versionRoot = repository.getVersionRoot(modManager.getInstanceId());
|
||||
Path resourcePacksRoot = versionRoot.resolve("resourcepacks");
|
||||
Path shaderPacksRoot = versionRoot.resolve("shaderpacks");
|
||||
finished.set(0);
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
|
||||
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
|
||||
|
||||
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
|
||||
String lowerCaseResult = remoteMod.getTitle().toLowerCase();
|
||||
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
|
||||
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
|
||||
|
||||
for (String s : StringUtils.tokenize(lowerCaseResult)) {
|
||||
|
||||
@@ -273,7 +273,7 @@ public class McbbsModpackCompletionTask extends CompletableFutureTask<Void> {
|
||||
@Nullable
|
||||
private Path getFilePath(McbbsModpackManifest.File file) {
|
||||
if (file instanceof McbbsModpackManifest.AddonFile) {
|
||||
return modManager.getRepository().getRunDirectory(modManager.getVersion()).resolve(((McbbsModpackManifest.AddonFile) file).getPath());
|
||||
return modManager.getRepository().getRunDirectory(modManager.getInstanceId()).resolve(((McbbsModpackManifest.AddonFile) file).getPath());
|
||||
} else if (file instanceof McbbsModpackManifest.CurseFile) {
|
||||
String fileName = ((McbbsModpackManifest.CurseFile) file).getFileName();
|
||||
if (fileName == null) return null;
|
||||
|
||||
@@ -6,12 +6,15 @@ import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.jar.Attributes;
|
||||
@@ -111,25 +114,45 @@ public final class ForgeNewModMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
private static final int ACC_FORGE = 0x01;
|
||||
public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
|
||||
return fromFile(modManager, modFile, fs, ModLoaderType.FORGE);
|
||||
}
|
||||
|
||||
private static final int ACC_NEO_FORGED = 0x02;
|
||||
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException {
|
||||
return fromFile(modManager, modFile, fs, ModLoaderType.NEO_FORGED);
|
||||
}
|
||||
|
||||
private static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
|
||||
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) {
|
||||
throw new IOException("Invalid mod loader: " + modLoaderType);
|
||||
}
|
||||
|
||||
if (modLoaderType == ModLoaderType.NEO_FORGED) {
|
||||
try {
|
||||
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, fs);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static LocalModFile fromFile(ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
try {
|
||||
return fromFile0("META-INF/mods.toml", ACC_FORGE | ACC_NEO_FORGED, ModLoaderType.FORGE, modManager, modFile, fs);
|
||||
return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, fs);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
return fromFile0("META-INF/neoforge.mods.toml", ACC_NEO_FORGED, ModLoaderType.NEO_FORGED, modManager, modFile, fs);
|
||||
return fromEmbeddedMod(modManager, modFile, fs, modLoaderType);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod.");
|
||||
}
|
||||
|
||||
private static LocalModFile fromFile0(String tomlPath, int loaderACC, ModLoaderType defaultLoader, ModManager modManager, Path modFile, FileSystem fs) throws IOException, JsonParseException {
|
||||
private static LocalModFile fromFile0(
|
||||
String tomlPath,
|
||||
ModLoaderType modLoaderType,
|
||||
ModManager modManager,
|
||||
Path modFile,
|
||||
FileSystem fs) throws IOException, JsonParseException {
|
||||
Path modToml = fs.getPath(tomlPath);
|
||||
if (Files.notExists(modToml))
|
||||
throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod.");
|
||||
@@ -149,7 +172,7 @@ public final class ForgeNewModMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
ModLoaderType type = analyzeLoader(toml, mod.getModId(), loaderACC, defaultLoader);
|
||||
ModLoaderType type = analyzeLoader(toml, mod.getModId(), modLoaderType);
|
||||
|
||||
return new LocalModFile(modManager, modManager.getLocalMod(mod.getModId(), type), modFile, mod.getDisplayName(), new LocalModFile.Description(mod.getDescription()),
|
||||
mod.getAuthors(), jarVersion == null ? mod.getVersion() : mod.getVersion().replace("${file.jarVersion}", jarVersion), "",
|
||||
@@ -157,31 +180,131 @@ public final class ForgeNewModMetadata {
|
||||
metadata.getLogoFile());
|
||||
}
|
||||
|
||||
private static ModLoaderType analyzeLoader(Toml toml, String modID, int loaderACC, ModLoaderType defaultLoader) throws IOException {
|
||||
private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, FileSystem fs, ModLoaderType modLoaderType) throws IOException {
|
||||
Path manifestFile = fs.getPath("META-INF/MANIFEST.MF");
|
||||
if (!Files.isRegularFile(manifestFile))
|
||||
throw new IOException("Missing MANIFEST.MF in file " + manifestFile);
|
||||
|
||||
Manifest manifest;
|
||||
try (InputStream input = Files.newInputStream(manifestFile)) {
|
||||
manifest = new Manifest(input);
|
||||
}
|
||||
|
||||
List<Path> embeddedModFiles = List.of();
|
||||
|
||||
String embeddedDependenciesMod = manifest.getMainAttributes().getValue("Embedded-Dependencies-Mod");
|
||||
if (embeddedDependenciesMod != null) {
|
||||
Path embeddedModFile = fs.getPath(embeddedDependenciesMod);
|
||||
if (!Files.isRegularFile(embeddedModFile)) {
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + embeddedModFile);
|
||||
throw new IOException();
|
||||
}
|
||||
embeddedModFiles = List.of(embeddedModFile);
|
||||
} else {
|
||||
Path jarInJarMetadata = fs.getPath("META-INF/jarjar/metadata.json");
|
||||
if (Files.isRegularFile(jarInJarMetadata)) {
|
||||
JarInJarMetadata metadata = JsonUtils.fromJsonFile(jarInJarMetadata, JarInJarMetadata.class);
|
||||
if (metadata == null)
|
||||
throw new IOException("Invalid metadata file: " + jarInJarMetadata);
|
||||
|
||||
metadata.validate();
|
||||
|
||||
embeddedModFiles = new ArrayList<>();
|
||||
for (EmbeddedJarMetadata jar : metadata.jars) {
|
||||
Path path = fs.getPath(jar.path);
|
||||
if (Files.isRegularFile(path)) {
|
||||
embeddedModFiles.add(path);
|
||||
} else {
|
||||
LOG.warning("Missing embedded-dependencies-mod: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (embeddedModFiles.isEmpty()) {
|
||||
throw new IOException("Missing embedded mods");
|
||||
}
|
||||
|
||||
Path tempFile = Files.createTempFile("hmcl-", ".zip");
|
||||
try {
|
||||
for (Path embeddedModFile : embeddedModFiles) {
|
||||
Files.copy(embeddedModFile, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (FileSystem embeddedFs = CompressingUtils.createReadOnlyZipFileSystem(tempFile)) {
|
||||
return fromFile(modManager, modFile, embeddedFs, modLoaderType);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
|
||||
throw new IOException();
|
||||
}
|
||||
|
||||
private static ModLoaderType analyzeLoader(Toml toml, String modID, ModLoaderType loader) throws IOException {
|
||||
List<HashMap<String, Object>> dependencies = toml.getList("dependencies." + modID);
|
||||
if (dependencies == null) {
|
||||
dependencies = toml.getList("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]]
|
||||
try {
|
||||
dependencies = toml.getList("dependencies"); // ??? I have no idea why some of the Forge mods use [[dependencies]]
|
||||
} catch (ClassCastException e) {
|
||||
try {
|
||||
Toml table = toml.getTable("dependencies");
|
||||
if (table == null)
|
||||
return loader;
|
||||
|
||||
dependencies = table.getList(modID);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (dependencies == null) {
|
||||
return defaultLoader;
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
|
||||
ModLoaderType result = null;
|
||||
loop:
|
||||
for (HashMap<String, Object> dependency : dependencies) {
|
||||
switch ((String) dependency.get("modId")) {
|
||||
case "forge": return checkLoaderACC(loaderACC, ACC_FORGE, ModLoaderType.FORGE);
|
||||
case "neoforge": return checkLoaderACC(loaderACC, ACC_NEO_FORGED, ModLoaderType.NEO_FORGED);
|
||||
case "forge":
|
||||
result = ModLoaderType.FORGE;
|
||||
break loop;
|
||||
case "neoforge":
|
||||
result = ModLoaderType.NEO_FORGED;
|
||||
break loop;
|
||||
}
|
||||
}
|
||||
|
||||
// ??? I have no idea why some of the Forge mods doesn't provide this key.
|
||||
return defaultLoader;
|
||||
}
|
||||
|
||||
private static ModLoaderType checkLoaderACC(int current, int target, ModLoaderType res) throws IOException {
|
||||
if ((target & current) != 0) {
|
||||
return res;
|
||||
} else {
|
||||
throw new IOException("Mismatched loader.");
|
||||
if (result == loader)
|
||||
return result;
|
||||
else if (result != null)
|
||||
throw new IOException("Loader mismatch");
|
||||
else {
|
||||
LOG.warning("Cannot determine the mod loader for mod " + modID + ", expected " + loader);
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
private record JarInJarMetadata(List<EmbeddedJarMetadata> jars) implements Validation {
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
Validation.requireNonNull(jars, "jars");
|
||||
for (EmbeddedJarMetadata jar : jars) {
|
||||
jar.validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable
|
||||
private record EmbeddedJarMetadata(
|
||||
String path,
|
||||
boolean isObfuscated
|
||||
) implements Validation {
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
Validation.requireNonNull(path, "path");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,6 +19,8 @@ package org.jackhuang.hmcl.mod.modinfo;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import org.jackhuang.hmcl.mod.LocalModFile;
|
||||
import org.jackhuang.hmcl.mod.ModLoaderType;
|
||||
import org.jackhuang.hmcl.mod.ModManager;
|
||||
@@ -125,9 +127,27 @@ public final class ForgeOldModMetadata {
|
||||
Path mcmod = fs.getPath("mcmod.info");
|
||||
if (Files.notExists(mcmod))
|
||||
throw new IOException("File " + modFile + " is not a Forge mod.");
|
||||
List<ForgeOldModMetadata> modList = JsonUtils.fromJsonFile(mcmod, listTypeOf(ForgeOldModMetadata.class));
|
||||
|
||||
List<ForgeOldModMetadata> modList;
|
||||
|
||||
try (var reader = Files.newBufferedReader(mcmod);
|
||||
var jsonReader = new JsonReader(reader)) {
|
||||
JsonToken firstToken = jsonReader.peek();
|
||||
|
||||
if (firstToken == JsonToken.BEGIN_ARRAY)
|
||||
modList = JsonUtils.GSON.fromJson(jsonReader, listTypeOf(ForgeOldModMetadata.class));
|
||||
else if (firstToken == JsonToken.BEGIN_OBJECT) {
|
||||
ForgeOldModMetadataLst list = JsonUtils.GSON.fromJson(jsonReader, ForgeOldModMetadataLst.class);
|
||||
if (list == null)
|
||||
throw new IOException("Mod " + modFile + " `mcmod.info` is malformed");
|
||||
modList = list.modList();
|
||||
} else {
|
||||
throw new JsonParseException("Unexpected first token: " + firstToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (modList == null || modList.isEmpty())
|
||||
throw new IOException("Mod " + modFile + " `mcmod.info` is malformed..");
|
||||
throw new IOException("Mod " + modFile + " `mcmod.info` is malformed");
|
||||
ForgeOldModMetadata metadata = modList.get(0);
|
||||
String authors = metadata.getAuthor();
|
||||
if (StringUtils.isBlank(authors) && metadata.getAuthors().length > 0)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.mod.modinfo;
|
||||
|
||||
import org.jackhuang.hmcl.util.gson.JsonSerializable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// @author Glavo
|
||||
@JsonSerializable
|
||||
public record ForgeOldModMetadataLst(
|
||||
int modListVersion,
|
||||
List<ForgeOldModMetadata> modList) {
|
||||
}
|
||||
@@ -45,16 +45,6 @@ import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public abstract class FetchTask<T> extends Task<T> {
|
||||
private static final HttpClient HTTP_CLIENT;
|
||||
|
||||
static {
|
||||
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
|
||||
|
||||
HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
|
||||
protected static final int DEFAULT_RETRY = 3;
|
||||
|
||||
@@ -221,7 +211,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI);
|
||||
requestBuilder.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT));
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
|
||||
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
|
||||
if (DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
@@ -424,7 +414,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
|
||||
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
||||
|
||||
public final void withResult(boolean success) {
|
||||
public void withResult(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
@@ -509,4 +499,21 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
public static int getDownloadExecutorConcurrency() {
|
||||
return downloadExecutorConcurrency;
|
||||
}
|
||||
|
||||
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
|
||||
private static final class Holder {
|
||||
private static final HttpClient HTTP_CLIENT;
|
||||
|
||||
static {
|
||||
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
|
||||
|
||||
HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Holder() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public final class StringUtils {
|
||||
builder.append(str, start, i);
|
||||
}
|
||||
builder.append(' ');
|
||||
i = whitespaceEnd ;
|
||||
i = whitespaceEnd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,19 @@ public final class StringUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean containsEmoji(String str) {
|
||||
for (int i = 0; i < str.length(); ) {
|
||||
int ch = str.codePointAt(i);
|
||||
|
||||
if (ch >= 0x1F300 && ch <= 0x1FAFF)
|
||||
return true;
|
||||
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isVarNameStart(char ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_';
|
||||
}
|
||||
|
||||
@@ -17,16 +17,23 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
@@ -48,52 +55,59 @@ public final class LocaleUtils {
|
||||
|
||||
private static final Map<String, String> subLanguageToParent = new HashMap<>();
|
||||
private static final Map<String, String> iso3To2 = new HashMap<>();
|
||||
private static final Set<String> rtl = new HashSet<>();
|
||||
|
||||
static {
|
||||
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")) {
|
||||
if (input != null) {
|
||||
new String(input.readAllBytes()).lines()
|
||||
.filter(line -> !line.startsWith("#") && !line.isBlank())
|
||||
.forEach(line -> {
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2)
|
||||
LOG.warning("Invalid line in sublanguages.csv: " + line);
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
subLanguageToParent.put(languages[i], parent);
|
||||
}
|
||||
});
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2) {
|
||||
LOG.warning("Invalid line in sublanguages.csv: " + line);
|
||||
continue;
|
||||
}
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
subLanguageToParent.put(languages[i], parent);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load sublanguages.csv", e);
|
||||
}
|
||||
|
||||
// Line Format:
|
||||
// (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
|
||||
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")) {
|
||||
if (input != null) {
|
||||
int lineLength = 2 + 1 + 3;
|
||||
|
||||
byte[] bytes = input.readAllBytes();
|
||||
for (int offset = 0; offset < bytes.length; ) {
|
||||
if (offset > bytes.length - lineLength)
|
||||
break;
|
||||
|
||||
if (bytes[offset + 2] != ',')
|
||||
throw new IOException("iso_languages.csv format invalid");
|
||||
|
||||
String iso2 = new String(bytes, offset, 2, StandardCharsets.US_ASCII);
|
||||
String iso3 = new String(bytes, offset + 3, 3, StandardCharsets.US_ASCII);
|
||||
|
||||
iso3To2.put(iso3, iso2);
|
||||
|
||||
offset += (lineLength + 1);
|
||||
try {
|
||||
// Line Format: (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = line.split(",", 3);
|
||||
if (parts.length != 2) {
|
||||
LOG.warning("Invalid line in iso_languages.csv: " + line);
|
||||
continue;
|
||||
}
|
||||
|
||||
iso3To2.put(parts[1], parts[0]);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load iso_languages.csv", e);
|
||||
}
|
||||
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/rtl.txt")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
rtl.add(line.trim());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load rtl.txt", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Locale getInstance(String language, String script, String region,
|
||||
@@ -161,6 +175,21 @@ public final class LocaleUtils {
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
public static @NotNull TextDirection getTextDirection(Locale locale) {
|
||||
TextDirection direction = rtl.contains(getRootLanguage(locale))
|
||||
? TextDirection.RIGHT_TO_LEFT
|
||||
: TextDirection.LEFT_TO_RIGHT;
|
||||
|
||||
if ("Qabs".equals(getScript(locale))) {
|
||||
direction = switch (direction) {
|
||||
case RIGHT_TO_LEFT -> TextDirection.LEFT_TO_RIGHT;
|
||||
case LEFT_TO_RIGHT -> TextDirection.RIGHT_TO_LEFT;
|
||||
};
|
||||
}
|
||||
|
||||
return direction;
|
||||
}
|
||||
|
||||
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
|
||||
|
||||
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
|
||||
@@ -363,6 +392,29 @@ public final class LocaleUtils {
|
||||
return "zh".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
public static final boolean IS_CHINA_MAINLAND = isChinaMainland();
|
||||
|
||||
private static boolean isChinaMainland() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private LocaleUtils() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
/// @author Glavo
|
||||
public enum TextDirection {
|
||||
LEFT_TO_RIGHT,
|
||||
RIGHT_TO_LEFT;
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.util.io;
|
||||
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalPredicate;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.Charset;
|
||||
@@ -153,6 +154,31 @@ public final class Zipper implements Closeable {
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
public OutputStream putStream(String path) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||
return new OutputStream() {
|
||||
public void write(int b) throws IOException {
|
||||
zos.write(b);
|
||||
}
|
||||
|
||||
public void write(@NotNull byte[] b) throws IOException {
|
||||
zos.write(b);
|
||||
}
|
||||
|
||||
public void write(@NotNull byte[] b, int off, int len) throws IOException {
|
||||
zos.write(b, off, len);
|
||||
}
|
||||
|
||||
public void flush() throws IOException {
|
||||
zos.flush();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
zos.closeEntry();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void putLines(Stream<String> lines, String path) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
package org.jackhuang.hmcl.util.platform;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Platform {
|
||||
public record Platform(OperatingSystem os, Architecture arch) {
|
||||
public static final Platform UNKNOWN = new Platform(OperatingSystem.UNKNOWN, Architecture.UNKNOWN);
|
||||
|
||||
public static final Platform WINDOWS_X86 = new Platform(OperatingSystem.WINDOWS, Architecture.X86);
|
||||
public static final Platform WINDOWS_X86_64 = new Platform(OperatingSystem.WINDOWS, Architecture.X86_64);
|
||||
public static final Platform WINDOWS_ARM64 = new Platform(OperatingSystem.WINDOWS, Architecture.ARM64);
|
||||
|
||||
public static final Platform LINUX_X86 = new Platform(OperatingSystem.LINUX, Architecture.X86);
|
||||
public static final Platform LINUX_X86_64 = new Platform(OperatingSystem.LINUX, Architecture.X86_64);
|
||||
public static final Platform LINUX_ARM64 = new Platform(OperatingSystem.LINUX, Architecture.ARM64);
|
||||
public static final Platform LINUX_ARM32 = new Platform(OperatingSystem.LINUX, Architecture.ARM32);
|
||||
public static final Platform LINUX_RISCV64 = new Platform(OperatingSystem.LINUX, Architecture.RISCV64);
|
||||
public static final Platform LINUX_LOONGARCH64 = new Platform(OperatingSystem.LINUX, Architecture.LOONGARCH64);
|
||||
public static final Platform LINUX_LOONGARCH64_OW = new Platform(OperatingSystem.LINUX, Architecture.LOONGARCH64_OW);
|
||||
public static final Platform LINUX_MIPS64EL = new Platform(OperatingSystem.LINUX, Architecture.MIPS64EL);
|
||||
|
||||
public static final Platform MACOS_X86_64 = new Platform(OperatingSystem.MACOS, Architecture.X86_64);
|
||||
public static final Platform MACOS_ARM64 = new Platform(OperatingSystem.MACOS, Architecture.ARM64);
|
||||
|
||||
public static final Platform FREEBSD_X86_64 = new Platform(OperatingSystem.FREEBSD, Architecture.X86);
|
||||
|
||||
public static final Platform CURRENT_PLATFORM = Platform.getPlatform(OperatingSystem.CURRENT_OS, Architecture.CURRENT_ARCH);
|
||||
public static final Platform SYSTEM_PLATFORM = Platform.getPlatform(OperatingSystem.CURRENT_OS, Architecture.SYSTEM_ARCH);
|
||||
|
||||
public static boolean isCompatibleWithX86Java() {
|
||||
return Architecture.SYSTEM_ARCH.isX86() || SYSTEM_PLATFORM == MACOS_ARM64 || SYSTEM_PLATFORM == WINDOWS_ARM64;
|
||||
}
|
||||
|
||||
private final OperatingSystem os;
|
||||
private final Architecture arch;
|
||||
|
||||
private Platform(OperatingSystem os, Architecture arch) {
|
||||
this.os = os;
|
||||
this.arch = arch;
|
||||
}
|
||||
|
||||
public static Platform getPlatform() {
|
||||
return CURRENT_PLATFORM;
|
||||
return Architecture.SYSTEM_ARCH.isX86() || SYSTEM_PLATFORM.equals(MACOS_ARM64) || SYSTEM_PLATFORM.equals(WINDOWS_ARM64);
|
||||
}
|
||||
|
||||
public static Platform getPlatform(OperatingSystem os, Architecture arch) {
|
||||
@@ -74,23 +68,10 @@ public final class Platform {
|
||||
return arch.getBits();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(os, arch);
|
||||
}
|
||||
|
||||
public boolean equals(OperatingSystem os, Architecture arch) {
|
||||
return this.os == os && this.arch == arch;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Platform)) return false;
|
||||
Platform platform = (Platform) o;
|
||||
return os == platform.os && arch == platform.arch;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return os.getCheckedName() + "-" + arch.getCheckedName();
|
||||
|
||||
@@ -112,7 +112,7 @@ public final class SystemUtils {
|
||||
return Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
|
||||
}
|
||||
|
||||
private static void onLogLine(String log) {
|
||||
public static void onLogLine(String log) {
|
||||
LOG.info(log);
|
||||
}
|
||||
}
|
||||
@@ -846,4 +846,6 @@
|
||||
1.21.9-pre3
|
||||
1.21.9-pre4
|
||||
1.21.9-rc1
|
||||
1.21.9
|
||||
1.21.9
|
||||
1.21.10-rc1
|
||||
1.21.10
|
||||
@@ -0,0 +1,6 @@
|
||||
ar
|
||||
fa
|
||||
he
|
||||
ps
|
||||
ur
|
||||
yi
|
||||
@@ -68,4 +68,10 @@ _‾
|
||||
!¡
|
||||
//
|
||||
\\
|
||||
',
|
||||
',
|
||||
()
|
||||
)(
|
||||
[]
|
||||
][
|
||||
{}
|
||||
}{
|
||||
@@ -220,4 +220,18 @@ public final class LocaleUtilsTest {
|
||||
assertNull(LocaleUtils.getParentLanguage("zh"));
|
||||
assertNull(LocaleUtils.getParentLanguage("zho"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetTextDirection() {
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("en")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("en-US")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-Hans")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-CN")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar-Qabs")));
|
||||
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("en-Qabs")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ara")));
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -37,6 +37,11 @@ subprojects {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
tasks.withType<Checkstyle> {
|
||||
maxHeapSize.set("2g")
|
||||
}
|
||||
|
||||
configure<CheckstyleExtension> {
|
||||
sourceSets = setOf()
|
||||
}
|
||||
@@ -77,11 +82,9 @@ tasks.register<UpdateDocuments>("updateDocuments") {
|
||||
}
|
||||
|
||||
tasks.register<CheckUpdate>("checkUpdateDev") {
|
||||
tagPrefix.set("v")
|
||||
uri.set("https://ci.huangyuhui.net/job/HMCL")
|
||||
uri.set("https://ci.huangyuhui.net/job/HMCL-nightly")
|
||||
}
|
||||
|
||||
tasks.register<CheckUpdate>("checkUpdateStable") {
|
||||
tagPrefix.set("release-")
|
||||
uri.set("https://ci.huangyuhui.net/job/HMCL-stable")
|
||||
}
|
||||
@@ -17,6 +17,10 @@ java {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
into("org/jackhuang/hmcl/gradle/l10n") {
|
||||
from(projectDir.resolve("../HMCLCore/src/main/resources/assets/lang/"))
|
||||
|
||||
@@ -55,9 +55,6 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
@Input
|
||||
public abstract Property<String> getUri();
|
||||
|
||||
@Input
|
||||
public abstract Property<String> getTagPrefix();
|
||||
|
||||
public CheckUpdate() {
|
||||
getOutputs().upToDateWhen(task -> false);
|
||||
}
|
||||
@@ -69,10 +66,21 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
);
|
||||
}
|
||||
|
||||
private static String concatUri(String base, String... others) {
|
||||
var builder = new StringBuilder(base);
|
||||
for (String other : others) {
|
||||
if (builder.charAt(builder.length() - 1) != '/') {
|
||||
builder.append('/');
|
||||
}
|
||||
builder.append(Objects.requireNonNull(other));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@TaskAction
|
||||
public void run() throws Exception {
|
||||
String uri = getUri().get();
|
||||
URI apiUri = toURI(uri, "api/json");
|
||||
URI apiUri = URI.create(concatUri(uri, "api", "json"));
|
||||
LOGGER.quiet("Fetching metadata from {}", apiUri);
|
||||
|
||||
BuildMetadata buildMetadata;
|
||||
@@ -93,19 +101,19 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
.filter(it -> !it.color().equals("disabled"))
|
||||
.map(it -> {
|
||||
try {
|
||||
return fetchBuildInfo(helper, toURI(it.url, "lastSuccessfulBuild/api/json"));
|
||||
return fetchBuildInfo(helper, it.url);
|
||||
} catch (Throwable e) {
|
||||
throw new GradleException("Failed to retrieve build info from " + it.url(), e);
|
||||
throw new GradleException("Failed to fetch build info from " + it.url(), e);
|
||||
}
|
||||
}).sorted(Comparator.comparing(BuildMetadata::timestamp))
|
||||
.toList();
|
||||
|
||||
if (metadatas.isEmpty())
|
||||
throw new GradleException("Failed to retrieve build metadata from " + apiUri);
|
||||
throw new GradleException("Failed to fetch build metadata from " + apiUri);
|
||||
|
||||
buildMetadata = metadatas.get(metadatas.size() - 1);
|
||||
} else if (WORKFLOW_JOB.equals(jobType) || FREE_STYLE_PROJECT.equals(jobType)) {
|
||||
buildMetadata = fetchBuildInfo(helper, toURI(uri, "lastSuccessfulBuild/api/json"));
|
||||
buildMetadata = fetchBuildInfo(helper, uri);
|
||||
} else {
|
||||
throw new GradleException("Unsupported job type: " + jobType);
|
||||
}
|
||||
@@ -130,14 +138,17 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
|
||||
addEnv.accept("HMCL_COMMIT_SHA", buildMetadata.revision());
|
||||
addEnv.accept("HMCL_VERSION", buildMetadata.version());
|
||||
addEnv.accept("HMCL_TAG_NAME", getTagPrefix().get() + buildMetadata.version());
|
||||
addEnv.accept("HMCL_TAG_NAME", "v" + buildMetadata.version());
|
||||
addEnv.accept("HMCL_CI_DOWNLOAD_BASE_URI", buildMetadata.downloadBaseUri);
|
||||
}
|
||||
}
|
||||
|
||||
private record BuildMetadata(String version, String revision, long timestamp) {
|
||||
private record BuildMetadata(String version, String revision, long timestamp, String downloadBaseUri) {
|
||||
}
|
||||
|
||||
private BuildMetadata fetchBuildInfo(Helper helper, URI uri) throws IOException, InterruptedException {
|
||||
private BuildMetadata fetchBuildInfo(Helper helper, String jobUri) throws IOException, InterruptedException {
|
||||
URI uri = URI.create(concatUri(jobUri, "lastSuccessfulBuild", "api", "json"));
|
||||
|
||||
LOGGER.quiet("Fetching build info from {}", uri);
|
||||
|
||||
BuildInfo buildInfo = Objects.requireNonNull(helper.fetch(uri, BuildInfo.class), "build info");
|
||||
@@ -153,16 +164,28 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
throw new GradleException("Invalid revision: " + revision);
|
||||
|
||||
Pattern fileNamePattern = Pattern.compile("HMCL-(?<version>\\d+(?:\\.\\d+)+)\\.jar");
|
||||
String version = Objects.requireNonNullElse(buildInfo.artifacts(), List.<ArtifactInfo>of())
|
||||
.stream()
|
||||
.map(ArtifactInfo::fileName)
|
||||
.map(fileNamePattern::matcher)
|
||||
.filter(Matcher::matches)
|
||||
.map(matcher -> matcher.group("version"))
|
||||
ArtifactInfo jarArtifact = Objects.requireNonNullElse(buildInfo.artifacts(), List.<ArtifactInfo>of()).stream()
|
||||
.filter(it -> fileNamePattern.matcher(it.fileName()).matches())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new GradleException("Could not find .jar artifact"));
|
||||
|
||||
return new BuildMetadata(version, revision, buildInfo.timestamp());
|
||||
String fileName = jarArtifact.fileName();
|
||||
String relativePath = jarArtifact.relativePath();
|
||||
if (!relativePath.endsWith("/" + fileName)) {
|
||||
throw new GradleException("Invalid artifact relative path: " + jarArtifact);
|
||||
}
|
||||
|
||||
Matcher matcher = fileNamePattern.matcher(fileName);
|
||||
if (!matcher.matches()) {
|
||||
throw new AssertionError("Artifact: " + jarArtifact.fileName());
|
||||
}
|
||||
|
||||
String version = matcher.group("version");
|
||||
|
||||
String downloadBaseUrl = concatUri(buildInfo.url(), "artifact",
|
||||
relativePath.substring(0, relativePath.length() - fileName.length() - 1));
|
||||
|
||||
return new BuildMetadata(version, revision, buildInfo.timestamp(), downloadBaseUrl);
|
||||
}
|
||||
|
||||
private static final class Helper implements AutoCloseable {
|
||||
@@ -193,14 +216,15 @@ public abstract class CheckUpdate extends DefaultTask {
|
||||
|
||||
}
|
||||
|
||||
private record BuildInfo(long number,
|
||||
private record BuildInfo(String url,
|
||||
long number,
|
||||
long timestamp,
|
||||
List<ArtifactInfo> artifacts,
|
||||
List<ActionInfo> actions
|
||||
) {
|
||||
}
|
||||
|
||||
record ArtifactInfo(String fileName) {
|
||||
record ArtifactInfo(String fileName, String relativePath) {
|
||||
}
|
||||
|
||||
record ActionInfo(String _class, BuiltRevision lastBuiltRevision) {
|
||||
|
||||
@@ -23,11 +23,22 @@ import java.util.Locale;
|
||||
/// @author Glavo
|
||||
public enum DocumentLocale {
|
||||
ENGLISH(Locale.ENGLISH, "") {
|
||||
@Override
|
||||
public String getSubLanguageDisplayName() {
|
||||
return "Standard";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DocumentLocale> getCandidates() {
|
||||
return List.of(ENGLISH);
|
||||
}
|
||||
},
|
||||
ENGLISH_UPSIDE_DOWN(Locale.forLanguageTag("en-Qabs"), "en-Qabs") {
|
||||
@Override
|
||||
public String getSubLanguageDisplayName() {
|
||||
return "uʍoᗡ ǝpᴉsd∩";
|
||||
}
|
||||
},
|
||||
SIMPLIFIED_CHINESE(Locale.forLanguageTag("zh-Hans"), "zh"),
|
||||
TRADITIONAL_CHINESE("zh-Hant") {
|
||||
@Override
|
||||
|
||||
@@ -63,6 +63,14 @@ public abstract class CheckTranslations extends DefaultTask {
|
||||
|
||||
checker.checkMisspelled(simplifiedChinese, key, value, "账户", "帐户");
|
||||
checker.checkMisspelled(simplifiedChinese, key, value, "其他", "其它");
|
||||
|
||||
checker.checkMisspelled(simplifiedChinese, key, value, "(", "(");
|
||||
checker.checkMisspelled(simplifiedChinese, key, value, ")", ")");
|
||||
});
|
||||
|
||||
traditionalChinese.forEach((key, value) -> {
|
||||
checker.checkMisspelled(traditionalChinese, key, value, "(", "(");
|
||||
checker.checkMisspelled(traditionalChinese, key, value, ")", ")");
|
||||
});
|
||||
|
||||
classicalChinese.forEach((key, value) -> {
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -53,10 +54,7 @@ public abstract class UpsideDownTranslate extends DefaultTask {
|
||||
Path inputFile = getInputFile().get().getAsFile().toPath();
|
||||
Path outputFile = getOutputFile().get().getAsFile().toPath();
|
||||
|
||||
Properties english = new Properties();
|
||||
try (var reader = Files.newBufferedReader(inputFile)) {
|
||||
english.load(reader);
|
||||
}
|
||||
Properties english = PropertiesUtils.load(inputFile);
|
||||
|
||||
Properties output = new Properties();
|
||||
Translator translator = new Translator();
|
||||
|
||||
@@ -52,8 +52,6 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputFile();
|
||||
|
||||
// ---
|
||||
|
||||
private static final Logger LOGGER = Logging.getLogger(ParseModDataTask.class);
|
||||
|
||||
private static final String S = ";";
|
||||
@@ -94,7 +92,33 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
return "";
|
||||
}
|
||||
|
||||
private static final Set<String> skip = Set.of(
|
||||
private static String cleanChineseName(String chineseName) {
|
||||
if (chineseName == null || chineseName.isBlank())
|
||||
return "";
|
||||
|
||||
chineseName = chineseName.trim();
|
||||
|
||||
StringBuilder builder = new StringBuilder(chineseName.length());
|
||||
int[] codePoints = chineseName.codePoints().toArray();
|
||||
for (int i = 0; i < codePoints.length; i++) {
|
||||
int ch = codePoints[i];
|
||||
int prev = i > 0 ? codePoints[i - 1] : 0;
|
||||
|
||||
switch (ch) {
|
||||
case '(' -> {
|
||||
if (Character.isWhitespace(prev) || prev == '!' || prev == '。')
|
||||
builder.append('(');
|
||||
else
|
||||
builder.append(" (");
|
||||
}
|
||||
case ')' -> builder.append(')');
|
||||
default -> builder.appendCodePoint(ch);
|
||||
}
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private static final Set<String> SKIP = Set.of(
|
||||
"Minecraft",
|
||||
"The Building Game"
|
||||
);
|
||||
@@ -106,7 +130,6 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
|
||||
|
||||
List<ModData> modDatas;
|
||||
try (BufferedReader reader = Files.newBufferedReader(inputFile)) {
|
||||
modDatas = new Gson().fromJson(reader, TypeToken.getParameterized(List.class, ModData.class).getType());
|
||||
@@ -125,14 +148,13 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
String subName = mod.name.sub;
|
||||
String abbr = mod.name.abbr;
|
||||
|
||||
if (chineseName == null)
|
||||
chineseName = "";
|
||||
chineseName = chineseName == null ? "" : cleanChineseName(chineseName);
|
||||
if (subName == null)
|
||||
subName = "";
|
||||
if (abbr == null)
|
||||
abbr = "";
|
||||
|
||||
if (skip.contains(subName)) {
|
||||
if (SKIP.contains(subName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -208,7 +230,6 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
public Map<String, List<Link>> list;
|
||||
}
|
||||
|
||||
|
||||
public static final class ModIdDeserializer implements JsonDeserializer<List<String>> {
|
||||
private static final Type STRING_LIST = TypeToken.getParameterized(List.class, String.class).getType();
|
||||
|
||||
|
||||
+8
-1
@@ -17,4 +17,11 @@
|
||||
#
|
||||
|
||||
# Languages
|
||||
en=英吉利文
|
||||
en=英吉利語
|
||||
es=佛郎機語
|
||||
ja=日本語
|
||||
ru=羅剎語
|
||||
uk=渥蓮語
|
||||
|
||||
# Scripts
|
||||
Qabs=倒置
|
||||
@@ -104,5 +104,17 @@
|
||||
<module name="RedundantImport"/> <!-- e.g. double import statements: http://checkstyle.sourceforge.net/config_imports.html#RedundantImport -->
|
||||
<module name="UnusedImports"/> <!-- http://checkstyle.sourceforge.net/config_imports.html#UnusedImports -->
|
||||
<module name="IllegalImport"/> <!-- checks if import sun.* is used http://checkstyle.sourceforge.net/config_imports.html#IllegalImport -->
|
||||
|
||||
<!-- Custom -->
|
||||
<module name="RegexpSinglelineJava">
|
||||
<property name="format" value="\.toLowerCase\(\)"/>
|
||||
<property name="ignoreComments" value="true"/>
|
||||
<property name="message" value="Should not use String#toLowerCase(), use String#toLowerCase(Locale.ROOT) instead."/>
|
||||
</module>
|
||||
<module name="RegexpSinglelineJava">
|
||||
<property name="format" value="\.toUpperCase\(\)"/>
|
||||
<property name="ignoreComments" value="true"/>
|
||||
<property name="message" value="Should not use String#toUpperCase(), use String#toUpperCase(Locale.ROOT) instead."/>
|
||||
</module>
|
||||
</module>
|
||||
</module>
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
> [!WARNING]
|
||||
> 本文介绍的是 HMCL 的内部功能,我们不保证这些功能的稳定性,并且随时可能修改或删除这些功能。
|
||||
>
|
||||
>
|
||||
> 使用这些功能时请务必小心,错误地使用这些功能可能会导致 HMCL 行为异常甚至崩溃。
|
||||
|
||||
HMCL 提供了一系列调试选项,用于控制启动器的行为。
|
||||
@@ -20,7 +20,7 @@ HMCL 提供了一系列调试选项,用于控制启动器的行为。
|
||||
| | `-Dhmcl.home=<path>` | 指定 HMCL 的用户数据文件夹 | Windows: `%APPDATA\.hmcl`<br>Linux/BSD: `$XDG_DATA_HOME/hmcl`<br>macOS: `~Library/Application Support/hmcl` | |
|
||||
| | `-Dhmcl.self_integrity_check.disable=true` | 检查更新时不检查本体完整性 | | |
|
||||
| | `-Dhmcl.bmclapi.override=<url>` | 指定 BMCLAPI 的 API Root | `https://bmclapi2.bangbang93.com` | |
|
||||
| | `-Dhmcl.discoapi.override=<url>` | 指定 foojay Disco API 的 API Root | https://api.foojay.io/disco/v3.0` |
|
||||
| | `-Dhmcl.discoapi.override=<url>` | 指定 foojay Disco API 的 API Root | `https://api.foojay.io/disco/v3.0` |
|
||||
| `HMCL_FONT` | `-Dhmcl.font.override=<font family>` | 指定 HMCL 默认字体 | 使用系统默认字体 | |
|
||||
| | `-Dhmcl.update_source.override=<url>` | 指定 HMCL 更新源 | `https://hmcl.huangyuhui.net/api/update_link` | |
|
||||
| | `-Dhmcl.authlibinjector.location=<path>` | 指定 authlib-injector JAR 文件的位置 | 使用 HMCL 内嵌的 authlib-injector | |
|
||||
|
||||
+3
-4
@@ -2,20 +2,19 @@
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY NAME=BADGES -->
|
||||
[](https://ci.huangyuhui.net/job/HMCL)
|
||||

|
||||
[](https://github.com/HMCL-dev/HMCL/releases)
|
||||

|
||||
[](https://discord.gg/jVvC7HfM6U)
|
||||
[](https://docs.hmcl.net/groups.html)
|
||||
<!-- #END BLOCK -->
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
**English** | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
**English** (**Standard**, [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## Introduction
|
||||
|
||||
HMCL is an open-source, cross-platform Minecraft launcher that supports Mod Management, Game Customizing, ModLoader Installing (Forge, NeoForge, Fabric, Quilt, LiteLoader, and OptiFine), Modpack Creating, UI Customization, and more.
|
||||
HMCL is an open-source, cross-platform Minecraft launcher that supports Mod Management, Game Customizing, ModLoader Installing (Forge, NeoForge, Cleanroom, Fabric, Quilt, LiteLoader, and OptiFine), Modpack Creating, UI Customization, and more.
|
||||
|
||||
HMCL has amazing cross-platform capabilities. Not only does it run on different operating systems like Windows, Linux, macOS, and FreeBSD, but it also supports various CPU architectures such as x86, ARM, RISC-V, MIPS, and LoongArch. You can easily enjoy Minecraft across different platforms through HMCL.
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# ɹǝɥɔunɐ˥ ¡ʇɟɐɹɔǝuᴉW ollǝH
|
||||
|
||||
[](https://github.com/HMCL-dev/HMCL/releases)
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY PROCESS_LINK=false -->
|
||||
**ɥsᴉlƃuƎ** (**uʍoᗡ ǝpᴉsd∩** '[pɹɐpuɐʇS](README.md))
|
||||
<!-- #END BLOCK -->
|
||||
|
||||
## uoᴉʇɔnpoɹʇuI
|
||||
|
||||
˙[ǝlqɐʇ sᴉɥʇ](PLATFORM.md) oʇ ɹǝɟǝɹ ǝsɐǝld '˥ƆWH ʎq pǝʇɹoddns sǝɹnʇɔǝʇᴉɥɔɹɐ ∩ԀƆ puɐ sɯǝʇsʎs ɹoℲ
|
||||
|
||||
˙˥ƆWH ɥƃnoɹɥʇ sɯɹoɟʇɐld ʇuǝɹǝɟɟᴉp ssoɹɔɐ ʇɟɐɹɔǝuᴉW ʎoɾuǝ ʎlᴉsɐǝ uɐɔ no⅄ ˙ɥɔɹ∀ƃuoo˥ puɐ 'SԀIW 'Λ-ƆSIɹ 'Wɹ∀ '98x sɐ ɥɔns sǝɹnʇɔǝʇᴉɥɔɹɐ ∩ԀƆ snoᴉɹɐʌ sʇɹoddns oslɐ ʇᴉ ʇnq 'pSqǝǝɹℲ puɐ 'SOɔɐɯ 'xnuᴉ˥ 'sʍopuᴉM ǝʞᴉl sɯǝʇsʎs ƃuᴉʇɐɹǝdo ʇuǝɹǝɟɟᴉp uo unɹ ʇᴉ sǝop ʎluo ʇoN ˙sǝᴉʇᴉlᴉqɐdɐɔ ɯɹoɟʇɐld-ssoɹɔ ƃuᴉzɐɯɐ sɐɥ ˥ƆWH
|
||||
|
||||
˙ǝɹoɯ puɐ 'uoᴉʇɐzᴉɯoʇsnƆ I∩ 'ƃuᴉʇɐǝɹƆ ʞɔɐdpoW '(ǝuᴉℲᴉʇdO puɐ 'ɹǝpɐo˥ǝʇᴉ˥ 'ʇlᴉnQ 'ɔᴉɹqɐℲ 'ɯooɹuɐǝlƆ 'ǝƃɹoℲoǝN 'ǝƃɹoℲ) ƃuᴉllɐʇsuI ɹǝpɐo˥poW 'ƃuᴉzᴉɯoʇsnƆ ǝɯɐפ 'ʇuǝɯǝƃɐuɐW poW sʇɹoddns ʇɐɥʇ ɹǝɥɔunɐl ʇɟɐɹɔǝuᴉW ɯɹoɟʇɐld-ssoɹɔ 'ǝɔɹnos-uǝdo uɐ sᴉ ˥ƆWH
|
||||
|
||||
## pɐoluʍoᗡ
|
||||
|
||||
˙ǝʌoqɐ pǝʇsᴉl sǝʇᴉsqǝʍ lɐᴉɔᴉɟɟo ǝɥʇ ɯoɹɟ sǝsɐǝlǝɹ pɐoluʍop oʇ ʎluo pǝpuǝɯɯoɔǝɹ sᴉ ʇᴉ 'ʎɹɐssǝɔǝu ʇou ɥƃnoɥʇl∀
|
||||
|
||||
˙[sǝsɐǝlǝɹ qnHʇᴉפ](https://github.com/HMCL-dev/HMCL/releases) uᴉ ˥ƆWH ɟo uoᴉsɹǝʌ ʇsǝʇɐl ǝɥʇ puᴉɟ oslɐ uɐɔ no⅄
|
||||
|
||||
˙[ǝʇᴉsqǝʍ lɐᴉɔᴉɟɟo](https://hmcl.huangyuhui.net/download) ǝɥʇ ɯoɹɟ uoᴉsɹǝʌ ʇsǝʇɐl ǝɥʇ pɐoluʍop
|
||||
+3
-4
@@ -2,8 +2,7 @@
|
||||
|
||||
<!-- #BEGIN COPY -->
|
||||
<!-- #PROPERTY NAME=BADGES -->
|
||||
[](https://ci.huangyuhui.net/job/HMCL)
|
||||

|
||||
[](https://github.com/HMCL-dev/HMCL/releases)
|
||||

|
||||
[](https://discord.gg/jVvC7HfM6U)
|
||||
[](https://docs.hmcl.net/groups.html)
|
||||
@@ -11,12 +10,12 @@
|
||||
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | **español** | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | **español** | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## Introducción
|
||||
|
||||
HMCL es un lanzador de Minecraft de código abierto y multiplataforma que admite la gestión de mods, personalización del juego, instalación de ModLoaders (Forge, NeoForge, Fabric, Quilt, LiteLoader y OptiFine), creación de modpacks, personalización de la interfaz de usuario y más.
|
||||
HMCL es un lanzador de Minecraft de código abierto y multiplataforma que admite la gestión de mods, personalización del juego, instalación de ModLoaders (Forge, NeoForge, Cleanroom, Fabric, Quilt, LiteLoader y OptiFine), creación de modpacks, personalización de la interfaz de usuario y más.
|
||||
|
||||
HMCL tiene increíbles capacidades multiplataforma. No solo funciona en diferentes sistemas operativos como Windows, Linux, macOS y FreeBSD, sino que también es compatible con varias arquitecturas de CPU como x86, ARM, RISC-V, MIPS y LoongArch. Puedes disfrutar fácilmente de Minecraft en diferentes plataformas a través de HMCL.
|
||||
|
||||
|
||||
+3
-4
@@ -2,20 +2,19 @@
|
||||
|
||||
<!-- #BEGIN COPY -->
|
||||
<!-- #PROPERTY NAME=BADGES -->
|
||||
[](https://ci.huangyuhui.net/job/HMCL)
|
||||

|
||||
[](https://github.com/HMCL-dev/HMCL/releases)
|
||||

|
||||
[](https://discord.gg/jVvC7HfM6U)
|
||||
[](https://docs.hmcl.net/groups.html)
|
||||
<!-- #END COPY -->
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
English ([Standard](README.md), [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## 紹介
|
||||
|
||||
HMCLはオープンソースでクロスプラットフォーム対応のMinecraftランチャーです。Mod管理、ゲームカスタマイズ、Modローダーのインストール(Forge、NeoForge、Fabric、Quilt、LiteLoader、OptiFine)、Modパック作成、UIカスタマイズなど、さまざまな機能をサポートしています。
|
||||
HMCLはオープンソースでクロスプラットフォーム対応のMinecraftランチャーです。Mod管理、ゲームカスタマイズ、Modローダーのインストール(Forge、NeoForge、Cleanroom、Fabric、Quilt、LiteLoader、OptiFine)、Modパック作成、UIカスタマイズなど、さまざまな機能をサポートしています。
|
||||
|
||||
HMCLは優れたクロスプラットフォーム性能を持っています。Windows、Linux、macOS、FreeBSDなどの異なるオペレーティングシステムだけでなく、x86、ARM、RISC-V、MIPS、LoongArchなどのさまざまなCPUアーキテクチャにも対応しています。HMCLを使えば、さまざまなプラットフォームでMinecraftを簡単に楽しむことができます。
|
||||
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user