Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d9803177 | ||
|
|
2ce4fcabcb | ||
|
|
3a30e5b048 | ||
|
|
49b0a99ced | ||
|
|
8f2c860242 | ||
|
|
8c10775de9 | ||
|
|
b5bf7ce4d0 | ||
|
|
bd6a6fa9b6 | ||
|
|
1c0ae3918d | ||
|
|
c6ce2df731 | ||
|
|
18985c0b63 | ||
|
|
322e29642d | ||
|
|
a86e5d7bf5 | ||
|
|
c90eaa2937 | ||
|
|
defacb3554 | ||
|
|
0a66863272 | ||
|
|
d90df221bc | ||
|
|
fd7f909561 | ||
|
|
a921a11be6 | ||
|
|
7fbc99f3eb | ||
|
|
7e40c799e5 | ||
|
|
f2032ab7a8 | ||
|
|
d7c6a23dbe | ||
|
|
27e1e021d7 | ||
|
|
2516fb5d69 | ||
|
|
d66c080dea | ||
|
|
94ca81035b | ||
|
|
1ab414e702 | ||
|
|
16f6e89e45 | ||
|
|
22f6bfd845 | ||
|
|
c4823b3e55 | ||
|
|
e34ec9f801 | ||
|
|
d2e87ba024 | ||
|
|
ffb50423af | ||
|
|
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 |
No files matched your search
@@ -16,6 +16,11 @@ NVIDIA
|
||||
minecraft-exported-crash-info*
|
||||
hmcl-exported-logs-*
|
||||
/.java/
|
||||
/.local/
|
||||
/.cache/
|
||||
|
||||
# IANA Language Subtag Registry
|
||||
language-subtag-registry
|
||||
|
||||
# gradle build
|
||||
/build/
|
||||
|
||||
@@ -335,7 +335,7 @@ tasks.register<JavaExec>("run") {
|
||||
classpath = files(jarPath)
|
||||
workingDir = rootProject.rootDir
|
||||
|
||||
val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS"))
|
||||
val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS") ?: "-Xmx1g")
|
||||
if (vmOptions.none { it.startsWith("-Dhmcl.offline.auth.restricted=") })
|
||||
vmOptions += "-Dhmcl.offline.auth.restricted=false"
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copy from https://github.com/sshahine/JFoenix/blob/d427fd801a338f934307ba41ce604eb5c79f0b20/jfoenix/src/main/java/com/jfoenix/skins/JFXListViewSkin.java
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package com.jfoenix.skins;
|
||||
|
||||
import com.jfoenix.adapters.skins.ListViewSkin;
|
||||
import com.jfoenix.controls.JFXListView;
|
||||
import com.jfoenix.effects.JFXDepthManager;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.skin.VirtualFlow;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
// https://github.com/HMCL-dev/HMCL/issues/4720
|
||||
public class JFXListViewSkin<T> extends ListViewSkin<T> {
|
||||
|
||||
private final VirtualFlow<ListCell<T>> flow;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public JFXListViewSkin(final JFXListView<T> listView) {
|
||||
super(listView);
|
||||
flow = (VirtualFlow<ListCell<T>>) getChildren().get(0);
|
||||
JFXDepthManager.setDepth(flow, listView.depthProperty().get());
|
||||
listView.depthProperty().addListener((o, oldVal, newVal) -> JFXDepthManager.setDepth(flow, newVal));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
final int itemsCount = getSkinnable().getItems().size();
|
||||
if (getSkinnable().maxHeightProperty().isBound() || itemsCount <= 0) {
|
||||
return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
|
||||
}
|
||||
|
||||
final double fixedCellSize = getSkinnable().getFixedCellSize();
|
||||
double computedHeight = fixedCellSize != Region.USE_COMPUTED_SIZE ?
|
||||
fixedCellSize * itemsCount + snapVerticalInsets() : estimateHeight();
|
||||
double height = super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
|
||||
if (height > computedHeight) {
|
||||
height = computedHeight;
|
||||
}
|
||||
|
||||
if (getSkinnable().getMaxHeight() > 0 && computedHeight > getSkinnable().getMaxHeight()) {
|
||||
return getSkinnable().getMaxHeight();
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
private double estimateHeight() {
|
||||
// compute the border/padding for the list
|
||||
double borderWidth = snapVerticalInsets();
|
||||
// compute the gap between list cells
|
||||
|
||||
JFXListView<T> listview = (JFXListView<T>) getSkinnable();
|
||||
double gap = listview.isExpanded() ? ((JFXListView<T>) getSkinnable()).getVerticalGap() * (getSkinnable().getItems()
|
||||
.size()) : 0;
|
||||
// compute the height of each list cell
|
||||
double cellsHeight = 0;
|
||||
for (int i = 0; i < flow.getCellCount(); i++) {
|
||||
ListCell<T> cell = flow.getCell(i);
|
||||
cellsHeight += cell.getHeight();
|
||||
}
|
||||
return cellsHeight + gap + borderWidth;
|
||||
}
|
||||
|
||||
private double snapVerticalInsets() {
|
||||
return getSkinnable().snappedBottomInset() + getSkinnable().snappedTopInset();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import org.jackhuang.hmcl.util.FileSaver;
|
||||
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
|
||||
import org.jackhuang.hmcl.util.SwingUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -28,6 +30,7 @@ import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -49,7 +52,7 @@ public final class EntryPoint {
|
||||
setupJavaFXVMOptions();
|
||||
checkDirectoryPath();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && !isInsideMacAppBundle())
|
||||
initIcon();
|
||||
|
||||
checkJavaFX();
|
||||
@@ -159,6 +162,25 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isInsideMacAppBundle() {
|
||||
Path thisJar = JarUtils.thisJarPath();
|
||||
if (thisJar == null)
|
||||
return false;
|
||||
|
||||
for (Path current = thisJar.getParent();
|
||||
current != null && current.getParent() != null;
|
||||
current = current.getParent()
|
||||
) {
|
||||
if ("Contents".equals(FileUtils.getName(current))
|
||||
&& FileUtils.getName(current.getParent()).endsWith(".app")
|
||||
&& Files.exists(current.resolve("Info.plist"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void initIcon() {
|
||||
try {
|
||||
if (java.awt.Taskbar.isTaskbarSupported()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -22,13 +22,19 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.paint.Color;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
@@ -51,8 +57,23 @@ public final class StyleSheets {
|
||||
config().themeProperty().addListener(o -> stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()));
|
||||
}
|
||||
|
||||
private static String toStyleSheetUri(String styleSheet) {
|
||||
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
|
||||
private static String toStyleSheetUri(String styleSheet, String fallback) {
|
||||
if (FXUtils.JAVAFX_MAJOR_VERSION >= 17)
|
||||
// JavaFX 17+ support loading stylesheets from data URIs
|
||||
// https://bugs.openjdk.org/browse/JDK-8267554
|
||||
return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8));
|
||||
else
|
||||
try {
|
||||
Path temp = Files.createTempFile("hmcl", ".css");
|
||||
// For JavaFX 17 or earlier, CssParser uses the default charset
|
||||
// https://bugs.openjdk.org/browse/JDK-8279328
|
||||
Files.writeString(temp, styleSheet, Charset.defaultCharset());
|
||||
temp.toFile().deleteOnExit();
|
||||
return temp.toUri().toString();
|
||||
} catch (IOException | NullPointerException e) {
|
||||
LOG.error("Unable to create stylesheet, fallback to " + fallback, e);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getFontStyleSheet() {
|
||||
@@ -102,7 +123,7 @@ public final class StyleSheets {
|
||||
|
||||
builder.append('}');
|
||||
|
||||
return toStyleSheetUri(builder.toString());
|
||||
return toStyleSheetUri(builder.toString(), defaultCss);
|
||||
}
|
||||
|
||||
private static String rgba(Color color, double opacity) {
|
||||
@@ -129,7 +150,7 @@ public final class StyleSheets {
|
||||
"-fx-base-disabled-text-fill:" + rgba(theme.getForegroundColor(), 0.7) + ";" +
|
||||
"-fx-base-text-fill:" + Theme.getColorDisplayName(theme.getForegroundColor()) + ";" +
|
||||
"-theme-thumb:" + rgba(theme.getPaint(), 0.7) + ";" +
|
||||
'}');
|
||||
'}', blueCss);
|
||||
}
|
||||
|
||||
public static void init(Scene scene) {
|
||||
|
||||
@@ -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) {
|
||||
@@ -48,8 +61,4 @@ public enum VersionIconType {
|
||||
public Image getIcon() {
|
||||
return FXUtils.newBuiltinImage(resourceUrl);
|
||||
}
|
||||
|
||||
public Image getIcon(int size) {
|
||||
return FXUtils.newBuiltinImage(resourceUrl, size, size, true, true);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
@@ -61,22 +62,20 @@ public final class TerracottaManager {
|
||||
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 {
|
||||
Schedulers.io().execute(() -> {
|
||||
try {
|
||||
if (TerracottaMetadata.PROVIDER == null)
|
||||
throw new IOException("Unsupported platform: " + org.jackhuang.hmcl.util.platform.Platform.CURRENT_PLATFORM);
|
||||
|
||||
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) {
|
||||
} catch (Exception e) {
|
||||
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
}
|
||||
|
||||
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
|
||||
@@ -143,12 +142,7 @@ public final class TerracottaManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState.Preparing preparing;
|
||||
if (state instanceof TerracottaState.Preparing it) {
|
||||
preparing = it;
|
||||
} else {
|
||||
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||
}
|
||||
TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
return file != null ? TarFileTree.open(file) : null;
|
||||
@@ -173,6 +167,7 @@ public final class TerracottaManager {
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching);
|
||||
}
|
||||
} else if (exception instanceof CancellationException) {
|
||||
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
|
||||
LOG.warning("Cannot install terracotta from local package.", exception);
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
@@ -183,7 +178,7 @@ public final class TerracottaManager {
|
||||
}
|
||||
}).start();
|
||||
|
||||
return setState(preparing);
|
||||
return compareAndSet(state, preparing) ? preparing : null;
|
||||
}
|
||||
|
||||
private static ITerracottaProvider getProvider() {
|
||||
|
||||
@@ -23,9 +23,12 @@ 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;
|
||||
@@ -41,7 +44,6 @@ 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;
|
||||
@@ -60,20 +62,25 @@ public final class TerracottaMetadata {
|
||||
|
||||
@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());
|
||||
for (String download : this.downloads) {
|
||||
private @Nullable TerracottaNative of(String classifier) {
|
||||
String hash = this.classifiers.get(classifier);
|
||||
if (hash == null)
|
||||
return null;
|
||||
|
||||
if (!hash.startsWith("sha256:"))
|
||||
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
|
||||
hash = hash.substring("sha256:".length());
|
||||
|
||||
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(
|
||||
@@ -86,7 +93,11 @@ public final class TerracottaMetadata {
|
||||
|
||||
public static final ITerracottaProvider PROVIDER;
|
||||
public static final String PACKAGE_NAME;
|
||||
private static final List<Link> PACKAGE_LINKS;
|
||||
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;
|
||||
@@ -131,42 +142,33 @@ public final class TerracottaMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Link> getPackageLinks() {
|
||||
return PACKAGE_LINKS;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
String arch = Architecture.SYSTEM_ARCH.getCheckedName();
|
||||
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;
|
||||
if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
|
||||
yield null;
|
||||
|
||||
TerracottaNative target = config.of("windows-%s.exe".formatted(arch));
|
||||
yield target != null
|
||||
? new ProviderContext(new GeneralProvider(target), "windows", arch)
|
||||
: null;
|
||||
}
|
||||
case LINUX -> {
|
||||
TerracottaNative target = config.of("linux-%s".formatted(arch));
|
||||
yield target != null
|
||||
? new ProviderContext(new GeneralProvider(target), "linux", arch)
|
||||
: null;
|
||||
}
|
||||
case MACOS -> {
|
||||
TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch));
|
||||
TerracottaNative binary = config.of("macos-%s".formatted(arch));
|
||||
|
||||
yield installer != null && binary != null
|
||||
? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch)
|
||||
: 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ public abstract sealed class TerracottaState {
|
||||
}
|
||||
|
||||
public boolean isRecoverable() {
|
||||
return this.type != Type.OS && this.type != Type.UNKNOWN;
|
||||
return this.type != Type.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
@@ -27,11 +29,13 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class MacOSProvider implements ITerracottaProvider {
|
||||
public final TerracottaNative installer, binary;
|
||||
@@ -62,19 +66,39 @@ public final class MacOSProvider implements ITerracottaProvider {
|
||||
|
||||
return Task.allOf(
|
||||
installerTask.thenComposeAsync(() -> {
|
||||
Path osascript = SystemUtils.which("osascript");
|
||||
if (osascript == null) {
|
||||
throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta.");
|
||||
}
|
||||
|
||||
Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg")
|
||||
.toRealPath()
|
||||
.resolve(FileUtils.getName(installer.getPath()));
|
||||
Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||
"osascript",
|
||||
"-e",
|
||||
String.format(
|
||||
"do shell script \"installer -pkg %s -target /Applications\" with prompt \"%s\" with administrator privileges",
|
||||
installer.getPath(),
|
||||
i18n("terracotta.sudo_installing")
|
||||
)
|
||||
));
|
||||
osascript.toString(), "-e", String.format(
|
||||
"do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges",
|
||||
pkg, i18n("terracotta.sudo_installing")
|
||||
)));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit());
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> {
|
||||
try {
|
||||
FileUtils.cleanDirectory(pkg.getParent());
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Cannot remove temporary Terracotta package file.", e);
|
||||
}
|
||||
|
||||
if (process.getExitCode() != 0) {
|
||||
throw new IllegalStateException(String.format(
|
||||
"Cannot install Terracotta %s: system installer exited with code %d",
|
||||
pkg,
|
||||
process.getExitCode()
|
||||
));
|
||||
}
|
||||
});
|
||||
}),
|
||||
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
|
||||
@@ -64,6 +64,7 @@ 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;
|
||||
@@ -433,26 +434,34 @@ public final class Controllers {
|
||||
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
||||
}
|
||||
|
||||
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> resolve.run());
|
||||
btnYes.setDisable(true);
|
||||
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");
|
||||
|
||||
int countdown = 10;
|
||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||
for (int i = 0; i < countdown; i++) {
|
||||
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(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||
new KeyValue(btnOk.textProperty(), i18n("button.ok.countdown", seconds - i)));
|
||||
}
|
||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnYes.disableProperty(), false));
|
||||
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.WARNING, btnYes, () -> {
|
||||
confirmAction(text, title, messageType, btnOk, () -> {
|
||||
timeline.stop();
|
||||
cancel.run();
|
||||
if (cancel != null)
|
||||
cancel.run();
|
||||
});
|
||||
timeline.play();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,6 +69,7 @@ public enum SVG {
|
||||
FOR_YOU("M12 12Q14.025 12 16.225 11.5875T20 10.5V20.5Q18.5 21.175 16.35 21.5875T12 22Q9.8 22 7.65 21.5875T4 20.5V10.5Q5.575 11.175 7.775 11.5875T12 12ZM18 19V13.25Q16.75 13.6 15.1125 13.8T12 14Q10.525 14 8.8875 13.8T6 13.25V19Q7.25 19.45 8.875 19.725T12 20Q13.5 20 15.125 19.725T18 19ZM12 2Q13.65 2 14.825 3.175T16 6Q16 7.65 14.825 8.825T12 10Q10.35 10 9.175 8.825T8 6Q8 4.35 9.175 3.175T12 2ZM12 8Q12.825 8 13.4125 7.4125T14 6Q14 5.175 13.4125 4.5875T12 4Q11.175 4 10.5875 4.5875T10 6Q10 6.825 10.5875 7.4125T12 8ZM12 6ZM12 16.625Z"),
|
||||
GAMEPAD("M12 7.65ZM16.35 12ZM7.65 12ZM12 16.35ZM12 10.5 9 7.5V2H15V7.5L12 10.5ZM16.5 15 13.5 12 16.5 9H22V15H16.5ZM2 15V9H7.5L10.5 12 7.5 15H2ZM9 22V16.5L12 13.5 15 16.5V22H9ZM12 7.65 13 6.65V4H11V6.65L12 7.65ZM4 13H6.65L7.65 12 6.65 11H4V13ZM11 20H13V17.35L12 16.35 11 17.35V20ZM17.35 13H20V11H17.35L16.35 12 17.35 13Z"),
|
||||
GLOBE_BOOK("M3.075 13Q3.05 12.75 3.0375 12.5T3.025 12Q3.025 10.125 3.725 8.4875T5.65 5.6375Q6.875 4.425 8.5 3.7125T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 12.25 20.9875 12.5T20.95 13H18.925Q18.975 12.75 18.9875 12.5T19 12Q19 11.75 18.9875 11.5T18.925 11H15.975Q16 11.25 16 11.5V12.5Q16 12.75 15.975 13H14V12.175Q14 11.875 13.9875 11.575T13.95 11H10.075Q10.05 11.275 10.0375 11.575T10.025 12.175V13H8.05Q8.025 12.75 8.025 12.5V11.5Q8.025 11.25 8.05 11H5.1Q5.05 11.25 5.0375 11.5T5.025 12Q5.025 12.25 5.0375 12.5T5.1 13H3.075ZM5.7 9H8.275Q8.475 7.925 8.775 7.0625T9.425 5.5Q8.225 5.95 7.25 6.8625T5.7 9ZM10.35 9H13.65Q13.4 7.925 13.025 6.9T12 5Q11.35 5.875 10.9625 6.9T10.35 9ZM15.75 9H18.325Q17.75 7.775 16.7625 6.8625T14.575 5.5Q14.925 6.25 15.2375 7.0875T15.75 9ZM11 21V20Q11 18.75 10.125 17.875T8 17H2V15H8Q9.2 15 10.2375 15.525T12 17Q12.725 16.05 13.7625 15.525T16 15H22V17H16Q14.75 17 13.875 17.875T13 20V21H11Z"),
|
||||
GRAPH2("M5 22q-1.25 0-2.125-.875T2 19q0-.975.5625-1.75T4 16.175V14q0-1.25.875-2.125T7 11h4V7.825q-.875-.3-1.4375-1.075T9 5q0-1.25.875-2.125T12 2t2.125.875T15 5q0 .975-.5625 1.75T13 7.825V11h4q1.25 0 2.125.875T20 14v2.175q.875.3 1.4375 1.075T22 19q0 1.25-.875 2.125T19 22t-2.125-.875T16 19q0-.975.5625-1.75T18 16.175V14q0-.425-.2875-.7125T17 13H13v3.175q.875.3 1.4375 1.075T15 19q0 1.25-.875 2.125T12 22t-2.125-.875T9 19q0-.975.5625-1.75T11 16.175V13H7q-.425 0-.7125.2875T6 14v2.175q.875.3 1.4375 1.075T8 19q0 1.25-.875 2.125T5 22Zm0-2q.425 0 .7125-.2875T6 19q0-.425-.2875-.7125T5 18q-.425 0-.7125.2875T4 19q0 .425.2875.7125T5 20Zm7 0q.425 0 .7125-.2875T13 19q0-.425-.2875-.7125T12 18q-.425 0-.7125.2875T11 19q0 .425.2875.7125T12 20Zm7 0q.425 0 .7125-.2875T20 19q0-.425-.2875-.7125T19 18q-.425 0-.7125.2875T18 19q0 .425.2875.7125T19 20ZM12 6q.425 0 .7125-.2875T13 5q0-.425-.2875-.7125T12 4q-.425 0-.7125.2875T11 5q0 .425.2875.7125T12 6Z"),
|
||||
HELP("M11.95 18Q12.475 18 12.8375 17.6375T13.2 16.75Q13.2 16.225 12.8375 15.8625T11.95 15.5Q11.425 15.5 11.0625 15.8625T10.7 16.75Q10.7 17.275 11.0625 17.6375T11.95 18ZM11.05 14.15H12.9Q12.9 13.325 13.0875 12.85T14.15 11.55Q14.8 10.9 15.175 10.3125T15.55 8.9Q15.55 7.5 14.525 6.75T12.1 6Q10.675 6 9.7875 6.75T8.55 8.55L10.2 9.2Q10.325 8.75 10.7625 8.225T12.1 7.7Q12.9 7.7 13.3 8.1375T13.7 9.1Q13.7 9.6 13.4 10.0375T12.65 10.85Q11.55 11.825 11.3 12.325T11.05 14.15ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
|
||||
HOME("M6 19H9V13H15V19H18V10L12 5.5 6 10V19ZM4 21V9L12 3 20 9V21H13V15H11V21H4ZM12 12.25Z"),
|
||||
HOST("M4 21Q3.175 21 2.5875 20.4125T2 19V5Q2 4.175 2.5875 3.5875T4 3H9Q9.825 3 10.4125 3.5875T11 5V19Q11 19.825 10.4125 20.4125T9 21H4ZM15 21Q14.175 21 13.5875 20.4125T13 19V5Q13 4.175 13.5875 3.5875T15 3H20Q20.825 3 21.4125 3.5875T22 5V19Q22 19.825 21.4125 20.4125T20 21H15ZM4 19H9V5H4V19ZM15 19H20V5H15V19ZM5 15H8V13H5V15ZM16 15H19V13H16V15ZM5 12H8V10H5V12ZM16 12H19V10H16V12ZM5 9H8V7H5V9ZM16 9H19V7H16V9ZM4 19H9 4ZM15 19H20 15Z"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,18 @@ import com.jfoenix.controls.JFXDialogLayout;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
|
||||
import org.jackhuang.hmcl.upgrade.RemoteVersion;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Node;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
|
||||
@@ -40,6 +42,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class UpgradeDialog extends JFXDialogLayout {
|
||||
|
||||
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
|
||||
maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7));
|
||||
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
|
||||
@@ -48,22 +51,21 @@ 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);
|
||||
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 current changelog in document");
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.getVersion());
|
||||
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
|
||||
if (targetVersion.compareTo(currentVersion) <= 0)
|
||||
// Downgrade update, no need to display changelog
|
||||
return null;
|
||||
|
||||
Document document = Jsoup.parse(new URL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
LOG.warning("Changelog not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
@@ -71,8 +73,11 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
});
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
|
||||
break;
|
||||
if ("h1".equals(node.nodeName())) {
|
||||
String changelogVersion = node.attr("data-version");
|
||||
if (StringUtils.isBlank(changelogVersion) || currentVersion.compareTo(changelogVersion) >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
renderer.appendNode(node);
|
||||
node = node.nextSibling();
|
||||
@@ -82,10 +87,14 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
return renderer.render();
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
ScrollPane scrollPane = new ScrollPane(result);
|
||||
scrollPane.setFitToWidth(true);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
setBody(scrollPane);
|
||||
if (result != null) {
|
||||
ScrollPane scrollPane = new ScrollPane(result);
|
||||
scrollPane.setFitToWidth(true);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
setBody(scrollPane);
|
||||
} else {
|
||||
setBody();
|
||||
}
|
||||
} else {
|
||||
LOG.warning("Failed to load update log, trying to open it in browser");
|
||||
FXUtils.openLink(url);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -264,10 +264,12 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
};
|
||||
|
||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
});
|
||||
Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
});
|
||||
} else {
|
||||
doCreate.run();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -86,18 +87,7 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
});
|
||||
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
String unit = "B/s";
|
||||
double speed = speedEvent.getSpeed();
|
||||
if (speed > 1024) {
|
||||
speed /= 1024;
|
||||
unit = "KiB/s";
|
||||
}
|
||||
if (speed > 1024) {
|
||||
speed /= 1024;
|
||||
unit = "MiB/s";
|
||||
}
|
||||
|
||||
String message = String.format("%.1f %s", speed, unit);
|
||||
String message = I18n.formatSpeed(speedEvent.getSpeed());
|
||||
Platform.runLater(() -> lblProgress.setText(message));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -138,8 +138,11 @@ public final class AboutPage extends StackPane {
|
||||
else if (obj.has("subtitleLocalized"))
|
||||
item.setSubtitle(i18n(obj.get("subtitleLocalized").getAsString()));
|
||||
|
||||
if (obj.has("externalLink"))
|
||||
item.setExternalLink(obj.get("externalLink").getAsString());
|
||||
if (obj.has("externalLink")) {
|
||||
String link = obj.get("externalLink").getAsString();
|
||||
item.setExternalLink(link);
|
||||
FXUtils.installFastTooltip(item.getExternalLinkButton(), link);
|
||||
}
|
||||
|
||||
componentList.getContent().add(item);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
@@ -49,6 +50,7 @@ import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
|
||||
import java.nio.file.Files;
|
||||
@@ -183,10 +185,28 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
|
||||
// sixth item in left sidebar
|
||||
AdvancedListItem terracottaItem = new AdvancedListItem();
|
||||
terracottaItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
terracottaItem.setLeftGraphic(wrap(SVG.GRAPH2));
|
||||
terracottaItem.setActionButtonVisible(false);
|
||||
terracottaItem.setTitle(i18n("terracotta"));
|
||||
terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage()));
|
||||
|
||||
terracottaItem.setOnAction(e -> {
|
||||
if (TerracottaMetadata.PROVIDER != null) {
|
||||
Controllers.navigate(Controllers.getTerracottaPage());
|
||||
} else {
|
||||
String message;
|
||||
if (Architecture.SYSTEM_ARCH.getBits() == Bits.BIT_32)
|
||||
message = i18n("terracotta.unsupported.arch.32bit");
|
||||
else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS
|
||||
&& !OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10))
|
||||
message = i18n("terracotta.unsupported.os.windows.old");
|
||||
else if (Platform.SYSTEM_PLATFORM.equals(OperatingSystem.LINUX, Architecture.LOONGARCH64_OW))
|
||||
message = i18n("terracotta.unsupported.arch.loongarch64_ow");
|
||||
else
|
||||
message = i18n("terracotta.unsupported");
|
||||
|
||||
Controllers.dialog(message, null, MessageDialogPane.MessageType.WARNING);
|
||||
}
|
||||
});
|
||||
|
||||
// the left sidebar
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
@@ -198,7 +218,8 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
.add(downloadItem)
|
||||
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
||||
.add(launcherSettingsItem)
|
||||
.add(terracottaItem);
|
||||
.add(terracottaItem)
|
||||
.addNavigationDrawerItem(i18n("chat"), SVG.CHAT, () -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
|
||||
// the root page, with the sidebar in left, navigator in center.
|
||||
setLeft(sideBar);
|
||||
|
||||
@@ -152,6 +152,7 @@ public abstract class SettingsView extends StackPane {
|
||||
previewPane = new OptionToggleButton();
|
||||
previewPane.setTitle(i18n("update.preview"));
|
||||
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
|
||||
FXUtils.installFastTooltip(previewPane, i18n("update.preview.tooltip"));
|
||||
|
||||
settingsPane.getContent().add(previewPane);
|
||||
}
|
||||
|
||||
+111
-48
@@ -22,6 +22,8 @@ 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;
|
||||
@@ -32,15 +34,14 @@ import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
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;
|
||||
@@ -79,10 +80,14 @@ 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 {
|
||||
@@ -130,13 +135,13 @@ public class TerracottaControllerPage extends StackPane {
|
||||
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.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, () -> {
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -177,13 +182,25 @@ public class TerracottaControllerPage extends StackPane {
|
||||
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
|
||||
download.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(download, () -> {
|
||||
if (uninitialized.hasLegacy()) {
|
||||
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.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
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);
|
||||
@@ -265,7 +282,18 @@ public class TerracottaControllerPage extends StackPane {
|
||||
});
|
||||
});
|
||||
|
||||
nodesProperty.setAll(flow, host, guest);
|
||||
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);
|
||||
@@ -303,10 +331,15 @@ public class TerracottaControllerPage extends StackPane {
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.HostOK hostOK) {
|
||||
if (hostOK.isForkOf(legacyState)) {
|
||||
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(hostOK.getProfiles());
|
||||
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);
|
||||
@@ -315,36 +348,24 @@ public class TerracottaControllerPage extends StackPane {
|
||||
code.setAlignment(Pos.CENTER);
|
||||
{
|
||||
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
|
||||
{
|
||||
ClipboardContent cp = new ClipboardContent();
|
||||
cp.putString(cs);
|
||||
Clipboard.getSystemClipboard().setContent(cp);
|
||||
}
|
||||
desc.setMouseTransparent(true);
|
||||
|
||||
// FIXME: The implementation to display Room Code is ambiguous. Consider using a clearer JavaFX Element in the future.
|
||||
TextField label = new TextField(cs);
|
||||
label.setEditable(false);
|
||||
label.setFocusTraversable(false);
|
||||
Label label = new Label(cs);
|
||||
label.setMouseTransparent(true);
|
||||
label.setStyle("-fx-font-size: 24");
|
||||
label.setAlignment(Pos.CENTER);
|
||||
label.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;");
|
||||
VBox.setMargin(label, new Insets(10, 0, 10, 0));
|
||||
label.setScaleX(1.8);
|
||||
label.setScaleY(1.8);
|
||||
holder.add(FXUtils.onWeakChange(label.selectedTextProperty(), string -> {
|
||||
if (string != null && !string.isEmpty() && !cs.equals(string)) {
|
||||
label.selectAll();
|
||||
}
|
||||
}));
|
||||
|
||||
code.getChildren().setAll(desc, label);
|
||||
}
|
||||
FXUtils.onClicked(code, () -> FXUtils.copyText(cs));
|
||||
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, () -> FXUtils.copyText(cs));
|
||||
FXUtils.onClicked(copy, () -> copyCode(cs));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
@@ -357,7 +378,11 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
|
||||
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"));
|
||||
@@ -377,7 +402,11 @@ public class TerracottaControllerPage extends StackPane {
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.GuestOK guestOK) {
|
||||
if (guestOK.isForkOf(legacyState)) {
|
||||
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(guestOK.getProfiles());
|
||||
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"));
|
||||
@@ -398,7 +427,11 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
|
||||
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)));
|
||||
@@ -530,7 +563,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
header.setSubtitle(i18n("terracotta.from_local.desc"));
|
||||
locals.setHeaderLeft(header);
|
||||
|
||||
for (TerracottaMetadata.Link link : TerracottaMetadata.getPackageLinks()) {
|
||||
for (TerracottaMetadata.Link link : TerracottaMetadata.PACKAGE_LINKS) {
|
||||
HBox node = new HBox();
|
||||
node.setAlignment(Pos.CENTER_LEFT);
|
||||
node.setPadding(new Insets(10, 16, 10, 16));
|
||||
@@ -554,12 +587,17 @@ public class TerracottaControllerPage extends StackPane {
|
||||
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 TwoLineListItem middle = new TwoLineListItem();
|
||||
private final ObjectProperty<Node> left = new SimpleObjectProperty<>();
|
||||
private final ObjectProperty<Node> right = new SimpleObjectProperty<>();
|
||||
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);
|
||||
@@ -584,7 +622,35 @@ public class TerracottaControllerPage extends StackPane {
|
||||
nodes.add(left);
|
||||
}
|
||||
|
||||
nodes.add(button.middle);
|
||||
{
|
||||
// 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();
|
||||
@@ -593,7 +659,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
|
||||
container.getChildren().setAll(nodes);
|
||||
}, button.middle.titleProperty(), button.middle.subtitleProperty(), button.left, button.right));
|
||||
}, button.title, button.subTitle, button.left, button.right));
|
||||
button.getProperties().put("ComponentList.noPadding", true);
|
||||
|
||||
return button;
|
||||
@@ -604,11 +670,11 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.middle.setTitle(title);
|
||||
this.title.set(title);
|
||||
}
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
this.middle.setSubtitle(subtitle);
|
||||
this.subTitle.set(subtitle);
|
||||
}
|
||||
|
||||
public void setLeftImage(Image left) {
|
||||
@@ -648,10 +714,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.setTitle(profile.getName());
|
||||
item.setSubtitle(profile.getVendor());
|
||||
item.getTags().setAll(TwoLineListItem.createTagLabel(
|
||||
i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)))
|
||||
);
|
||||
|
||||
item.addTag(i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)));
|
||||
pane.getChildren().add(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,21 +19,30 @@ package org.jackhuang.hmcl.ui.terracotta;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
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.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.AdvancedListItem;
|
||||
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 java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||
@@ -42,6 +51,9 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
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);
|
||||
@@ -52,25 +64,34 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
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 easytierItem = new AdvancedListItem();
|
||||
easytierItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
easytierItem.setActionButtonVisible(false);
|
||||
easytierItem.setTitle(i18n("terracotta.easytier"));
|
||||
easytierItem.setOnAction(e -> FXUtils.openLink("https://easytier.cn/"));
|
||||
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)
|
||||
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
|
||||
.add(chatItem)
|
||||
.add(easytierItem);
|
||||
FXUtils.setLimitWidth(sideBar, 200);
|
||||
setLeft(sideBar);
|
||||
.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/"));
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
left.setBottom(toolbar);
|
||||
|
||||
setCenter(transitionPane);
|
||||
}
|
||||
|
||||
@@ -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,18 +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.ScrollPane;
|
||||
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;
|
||||
@@ -37,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;
|
||||
@@ -55,32 +55,28 @@ 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;
|
||||
|
||||
@@ -97,6 +93,9 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
// FXThread
|
||||
private boolean isSearching = false;
|
||||
|
||||
@SuppressWarnings({"FieldCanBeLocal", "unused"})
|
||||
private final ChangeListener<Boolean> holder;
|
||||
|
||||
ModListPageSkin(ModListPage skinnable) {
|
||||
super(skinnable);
|
||||
|
||||
@@ -108,6 +107,12 @@ final 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();
|
||||
|
||||
@@ -145,7 +150,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
toolbarNormal.getChildren().setAll(
|
||||
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
|
||||
createToolbarButton2(i18n("mods.add"), SVG.ADD, skinnable::add),
|
||||
createToolbarButton2(i18n("folder.mod"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openModFolder),
|
||||
createToolbarButton2(i18n("mods.check_updates"), SVG.UPDATE, skinnable::checkUpdates),
|
||||
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, skinnable::download),
|
||||
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
|
||||
@@ -275,19 +280,41 @@ final 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Image> loadModIcon(LocalModFile modFile, int size) {
|
||||
return Task.supplyAsync(() -> {
|
||||
static final class ModInfoObject {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final @Nullable ModTranslations.Mod modTranslations;
|
||||
|
||||
private SoftReference<CompletableFuture<Image>> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName());
|
||||
}
|
||||
|
||||
LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public @Nullable ModTranslations.Mod getModTranslations() {
|
||||
return modTranslations;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
private Image loadIcon() {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(modFile.getLogoPath())) {
|
||||
iconPaths.add(modFile.getLogoPath());
|
||||
if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) {
|
||||
iconPaths.add(this.localModFile.getLogoPath());
|
||||
}
|
||||
|
||||
iconPaths.addAll(List.of(
|
||||
@@ -311,7 +338,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
"resources/mod_icon.png"
|
||||
));
|
||||
|
||||
String modId = modFile.getId();
|
||||
String modId = this.localModFile.getId();
|
||||
if (StringUtils.isNotBlank(modId)) {
|
||||
iconPaths.addAll(List.of(
|
||||
"assets/" + modId + "/icon.png",
|
||||
@@ -330,14 +357,12 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
));
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modFile.getFile())) {
|
||||
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, size, size, true, true);
|
||||
if (!image.isError() &&
|
||||
image.getWidth() > 0 &&
|
||||
image.getHeight() > 0 &&
|
||||
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;
|
||||
}
|
||||
@@ -347,102 +372,68 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
LOG.warning("Failed to load mod icons", e);
|
||||
}
|
||||
|
||||
VersionIconType defaultIcon = switch (modFile.getModLoaderType()) {
|
||||
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;
|
||||
};
|
||||
return VersionIconType.getIconType(this.localModFile.getModLoaderType()).getIcon();
|
||||
}
|
||||
|
||||
return defaultIcon.getIcon(size);
|
||||
});
|
||||
}
|
||||
|
||||
static class ModInfoObject extends RecursiveTreeObject<ModInfoObject> implements Comparable<ModInfoObject> {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
private final String title;
|
||||
private final String message;
|
||||
private final ModTranslations.Mod mod;
|
||||
|
||||
private SoftReference<Image> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
this.title = localModFile.getName();
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (isNotBlank(localModFile.getId())) {
|
||||
parts.add(localModFile.getId());
|
||||
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);
|
||||
}
|
||||
if (isNotBlank(localModFile.getVersion())) {
|
||||
parts.add(localModFile.getVersion());
|
||||
}
|
||||
if (isNotBlank(localModFile.getGameVersion())) {
|
||||
parts.add(i18n("game.version") + ": " + localModFile.getGameVersion());
|
||||
}
|
||||
this.message = String.join(", ", parts);
|
||||
this.mod = ModTranslations.MOD.getModById(localModFile.getId());
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
String getSubtitle() {
|
||||
return message;
|
||||
}
|
||||
|
||||
LocalModFile getModInfo() {
|
||||
return localModFile;
|
||||
}
|
||||
|
||||
public ModTranslations.Mod getMod() {
|
||||
return mod;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NotNull ModListPageSkin.ModInfoObject o) {
|
||||
return localModFile.getFileName().toLowerCase().compareTo(o.localModFile.getFileName().toLowerCase());
|
||||
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();
|
||||
FXUtils.limitSize(imageView, 40, 40);
|
||||
loadModIcon(modInfo.getModInfo(), 40)
|
||||
.whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
imageView.setImage(image);
|
||||
}).start();
|
||||
modInfo.loadIcon(imageView, null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
if (modInfo.getMod() != null) {
|
||||
title.addTag(modInfo.getMod().getDisplayName());
|
||||
}
|
||||
if (modInfo.getModTranslations() != null && I18n.isUseChinese())
|
||||
title.setTitle(modInfo.getModTranslations().getDisplayName());
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
List<String> subtitleParts = new ArrayList<>();
|
||||
subtitleParts.add(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
StringJoiner subtitle = new StringJoiner(" | ");
|
||||
subtitle.add(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitleParts.add(modInfo.getModInfo().getGameVersion());
|
||||
subtitle.add(modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitleParts.add(modInfo.getModInfo().getVersion());
|
||||
subtitle.add(modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitleParts.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(String.join(", ", subtitleParts));
|
||||
title.setSubtitle(subtitle.toString());
|
||||
|
||||
titleContainer.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), title);
|
||||
setHeading(titleContainer);
|
||||
@@ -456,6 +447,11 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
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);
|
||||
|
||||
@@ -507,7 +503,7 @@ final 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")
|
||||
));
|
||||
});
|
||||
@@ -530,7 +526,7 @@ final 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());
|
||||
@@ -545,7 +541,7 @@ final 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);
|
||||
}
|
||||
@@ -564,6 +560,8 @@ final 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();
|
||||
@@ -572,9 +570,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
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);
|
||||
@@ -585,7 +587,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
imageView.setFitWidth(24);
|
||||
imageView.setFitHeight(24);
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 24, 24, true, true));
|
||||
imageView.setImage(VersionIconType.COMMAND.getIcon());
|
||||
|
||||
restoreButton.getStyleClass().add("toggle-icon4");
|
||||
restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(Theme.blackFill(), 24), 24, 24));
|
||||
@@ -606,68 +608,113 @@ final 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;
|
||||
|
||||
SoftReference<Image> iconCache = dataItem.iconCache;
|
||||
Image icon;
|
||||
if (iconCache != null && (icon = iconCache.get()) != null) {
|
||||
imageView.setImage(icon);
|
||||
} else {
|
||||
loadModIcon(dataItem.getModInfo(), 24)
|
||||
.whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
dataItem.iconCache = new SoftReference<>(image);
|
||||
imageView.setImage(image);
|
||||
}).start();
|
||||
List<String> warning = new ArrayList<>();
|
||||
|
||||
content.getTags().clear();
|
||||
|
||||
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 + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
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.setTitle(dataItem.getTitle());
|
||||
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;
|
||||
}
|
||||
if (dataItem.getMod() != null && I18n.isUseChinese()) {
|
||||
if (isNotBlank(dataItem.getSubtitle())) {
|
||||
content.setSubtitle(dataItem.getSubtitle() + ", " + dataItem.getMod().getDisplayName());
|
||||
} else {
|
||||
content.setSubtitle(dataItem.getMod().getDisplayName());
|
||||
}
|
||||
} else {
|
||||
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];
|
||||
|
||||
@@ -24,7 +24,6 @@ import javafx.beans.property.*;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -238,10 +237,6 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
super(control);
|
||||
|
||||
{
|
||||
BorderPane left = new BorderPane();
|
||||
FXUtils.setLimitWidth(left, 200);
|
||||
setLeft(left);
|
||||
|
||||
AdvancedListItem versionSettingsItem = new AdvancedListItem();
|
||||
versionSettingsItem.getStyleClass().add("navigation-drawer-item");
|
||||
versionSettingsItem.setTitle(i18n("settings.game"));
|
||||
|
||||
@@ -222,9 +222,14 @@ public final class Versions {
|
||||
|
||||
private static boolean checkVersionForLaunching(Profile profile, String id) {
|
||||
if (id == null || !profile.getRepository().isLoaded() || !profile.getRepository().hasVersion(id)) {
|
||||
Controllers.dialog(i18n("version.empty.launch"), i18n("launch.failed"), MessageDialogPane.MessageType.ERROR, () -> {
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
});
|
||||
JFXButton gotoDownload = new JFXButton(i18n("version.empty.launch.goto_download"));
|
||||
gotoDownload.getStyleClass().add("dialog-accept");
|
||||
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
|
||||
|
||||
Controllers.confirmAction(i18n("version.empty.launch"), i18n("launch.failed"),
|
||||
MessageDialogPane.MessageType.ERROR,
|
||||
gotoDownload,
|
||||
null);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
|
||||
@@ -30,9 +30,9 @@ public final class ChunkBaseApp {
|
||||
private static final GameVersionNumber MIN_GAME_VERSION = GameVersionNumber.asGameVersion("1.7");
|
||||
|
||||
private static final String[] SEED_MAP_GAME_VERSIONS = {
|
||||
"1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21", "1.20", "1.19.3",
|
||||
"1.19", "1.18", "1.17", "1.16", "1.15", "1.14", "1.13", "1.12",
|
||||
"1.11", "1.10", "1.9", "1.8", "1.7"
|
||||
"1.21.9", "1.21.6", "1.21.5", "1.21.4", "1.21.2", "1.21", "1.20",
|
||||
"1.19.3", "1.19", "1.18", "1.17", "1.16", "1.15", "1.14", "1.13",
|
||||
"1.12", "1.11", "1.10", "1.9", "1.8", "1.7"
|
||||
};
|
||||
|
||||
public static final String[] STRONGHOLD_FINDER_GAME_VERSIONS = {
|
||||
|
||||
@@ -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,85 @@ 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";
|
||||
maxVersion = "1.21.5";
|
||||
} 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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,15 @@ public final class I18n {
|
||||
}
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
return locale.formatDateTime(time);
|
||||
return getTranslator().formatDateTime(time);
|
||||
}
|
||||
|
||||
public static String formatSpeed(long bytes) {
|
||||
return getTranslator().formatSpeed(bytes);
|
||||
}
|
||||
|
||||
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,71 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
public String formatSpeed(long bytes) {
|
||||
if (bytes < 1024) {
|
||||
return supportedLocale.i18n("download.speed.byte_per_second", bytes);
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return supportedLocale.i18n("download.speed.kibibyte_per_second", (double) bytes / 1024);
|
||||
} else {
|
||||
return supportedLocale.i18n("download.speed.megabyte_per_second", (double) bytes / (1024 * 1024));
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-14
@@ -15,28 +15,29 @@
|
||||
* 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;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public final class UpsideDownUtils {
|
||||
private static final Map<Integer, Integer> MAPPER = loadMap();
|
||||
public class Translator_en_Qabs extends Translator {
|
||||
private static final Map<Integer, Integer> MAPPER;
|
||||
|
||||
private static Map<Integer, Integer> loadMap() {
|
||||
static {
|
||||
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 -> {
|
||||
@@ -56,7 +57,7 @@ public final class UpsideDownUtils {
|
||||
} else {
|
||||
LOG.warning("upside_down.txt not found");
|
||||
}
|
||||
return Collections.unmodifiableMap(map);
|
||||
MAPPER = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
public static String translate(String str) {
|
||||
@@ -65,13 +66,24 @@ 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());
|
||||
private final SupportedLocale originalLocale = SupportedLocale.getLocale(Locale.ENGLISH);
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
return translate(BASE_FORMATTER.format(time));
|
||||
public Translator_en_Qabs(SupportedLocale locale) {
|
||||
super(locale);
|
||||
}
|
||||
|
||||
private UpsideDownUtils() {
|
||||
@Override
|
||||
public String getDisplayVersion(RemoteVersion remoteVersion) {
|
||||
return translate(remoteVersion.getSelfVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
return translate(originalLocale.getTranslator().formatDateTime(time));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatSpeed(long bytes) {
|
||||
return translate(originalLocale.getTranslator().formatSpeed(bytes));
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@
|
||||
|
||||
.class-title {
|
||||
-fx-font-size: 12px;
|
||||
-fx-padding: 16 16 8 16;
|
||||
-fx-padding: 8 16 8 16;
|
||||
}
|
||||
|
||||
.advanced-list-box-item {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ button.export=Export
|
||||
button.no=No
|
||||
button.ok=OK
|
||||
button.ok.countdown=OK (%d)
|
||||
button.reveal_dir=Reveal
|
||||
button.refresh=Refresh
|
||||
button.remove=Remove
|
||||
button.remove.confirm=Are you sure you want to permanently remove it? This action cannot be undone!
|
||||
@@ -359,6 +360,9 @@ download.javafx.notes=We are currently downloading dependencies for HMCL from th
|
||||
Note: If your download speed is too slow, you can try switching to another mirror.
|
||||
download.javafx.component=Downloading module "%s"
|
||||
download.javafx.prepare=Preparing to download
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=HMCL is unable to access the file "%s". It may be locked by another process.\n\
|
||||
\n\
|
||||
@@ -717,10 +721,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
|
||||
@@ -1055,7 +1059,7 @@ mods.category=Category
|
||||
mods.channel.alpha=Alpha
|
||||
mods.channel.beta=Beta
|
||||
mods.channel.release=Release
|
||||
mods.check_updates=Check for Updates
|
||||
mods.check_updates=Update
|
||||
mods.check_updates.current_version=Current Version
|
||||
mods.check_updates.empty=All mods are up-to-date
|
||||
mods.check_updates.failed_check=Failed to check for updates.
|
||||
@@ -1090,8 +1094,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
|
||||
@@ -1418,6 +1424,8 @@ 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.
|
||||
@@ -1432,7 +1440,7 @@ 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, which has little relationship with HMCL.\n\
|
||||
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)
|
||||
@@ -1456,6 +1464,7 @@ 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
|
||||
@@ -1472,7 +1481,6 @@ terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier c
|
||||
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.
|
||||
@@ -1482,6 +1490,10 @@ terracotta.player_anonymous=Anonymous Player
|
||||
terracotta.player_kind.host=Host
|
||||
terracotta.player_kind.local=Yourself
|
||||
terracotta.player_kind.guest=Guest
|
||||
terracotta.unsupported=Multiplayer is not yet supported on the current platform.
|
||||
terracotta.unsupported.os.windows.old=Multiplayer requires Windows 10 or later. Please update your system.
|
||||
terracotta.unsupported.arch.32bit=Multiplayer is not supported on 32-bit systems. Please upgrade to a 64-bit system.
|
||||
terracotta.unsupported.arch.loongarch64_ow=Multiplayer is not supported on Linux LoongArch64 Old World distributions. Please update to a New World distribution (such as AOSC OS).
|
||||
|
||||
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
|
||||
|
||||
@@ -1513,14 +1525,16 @@ 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=Try out the HMCL version
|
||||
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
|
||||
version.cannot_read=Failed to parse the game instance, installation cannot continue.
|
||||
version.empty=No Instances
|
||||
version.empty.add=Add new instance
|
||||
version.empty.launch=No available instances. Clicking "OK" will take you to the "Download" page.\n\nYou can also download the game or switch game directories via the "Download" or "All Instances" buttons on the HMCL homepage.
|
||||
version.empty.launch=No available instances.\nYou can go to the "Download" page to get the game, or switch the game directory in the "All Instances" page.
|
||||
version.empty.launch.goto_download=Go to Download Page
|
||||
version.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to download one.
|
||||
version.game.all=All
|
||||
version.game.april_fools=April Fools
|
||||
@@ -1529,6 +1543,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
|
||||
|
||||
@@ -187,6 +187,7 @@ button.export=Exportar
|
||||
button.no=No
|
||||
button.ok=Aceptar
|
||||
button.ok.countdown=Aceptar (%d)
|
||||
button.reveal_dir=Carpeta
|
||||
button.refresh=Refrescar
|
||||
button.remove=Eliminar
|
||||
button.remove.confirm=¿Estás seguro de que deseas eliminarlo de forma permanente? ¡Esta acción no se puede deshacer!
|
||||
@@ -723,10 +724,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
|
||||
@@ -1061,7 +1062,7 @@ mods.category=Categoría
|
||||
mods.channel.alpha=Alpha
|
||||
mods.channel.beta=Beta
|
||||
mods.channel.release=Release
|
||||
mods.check_updates=Comprobar actualizaciones
|
||||
mods.check_updates=Actualizar
|
||||
mods.check_updates.current_version=Versión actual
|
||||
mods.check_updates.empty=Todos los mods están actualizados
|
||||
mods.check_updates.failed_check=No se ha podido comprobar si hay actualizaciones.
|
||||
@@ -1444,13 +1445,14 @@ 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
|
||||
version.cannot_read=No se ha podido analizar la instancia del juego, la instalación no puede continuar.
|
||||
version.empty=No hay instancias
|
||||
version.empty.add=Añadir una instancia
|
||||
version.empty.launch=No hay instancias disponibles. Si haces clic en «Aceptar», accederás a la página «Descargar». También puedes descargar el juego o cambiar de directorio de juego con los botones «Descargar» o «Todas las instancias» de la página principal de HMCL.
|
||||
version.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para descargar una.
|
||||
version.game.all=Todos
|
||||
version.game.april_fools=Día de los Inocentes
|
||||
|
||||
@@ -929,7 +929,6 @@ version=ゲーム
|
||||
version.cannot_read=ゲームのバージョンが見つかりません。自動インストールを続行できません。
|
||||
version.empty=ゲームなし
|
||||
version.empty.add=起動構成を作成
|
||||
version.empty.launch=起動するバージョンがありません。ダウンロードページからバージョンをインストールしてください。
|
||||
version.empty.hint=インストールされているゲームバージョンはありません。新しいゲームをダウンロードするには、ここをクリックしてください。
|
||||
version.game.old=Old
|
||||
version.game.release=リリース
|
||||
|
||||
@@ -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
|
||||
@@ -1240,7 +1240,6 @@ version.name=戲例之名
|
||||
version.cannot_read=不能閲戲例,無自裝矣
|
||||
version.empty=無游戲例
|
||||
version.empty.add=入引頁以裝戲
|
||||
version.empty.launch=無可啟者。擊「善」入「引」之頁。\n庶擊 HMCL 主頁之左「引」之鈕裝戲,抑更戲之案夾乎「例列」。
|
||||
version.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以往戲之引頁。
|
||||
version.game.all=全覽
|
||||
version.game.april_fools=愚人節
|
||||
|
||||
@@ -360,6 +360,9 @@ download.javafx.notes=Скачивание зависимостей для ла
|
||||
Примечание: Если ваша скорость скачивания слишком низкая, вы можете попробовать переключиться на другое зеркало.
|
||||
download.javafx.component=Скачивание модуля «%s»
|
||||
download.javafx.prepare=Подготовка к скачиванию
|
||||
download.speed.byte_per_second=%d Б/сек
|
||||
download.speed.kibibyte_per_second=%.1f КиБ/сек
|
||||
download.speed.megabyte_per_second=%.1f МиБ/сек
|
||||
|
||||
exception.access_denied=Лаунчер не может получить доступ к файлу «%s», возможно он занят другим процессом.\n\
|
||||
\n\
|
||||
@@ -714,10 +717,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
|
||||
@@ -1440,7 +1443,6 @@ version.name=Название сборки
|
||||
version.cannot_read=Невозможно найти версию игры. Невозможно продолжить автоматическую установку.
|
||||
version.empty=Нет сборок
|
||||
version.empty.add=Установить новую сборок
|
||||
version.empty.launch=Нет доступных сборок. Нажав «ОК», вы перейдете на страницу «Скачать»\n\nВы также можете скачать игру или переключить директорию игры с помощью кнопок «Скачать» или «Все сборок» на главной странице HMCL.
|
||||
version.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы скачать игру.
|
||||
version.game.all=Все
|
||||
version.game.april_fools=День смеха
|
||||
|
||||
@@ -356,6 +356,10 @@ download.javafx.notes=Ми зараз завантажуємо залежнос
|
||||
Примітка: Якщо ваша швидкість завантаження занадто повільна, ви можете спробувати переключитися на інше дзеркало.
|
||||
download.javafx.component=Завантаження модуля "%s"
|
||||
download.javafx.prepare=Підготовка до завантаження
|
||||
download.speed.byte_per_second=%d Б/сек
|
||||
download.speed.kibibyte_per_second=%.1f КiБ/сек
|
||||
download.speed.megabyte_per_second=%.1f МiБ/сек
|
||||
|
||||
exception.access_denied=HMCL не може отримати доступ до файлу "%s". Можливо, він заблокований іншим процесом.\n\
|
||||
\n\
|
||||
Для користувачів Windows ви можете відкрити "Монітор ресурсів", щоб перевірити, чи не використовується він іншим процесом. Якщо так, ви можете спробувати знову після завершення цього процесу.\n\
|
||||
@@ -659,10 +663,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
|
||||
@@ -1385,7 +1389,6 @@ version.name=Назва екземпляра
|
||||
version.cannot_read=Не вдалося розібрати екземпляр гри, встановлення не може продовжуватися.
|
||||
version.empty=Немає екземплярів
|
||||
version.empty.add=Додати новий екземпляр
|
||||
version.empty.launch=Немає доступних екземплярів. Натискання "ОК" перенесе вас на сторінку "Завантаження". Ви також можете завантажити гру або змінити ігрові каталоги через кнопки "Завантаження" або "Усі екземпляри" на головній сторінці HMCL.
|
||||
version.empty.hint=Тут немає екземплярів Minecraft. Ви можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб завантажити один.
|
||||
version.game.all=Усі
|
||||
version.game.april_fools=Перший квітня
|
||||
|
||||
@@ -189,6 +189,7 @@ button.export=匯出
|
||||
button.no=否
|
||||
button.ok=確定
|
||||
button.ok.countdown=確定 (%d)
|
||||
button.reveal_dir=開啟目錄
|
||||
button.refresh=重新整理
|
||||
button.remove=刪除
|
||||
button.remove.confirm=你確認要刪除嗎?該操作無法復原!
|
||||
@@ -358,6 +359,9 @@ download.javafx=正在下載必要的執行時元件
|
||||
download.javafx.notes=正在透過網路下載 HMCL 必要的執行時元件。\n點擊「切換下載源」按鈕查看詳情以及選取下載源。點擊「取消」按鈕停止並退出。\n注意:如果下載速度過慢,請嘗試切換下載源。
|
||||
download.javafx.component=正在下載元件「%s」
|
||||
download.javafx.prepare=準備開始下載
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=無法存取檔案「%s」。因為 HMCL 沒有對該檔案的訪問權限,或者該檔案已被其他程式開啟。\n\
|
||||
請你檢查目前作業系統帳戶是否能訪存取檔案,比如非管理員使用者可能不能訪問其他帳戶的個人目錄內的檔案。\n\
|
||||
@@ -524,10 +528,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 +542,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 +752,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 +891,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=開啟檔案失敗
|
||||
@@ -1206,6 +1212,8 @@ 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=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
|
||||
@@ -1215,15 +1223,15 @@ 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.not_exist.title=下載聯機核心 (約 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新聯機核心
|
||||
terracotta.status.uninitialized.update.title=更新聯機核心(約 8MiB)
|
||||
terracotta.status.uninitialized.update.title=更新聯機核心 (約 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
|
||||
terracotta.confirm.title=使用者須知
|
||||
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,與 HMCL 無強關聯性。\n\
|
||||
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,您在使用過程中所遇到的問題請通過相關管道進行回饋。\n\
|
||||
多人連線基於 p2p,最終線上體驗和您的網路情況有較大關係。\n\
|
||||
您承諾,在多人連線全過程中,您將嚴格遵守您所在國家或地區的全部法律法規。
|
||||
terracotta.status.preparing=正在下載聯機核心(請勿退出啟動器)
|
||||
terracotta.status.preparing=正在下載聯機核心 (請勿退出啟動器)
|
||||
terracotta.status.launching=正在初始化聯機核心
|
||||
terracotta.status.unknown=正在初始化聯機核心
|
||||
terracotta.status.waiting=聯機核心已就緒
|
||||
@@ -1242,8 +1250,9 @@ 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=邀請碼 (已自動複製到剪貼簿)
|
||||
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=正在加入房間
|
||||
@@ -1260,7 +1269,6 @@ 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=嚴重錯誤:無法與線上核心通訊
|
||||
@@ -1270,6 +1278,10 @@ terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
terracotta.unsupported=多人聯機功能尚未支援目前平台。
|
||||
terracotta.unsupported.os.windows.old=多人聯機功能需要 Windows 10 或更高版本。請更新系統。
|
||||
terracotta.unsupported.arch.32bit=多人聯機功能不支援 32 位元系統。請更新至 64 位元系統。
|
||||
terracotta.unsupported.arch.loongarch64_ow=多人聯機功能不支援 Linux LoongArch64 舊世界發行版,請更新至新世界發行版 (如 AOSC OC)。
|
||||
|
||||
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
|
||||
|
||||
@@ -1297,14 +1309,16 @@ update.note=開發版與預覽版包含更多的功能以及錯誤修復,但
|
||||
update.latest=目前版本為最新版本
|
||||
update.no_browser=無法開啟瀏覽器。網址已經複製到剪貼簿了,你可以手動複製網址開啟頁面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前預覽 HMCL 版本
|
||||
update.preview=提前測試 HMCL 預覽版本
|
||||
update.preview.tooltip=啟用此選項,你將可以提前取得 HMCL 的新版本,以便在正式發布前進行測試。
|
||||
|
||||
version=遊戲
|
||||
version.name=遊戲實例名稱
|
||||
version.cannot_read=讀取遊戲實例失敗,無法進行自動安裝
|
||||
version.empty=沒有遊戲實例
|
||||
version.empty.add=進入下載頁安裝遊戲
|
||||
version.empty.launch=沒有可啟動的遊戲。點擊「確定」將進入「下載」頁面。\n你也可以點擊 HMCL 主介面左側的「下載」按鈕安裝遊戲,或在「實例清單」切換遊戲目錄。
|
||||
version.empty.launch=沒有可啟動的遊戲。\n你可以前往「下載」頁面下載新遊戲,或在「實例清單」切換遊戲目錄。
|
||||
version.empty.launch.goto_download=前往下載頁面
|
||||
version.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處進入遊戲下載頁面。
|
||||
version.game.all=全部
|
||||
version.game.april_fools=愚人節
|
||||
@@ -1313,6 +1327,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=開始遊戲
|
||||
|
||||
@@ -197,6 +197,7 @@ button.export=导出
|
||||
button.no=否
|
||||
button.ok=确定
|
||||
button.ok.countdown=确定 (%d)
|
||||
button.reveal_dir=打开文件夹
|
||||
button.refresh=刷新
|
||||
button.remove=删除
|
||||
button.remove.confirm=你确定要删除吗?此操作无法撤销!
|
||||
@@ -366,6 +367,9 @@ download.javafx=正在下载必要的运行时组件……
|
||||
download.javafx.notes=正在通过网络下载 HMCL 必要的运行时组件。\n点击“切换下载源”按钮查看详情以及选择下载源。点击“取消”按钮停止并退出。\n注意:若下载速度过慢,请尝试切换下载源。
|
||||
download.javafx.component=正在下载模块“%s”
|
||||
download.javafx.prepare=准备开始下载
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=无法访问文件“%s”。HMCL 没有对该文件的访问权限,或者该文件已被其他程序打开。\n\
|
||||
请你检查当前操作系统账户是否能访问该文件,比如非管理员用户可能无法访问其他账户的个人文件夹内的文件。\n\
|
||||
@@ -534,10 +538,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 +552,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 +762,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 +901,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=打开文件失败
|
||||
@@ -1216,6 +1222,8 @@ 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 内置的默认下载渠道可能不稳定或连接缓慢
|
||||
@@ -1225,15 +1233,15 @@ 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.not_exist.title=下载联机核心 (约 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新联机核心
|
||||
terracotta.status.uninitialized.update.title=更新联机核心(约 8MiB)
|
||||
terracotta.status.uninitialized.update.title=更新联机核心 (约 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
|
||||
terracotta.confirm.title=用户须知
|
||||
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,与 HMCL 无强关联性。\n\
|
||||
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,您在使用过程中所遇到的问题请通过相关渠道进行反馈。\n\
|
||||
多人联机基于 p2p,最终联机体验和您的网络情况有较大关系。\n\
|
||||
在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规。
|
||||
terracotta.status.preparing=正在下载联机核心(请勿退出启动器)
|
||||
terracotta.status.preparing=正在下载联机核心 (请勿退出启动器)
|
||||
terracotta.status.launching=正在初始化联机核心
|
||||
terracotta.status.unknown=正在初始化联机核心
|
||||
terracotta.status.waiting=联机核心已就绪
|
||||
@@ -1252,8 +1260,9 @@ 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=邀请码 (已自动复制到剪贴板)
|
||||
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=正在加入房间
|
||||
@@ -1270,7 +1279,6 @@ 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=严重错误:无法与联机核心通讯
|
||||
@@ -1280,6 +1288,10 @@ terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
terracotta.unsupported=多人联机功能尚不支持当前平台。
|
||||
terracotta.unsupported.os.windows.old=多人联机功能需要 Windows 10 或更高版本。请更新系统。
|
||||
terracotta.unsupported.arch.32bit=多人联机功能不支持 32 位系统。请更新至 64 位系统。
|
||||
terracotta.unsupported.arch.loongarch64_ow=多人联机功能不支持 Linux LoongArch64 旧世界发行版,请更新至新世界发行版 (如 AOSC OC)。
|
||||
|
||||
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
|
||||
|
||||
@@ -1308,13 +1320,15 @@ update.latest=当前版本为最新版本
|
||||
update.no_browser=无法打开浏览器。网址已经复制到剪贴板,你可以手动粘贴网址打开页面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前预览 HMCL 版本
|
||||
update.preview.tooltip=启用此选项,你将可以提前获取 HMCL 的新版本,以便在正式发布前进行测试。
|
||||
|
||||
version=游戏
|
||||
version.name=游戏实例名称
|
||||
version.cannot_read=读取游戏实例失败,无法进行自动安装
|
||||
version.empty=没有游戏实例
|
||||
version.empty.add=进入下载页安装游戏
|
||||
version.empty.launch=没有可启动的游戏。点击“确定”将进入“下载”页面。\n你也可以点击 HMCL 主界面左侧的“下载”按钮安装游戏,或在“实例列表”切换游戏文件夹。
|
||||
version.empty.launch=没有可启动的游戏。\n你可以前往下载页面下载新游戏,或在“实例列表”中切换游戏文件夹。
|
||||
version.empty.launch.goto_download=前往下载页面
|
||||
version.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处进入游戏下载页面。
|
||||
version.game.all=全部
|
||||
version.game.april_fools=愚人节
|
||||
@@ -1323,6 +1337,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
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
{
|
||||
"version_legacy": "0\\.3\\.[89]-rc.[0-9]",
|
||||
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|10|11|12)",
|
||||
"version_recent": [
|
||||
"0.3.9-rc.9"
|
||||
"0.3.12"
|
||||
],
|
||||
"version_latest": "0.3.9-rc.10",
|
||||
"version_latest": "0.3.13",
|
||||
"classifiers": {
|
||||
"linux-arm64": "sha256:d5d386e9936d99dd8c31cdab2098a9db04bf55263faf5cd27d388139a42dee0b",
|
||||
"linux-x86_64": "sha256:c43f111460c777315f12081d934729c08cc67b1a2048fdf3ae451e2682157dd8",
|
||||
"macos-arm64": "sha256:49cbf5880aa94551951abda0e225bbbd9c43b9078ead8a738f5b995085d6022c",
|
||||
"macos-arm64.pkg": "sha256:756831f50d67ed2382f8507dfcc94fcc32d6082e087cd4f6a2fc40ae39f25cdf",
|
||||
"macos-x86_64": "sha256:fdf519f018ce4b3d5b4be06b86dcf9990e94b4010db9dac391175d917214cc01",
|
||||
"macos-x86_64.pkg": "sha256:fc030f0205aa3667033c79ac514045bb7c94ae0175dc5946ea920eefee7ad997",
|
||||
"windows-arm64.exe": "sha256:4b395093b0acaea85a36ce1e569c206d9dfa7e11bfe70409f00bc6c6100df88e",
|
||||
"windows-x86_64.exe": "sha256:2b8add8f6330452d29faaa944ac63c35452e61c5a4ff009069d2c3017c7186c6"
|
||||
"freebsd-x86_64": "sha256:0713e54ee552496416bda9d9e814e33a8950ca8f321f5b3c6dd2e07e79b0e3af",
|
||||
"linux-arm64": "sha256:61affc46035337c182adeca3671b4cf4cc59c7b4e73039899f35416f7d00ad94",
|
||||
"linux-x86_64": "sha256:9399e1627b77d518950e66d944c9a4b70c20d2e13ca2c0e2fed0ded637e7ae06",
|
||||
"linux-loongarch64": "sha256:f3eb4c40dfccc25b5b355298c776abe3d399afb57a2af38803dd78089f0c182e",
|
||||
"linux-riscv64": "sha256:26f95f8b5f83746c9cf9a8362ce0ef793ede8515897a1ba15e5e6f93c3d39533",
|
||||
"macos-arm64": "sha256:35ba271c7dc924e91c2fdd8c1cabeff2ce3d060836748a7a07162b0a5900e8d5",
|
||||
"macos-arm64.pkg": "sha256:90a613ec69f28713fe06188247c57b7cc91743c95112de5aed85ea252103beaa",
|
||||
"macos-x86_64": "sha256:45b420b15a32d5450794a9776cf45a217871cf4333b29b65a35d7358c806b5b1",
|
||||
"macos-x86_64.pkg": "sha256:587623becb3593ccb5fe542a201a67ab3a4029dfa847fcef758faff7ba6d38d5",
|
||||
"windows-arm64.exe": "sha256:bd4e1acf2f304761cdabddd9ade94d046534f4c024bc3026ac98e6be58c2bc22",
|
||||
"windows-x86_64.exe": "sha256:b89599bbcc92b00222cfc6f2e5ef636b7daf192c96efba1049a892e6cb59ee70"
|
||||
},
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"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://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://ghfast.top/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cdn.crashmc.com/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cp.zkitefly.eu.org/https://github.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": [
|
||||
{
|
||||
|
||||
+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) {
|
||||
}
|
||||
@@ -17,9 +17,10 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.task;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public abstract class CompletableFutureTask<T> extends Task<T> {
|
||||
|
||||
@@ -29,18 +30,11 @@ public abstract class CompletableFutureTask<T> extends Task<T> {
|
||||
|
||||
public abstract CompletableFuture<T> getFuture(TaskCompletableFuture executor);
|
||||
|
||||
protected static Throwable resolveException(Throwable e) {
|
||||
if (e instanceof ExecutionException || e instanceof CompletionException)
|
||||
return resolveException(e.getCause());
|
||||
else
|
||||
return e;
|
||||
}
|
||||
|
||||
public static class CustomException extends RuntimeException {}
|
||||
|
||||
protected static CompletableFuture<Void> breakable(CompletableFuture<?> future) {
|
||||
return future.thenApplyAsync(unused1 -> (Void) null).exceptionally(throwable -> {
|
||||
if (resolveException(throwable) instanceof CustomException) return null;
|
||||
if (Lang.resolveException(throwable) instanceof CustomException) return null;
|
||||
else throw new CompletionException(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,16 +345,11 @@ public abstract class Task<T> {
|
||||
}
|
||||
|
||||
private final ReadOnlyStringWrapper message = new ReadOnlyStringWrapper(this, "message", null);
|
||||
private final InvocationDispatcher<String> messageUpdate = InvocationDispatcher.runOn(Platform::runLater, message::set);
|
||||
|
||||
public final ReadOnlyStringProperty messageProperty() {
|
||||
return message.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
protected final void updateMessage(String newMessage) {
|
||||
messageUpdate.accept(newMessage);
|
||||
}
|
||||
|
||||
public final T run() throws Exception {
|
||||
if (getSignificance().shouldLog())
|
||||
LOG.trace("Executing task: " + getName());
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.VarHandle;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/// When [#accept(T)] is called, this class invokes the handler on another thread.
|
||||
@@ -28,6 +29,16 @@ import java.util.function.Consumer;
|
||||
/// @author yushijinhun
|
||||
public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
private static final VarHandle PENDING_ARG_HANDLE;
|
||||
static {
|
||||
try {
|
||||
PENDING_ARG_HANDLE = MethodHandles.lookup()
|
||||
.findVarHandle(InvocationDispatcher.class, "pendingArg", Holder.class);
|
||||
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// @param executor The executor must dispatch all tasks to a single thread.
|
||||
public static <T> InvocationDispatcher<T> runOn(Executor executor, Consumer<T> action) {
|
||||
return new InvocationDispatcher<>(executor, action);
|
||||
@@ -35,7 +46,10 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
private final Executor executor;
|
||||
private final Consumer<T> action;
|
||||
private final AtomicReference<Holder<T>> pendingArg = new AtomicReference<>();
|
||||
|
||||
/// @see #PENDING_ARG_HANDLE
|
||||
@SuppressWarnings("unused")
|
||||
private volatile Holder<T> pendingArg;
|
||||
|
||||
private InvocationDispatcher(Executor executor, Consumer<T> action) {
|
||||
this.executor = executor;
|
||||
@@ -44,12 +58,15 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
@Override
|
||||
public void accept(T t) {
|
||||
if (pendingArg.getAndSet(new Holder<>(t)) == null) {
|
||||
if (PENDING_ARG_HANDLE.getAndSet(this, new Holder<>(t)) == null) {
|
||||
executor.execute(() -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
var holder = (Holder<T>) PENDING_ARG_HANDLE.getAndSet(this, (Holder<T>) null);
|
||||
|
||||
// If the executor supports multiple underlying threads,
|
||||
// we need to add synchronization, but for now we can omit it :)
|
||||
// synchronized (InvocationDispatcher.this)
|
||||
action.accept(pendingArg.getAndSet(null).value);
|
||||
action.accept(holder.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == '_';
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ import java.util.ResourceBundle;
|
||||
/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list,
|
||||
/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`.
|
||||
/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`).
|
||||
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// - For all supported ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list.
|
||||
///
|
||||
/// @author Glavo
|
||||
|
||||
@@ -17,24 +17,24 @@
|
||||
*/
|
||||
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.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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;
|
||||
import java.util.stream.Stream;
|
||||
@@ -48,54 +48,74 @@ public final class LocaleUtils {
|
||||
|
||||
public static final Locale SYSTEM_DEFAULT = Locale.getDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static final Locale LOCALE_ZH_HANS = Locale.forLanguageTag("zh-Hans");
|
||||
public static final Locale LOCALE_ZH_HANT = Locale.forLanguageTag("zh-Hant");
|
||||
|
||||
public static final String DEFAULT_LANGUAGE_KEY = "default";
|
||||
|
||||
private static final Map<String, String> subLanguageToParent = new HashMap<>();
|
||||
private static final Map<String, String> iso3To2 = new HashMap<>();
|
||||
private static final Map<String, String> PARENT_LANGUAGE = loadCSV("sublanguages.csv");
|
||||
private static final Map<String, String> NORMALIZED_TAG = loadCSV("language_aliases.csv");
|
||||
private static final Map<String, String> DEFAULT_SCRIPT = loadCSV("default_script.csv");
|
||||
private static final Map<String, String> PREFERRED_LANGUAGE = Map.of("zh", "cmn");
|
||||
private static final Set<String> RTL_SCRIPTS = Set.of("Qabs", "Arab", "Hebr");
|
||||
private static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
|
||||
|
||||
static {
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
/// Load CSV files located in `/assets/lang/`.
|
||||
/// Each line in these files contains at least two elements.
|
||||
///
|
||||
/// For example, if a file contains `value0,value1,value2`, the return value will be `{value1=value0, value2=value0}`.
|
||||
private static Map<String, String> loadCSV(String fileName) {
|
||||
InputStream resource = LocaleUtils.class.getResourceAsStream("/assets/lang/" + fileName);
|
||||
if (resource == null) {
|
||||
LOG.warning("Can't find file: " + fileName);
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
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;
|
||||
HashMap<String, String> result = new HashMap<>();
|
||||
try (resource) {
|
||||
new String(resource.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
if (line.startsWith("#") || line.isBlank())
|
||||
return;
|
||||
|
||||
String[] items = line.split(",");
|
||||
if (items.length < 2) {
|
||||
LOG.warning("Invalid line in " + fileName + ": " + line);
|
||||
return;
|
||||
}
|
||||
|
||||
String[] parts = line.split(",", 3);
|
||||
if (parts.length != 2) {
|
||||
LOG.warning("Invalid line in iso_languages.csv: " + line);
|
||||
continue;
|
||||
String parent = items[0];
|
||||
for (int i = 1; i < items.length; i++) {
|
||||
result.put(items[i], parent);
|
||||
}
|
||||
|
||||
iso3To2.put(parts[1], parts[0]);
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load iso_languages.csv", e);
|
||||
LOG.warning("Failed to load " + fileName, e);
|
||||
}
|
||||
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static Locale getInstance(String language, String script, String region,
|
||||
@@ -118,6 +138,31 @@ public final class LocaleUtils {
|
||||
: locale.stripExtensions().toLanguageTag();
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
public static boolean isChinese(Locale locale) {
|
||||
return "zh".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/// Normalize the language code to the code in the IANA Language Subtag Registry.
|
||||
/// Typically, it normalizes ISO 639 alpha-3 codes to ISO 639 alpha-2 codes.
|
||||
public static @NotNull String normalizeLanguage(String language) {
|
||||
return language.isEmpty()
|
||||
? "en"
|
||||
: NORMALIZED_TAG.getOrDefault(language, language);
|
||||
}
|
||||
|
||||
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage; otherwise, return `null`.
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return PARENT_LANGUAGE.get(language);
|
||||
}
|
||||
|
||||
/// @see #getRootLanguage(String)
|
||||
public static @NotNull String getRootLanguage(Locale locale) {
|
||||
return getRootLanguage(locale.getLanguage());
|
||||
}
|
||||
@@ -128,54 +173,64 @@ public final class LocaleUtils {
|
||||
/// - If `language` is empty, return `en`;
|
||||
/// - Otherwise, return the `language`.
|
||||
public static @NotNull String getRootLanguage(String language) {
|
||||
if (language.isEmpty()) return "en";
|
||||
if (language.length() <= 2)
|
||||
return language;
|
||||
|
||||
String iso2 = mapToISO2Language(language);
|
||||
if (iso2 != null)
|
||||
return iso2;
|
||||
language = normalizeLanguage(language);
|
||||
|
||||
String parent = getParentLanguage(language);
|
||||
return parent != null ? parent : language;
|
||||
}
|
||||
|
||||
/// If `language` is a macrolanguage, try to map it to the most commonly used individual language.
|
||||
///
|
||||
/// For example, if `language` is `zh`, this method will return `cmn`.
|
||||
public static @NotNull String getPreferredLanguage(String language) {
|
||||
language = normalizeLanguage(language);
|
||||
return PREFERRED_LANGUAGE.getOrDefault(language, language);
|
||||
}
|
||||
|
||||
/// Get the script of the locale. If the script is empty and the language is Chinese,
|
||||
/// the script will be inferred based on the language, the region and the variant.
|
||||
public static @NotNull String getScript(Locale locale) {
|
||||
if (locale.getScript().isEmpty()) {
|
||||
if (isEnglish(locale)) {
|
||||
if ("UD".equals(locale.getCountry())) {
|
||||
return "Qabs";
|
||||
}
|
||||
if (!locale.getVariant().isEmpty()) {
|
||||
String script = DEFAULT_SCRIPT.get(locale.getVariant());
|
||||
if (script != null)
|
||||
return script;
|
||||
}
|
||||
|
||||
if (isChinese(locale)) {
|
||||
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
|
||||
return "Latn";
|
||||
if (locale.getLanguage().equals("lzh") || CHINESE_TRADITIONAL_REGIONS.contains(locale.getCountry()))
|
||||
return "Hant";
|
||||
else
|
||||
return "Hans";
|
||||
if ("UD".equals(locale.getCountry())) {
|
||||
return "Qabs";
|
||||
}
|
||||
|
||||
String script = DEFAULT_SCRIPT.get(normalizeLanguage(locale.getLanguage()));
|
||||
if (script != null)
|
||||
return script;
|
||||
|
||||
if (isChinese(locale)) {
|
||||
return CHINESE_TRADITIONAL_REGIONS.contains(locale.getCountry())
|
||||
? "Hant"
|
||||
: "Hans";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
public static @NotNull TextDirection getTextDirection(Locale locale) {
|
||||
return RTL_SCRIPTS.contains(getScript(locale))
|
||||
? TextDirection.RIGHT_TO_LEFT
|
||||
: TextDirection.LEFT_TO_RIGHT;
|
||||
}
|
||||
|
||||
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
|
||||
|
||||
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
|
||||
return CANDIDATE_LOCALES.computeIfAbsent(locale, LocaleUtils::createCandidateLocaleList);
|
||||
}
|
||||
|
||||
// -------------
|
||||
|
||||
private static List<Locale> createCandidateLocaleList(Locale locale) {
|
||||
String language = locale.getLanguage();
|
||||
if (language.isEmpty())
|
||||
return List.of(Locale.ENGLISH, Locale.ROOT);
|
||||
|
||||
String language = getPreferredLanguage(locale.getLanguage());
|
||||
String script = getScript(locale);
|
||||
String region = locale.getCountry();
|
||||
List<String> variants = locale.getVariant().isEmpty()
|
||||
@@ -184,18 +239,7 @@ public final class LocaleUtils {
|
||||
|
||||
ArrayList<Locale> result = new ArrayList<>();
|
||||
do {
|
||||
String currentLanguage;
|
||||
|
||||
if (language.length() <= 2) {
|
||||
currentLanguage = language;
|
||||
} else {
|
||||
String iso2 = mapToISO2Language(language);
|
||||
currentLanguage = iso2 != null
|
||||
? iso2
|
||||
: language;
|
||||
}
|
||||
|
||||
addCandidateLocales(result, currentLanguage, script, region, variants);
|
||||
addCandidateLocales(result, language, script, region, variants);
|
||||
} while ((language = getParentLanguage(language)) != null);
|
||||
|
||||
result.add(Locale.ROOT);
|
||||
@@ -340,31 +384,6 @@ public final class LocaleUtils {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
|
||||
/// Returns `null` if there is no corresponding ISO 639 alpha-2 language code.
|
||||
public static @Nullable String mapToISO2Language(String iso3Language) {
|
||||
return iso3To2.get(iso3Language);
|
||||
}
|
||||
|
||||
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage; otherwise, return `null`.
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return subLanguageToParent.get(language);
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
public static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
|
||||
public static final Set<String> CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong");
|
||||
|
||||
public static boolean isChinese(Locale locale) {
|
||||
return "zh".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,37 +1,48 @@
|
||||
/*
|
||||
* 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.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_64);
|
||||
|
||||
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 +85,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();
|
||||
|
||||
@@ -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,29 @@
|
||||
Arab,ar,fa,ps,ur
|
||||
Armn,hy
|
||||
Beng,as,bn
|
||||
Blis,zbl
|
||||
Cyrl,ab,be,bg,kk,mk,ru,uk
|
||||
Deva,hi,mr,ne,kok,mai
|
||||
Ethi,am,ti
|
||||
Geor,ka
|
||||
Grek,el
|
||||
Gujr,gu
|
||||
Guru,pa
|
||||
Hant,lzh
|
||||
Hebr,he,yi
|
||||
Jpan,ja
|
||||
Khmr,km
|
||||
Knda,kn
|
||||
Kore,ko
|
||||
Laoo,lo
|
||||
Latn,af,ay,bs,ca,ch,cs,cy,da,de,en,eo,es,et,eu,fi,fj,fo,fr,fy,ga,gl,gn,gv,hr,ht,hu,id,is,it,kl,la,lb,ln,lt,lv,mg,mh,ms,mt,na,nb,nd,nl,nn,no,nr,ny,om,pl,pt,qu,rm,rn,ro,rw,sg,sk,sl,sm,so,sq,ss,st,sv,sw,tl,tn,to,tr,ts,ve,vi,xh,zu,dsb,frr,frs,gsw,hsb,men,nds,niu,nso,tem,tkl,tmh,tpi,tvl,tailo,pinyin,hepburn,pehoeji,tongyong,wadegile
|
||||
Mlym,ml
|
||||
Mymr,my
|
||||
Nkoo,nqo
|
||||
Orya,or
|
||||
Sinh,si
|
||||
Taml,ta
|
||||
Telu,te
|
||||
Thaa,dv
|
||||
Thai,th
|
||||
Tibt,dz
|
||||
|
-1
@@ -73,7 +73,6 @@ io,ido
|
||||
is,isl
|
||||
it,ita
|
||||
iu,iku
|
||||
iw,heb
|
||||
ja,jpn
|
||||
ji,yid
|
||||
jv,jav
|
||||
|
@@ -1 +1,63 @@
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
ak,tw,fat
|
||||
ar,aao,abh,abv,acm,acq,acw,acx,acy,adf,aeb,aec,afb,apc,apd,arb,arq,ars,ary,arz,auz,avl,ayh,ayl,ayn,ayp,pga,shu,ssh
|
||||
ay,ayc,ayr
|
||||
az,azb,azj
|
||||
cr,crj,crk,crl,crm,csw,cwd
|
||||
et,ekk,vro
|
||||
fa,pes,prs
|
||||
ff,ffm,fub,fuc,fue,fuf,fuh,fui,fuq,fuv
|
||||
gn,gnw,gug,gui,gun,nhd
|
||||
ik,esi,esk
|
||||
iu,ike,ikt
|
||||
kg,kng,kwy,ldi
|
||||
kr,kby,knc,krt
|
||||
ku,ckb,kmr,sdh
|
||||
kv,koi,kpv
|
||||
lv,ltg,lvs
|
||||
mg,bhr,bmm,bzc,msh,plt,skg,tdx,tkg,txy,xmv,xmw
|
||||
mn,khk,mvf
|
||||
ms,id,bjn,btj,bve,bvu,coa,dup,hji,jak,jax,kvb,kvr,kxd,lce,lcf,liw,max,meo,mfa,mfb,min,mqg,msi,mui,orn,ors,pel,pse,tmw,urk,vkk,vkt,xmm,zlm,zmi,zsm
|
||||
ne,dty,npi
|
||||
no,nb,nn
|
||||
oj,ciw,ojb,ojc,ojg,ojs,ojw,otw
|
||||
om,gax,gaz,hae,orc
|
||||
or,ory,spv
|
||||
ps,pbt,pbu,pst
|
||||
qu,qub,qud,quf,qug,quh,quk,qul,qup,qur,qus,quw,qux,quy,quz,qva,qvc,qve,qvh,qvi,qvj,qvl,qvm,qvn,qvo,qvp,qvs,qvw,qvz,qwa,qwc,qwh,qws,qxa,qxc,qxh,qxl,qxn,qxo,qxp,qxr,qxt,qxu,qxw
|
||||
sa,cls,vsn
|
||||
sc,sdc,sdn,src,sro
|
||||
sh,bs,hr,sr,cnr
|
||||
sq,aae,aat,aln,als
|
||||
sw,swc,swh
|
||||
uz,uzn,uzs
|
||||
yi,ydd,yih
|
||||
za,zch,zeh,zgb,zgm,zgn,zhd,zhn,zlj,zln,zlq,zqe,zyb,zyg,zyj,zyn,zzj
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
bal,bcc,bgn,bgp
|
||||
bik,bcl,bln,bto,cts,fbl,lbl,rbl,ubl
|
||||
bnc,ebk,lbk,obk,rbk,vbk
|
||||
bua,bxm,bxr,bxu
|
||||
chm,mhr,mrj
|
||||
del,umu,unm
|
||||
den,scs,xsl
|
||||
din,dib,dik,dip,diw,dks
|
||||
doi,dgo,xnr
|
||||
gba,bdt,gbp,gbq,gmm,gso,gya
|
||||
gon,esg,gno,wsg
|
||||
grb,gbo,gec,grj,grv,gry
|
||||
hai,hax,hdn
|
||||
hmn,cqd,hea,hma,hmc,hmd,hme,hmg,hmh,hmi,hmj,hml,hmm,hmp,hmq,hms,hmw,hmy,hmz,hnj,hrm,huj,mmr,muq,mww,sfm
|
||||
jrb,aju,jye,yhd,yud
|
||||
kln,enb,eyo,niq,oki,pko,sgc,spy,tec,tuy
|
||||
kok,gom,knn
|
||||
kpe,gkp,xpe
|
||||
lah,hnd,hno,jat,phr,pnb,skr,xhe
|
||||
luy,bxk,ida,lkb,lko,lks,lri,lrm,lsm,lto,lts,lwg,nle,nyd,rag
|
||||
man,emk,mku,mlq,mnk,msc,mwk
|
||||
mwr,dhd,mtr,mve,rwr,swv,wry
|
||||
raj,bgq,gda,gju,hoj,mup,wbr
|
||||
rom,rmc,rmf,rml,rmn,rmo,rmw,rmy
|
||||
syr,aii,cld
|
||||
tmh,taq,thv,thz,ttq
|
||||
zap,zaa,zab,zac,zad,zae,zaf,zai,zam,zao,zaq,zar,zas,zat,zav,zaw,zax,zca,zcd,zoo,zpa,zpb,zpc,zpd,zpe,zpf,zpg,zph,zpi,zpj,zpk,zpl,zpm,zpn,zpo,zpp,zpq,zpr,zps,zpt,zpu,zpv,zpw,zpx,zpy,zpz,zsr,zte,ztg,ztl,ztm,ztn,ztp,ztq,zts,ztt,ztu,ztx,zty
|
||||
zza,diq,kiu
|
||||
|
@@ -68,4 +68,10 @@ _‾
|
||||
!¡
|
||||
//
|
||||
\\
|
||||
',
|
||||
',
|
||||
()
|
||||
)(
|
||||
[]
|
||||
][
|
||||
{}
|
||||
}{
|
||||
@@ -28,7 +28,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -41,55 +40,76 @@ public final class LocaleUtilsTest {
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(languageTag))
|
||||
.stream()
|
||||
.map(Locale::toLanguageTag)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static void assertCandidateLocalesEquals(String l1, String l2) {
|
||||
assertEquals(
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(l1)),
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(l2))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetCandidateLocales() {
|
||||
assertCandidateLocales("zh", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-CN", List.of("zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-SG", List.of("zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-MY", List.of("zh-Hans-MY", "zh-Hans", "zh-MY", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-TW", List.of("zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-HK", List.of("zh-Hant-HK", "zh-Hant", "zh-HK", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-MO", List.of("zh-Hant-MO", "zh-Hant", "zh-MO", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hans", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hant", List.of("zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hans-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hant-CN", List.of("zh-Hant-CN", "zh-Hant", "zh-CN", "zh-TW", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hans-TW", List.of("zh-Hans-TW", "zh-Hans", "zh-TW", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Latn", List.of("zh-Latn", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Latn-CN", List.of("zh-Latn-CN", "zh-Latn", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-pinyin", List.of("zh-Latn-pinyin", "zh-Latn", "zh-pinyin", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zho", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
// English
|
||||
|
||||
assertCandidateLocales("en", List.of("en-Latn", "en", "und"));
|
||||
assertCandidateLocales("en-US", List.of("en-Latn-US", "en-Latn", "en-US", "en", "und"));
|
||||
assertCandidateLocalesEquals("en", "eng");
|
||||
assertCandidateLocalesEquals("en-US", "eng-US");
|
||||
assertCandidateLocalesEquals("und", "en");
|
||||
|
||||
// Spanish
|
||||
|
||||
assertCandidateLocales("es", List.of("es-Latn", "es", "und"));
|
||||
assertCandidateLocalesEquals("es", "spa");
|
||||
|
||||
// Japanese
|
||||
|
||||
assertCandidateLocales("ja", List.of("ja-Jpan", "ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-Jpan-JP", "ja-Jpan", "ja-JP", "ja", "und"));
|
||||
assertCandidateLocalesEquals("ja", "jpn");
|
||||
assertCandidateLocalesEquals("ja-JP", "jpn-JP");
|
||||
|
||||
// Russian
|
||||
|
||||
assertCandidateLocales("ru", List.of("ru-Cyrl", "ru", "und"));
|
||||
assertCandidateLocalesEquals("ru", "rus");
|
||||
|
||||
// Ukrainian
|
||||
|
||||
assertCandidateLocales("uk", List.of("uk-Cyrl", "uk", "und"));
|
||||
assertCandidateLocalesEquals("uk", "ukr");
|
||||
|
||||
// Chinese
|
||||
|
||||
assertCandidateLocales("zh", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-CN", List.of("cmn-Hans-CN", "cmn-Hans", "cmn-CN", "cmn", "zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-SG", List.of("cmn-Hans-SG", "cmn-Hans", "cmn-SG", "cmn", "zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("zh-TW", List.of("cmn-Hant-TW", "cmn-Hant", "cmn-TW", "cmn", "zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-HK", List.of("cmn-Hant-HK", "cmn-Hant", "cmn-HK", "cmn", "zh-Hant-HK", "zh-Hant", "zh-HK", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hant-CN", List.of("cmn-Hant-CN", "cmn-Hant", "cmn-CN", "cmn", "zh-Hant-CN", "zh-Hant", "zh-CN", "zh-TW", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("zh-pinyin", List.of("cmn-Latn-pinyin", "cmn-Latn", "cmn-pinyin", "cmn", "zh-Latn-pinyin", "zh-Latn", "zh-pinyin", "zh", "zh-CN", "und"));
|
||||
|
||||
assertCandidateLocales("lzh", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("lzh-Hant", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("lzh-Hans", List.of("lzh-Hans", "lzh", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("cmn", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("cmn-Hans", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("yue", List.of("yue-Hans", "yue", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("ja", List.of("ja", "und"));
|
||||
assertCandidateLocales("jpn", List.of("ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und"));
|
||||
assertCandidateLocales("jpn-JP", List.of("ja-JP", "ja", "und"));
|
||||
|
||||
assertCandidateLocales("en", List.of("en", "und"));
|
||||
assertCandidateLocales("eng", List.of("en", "und"));
|
||||
assertCandidateLocales("en-US", List.of("en-US", "en", "und"));
|
||||
assertCandidateLocales("eng-US", List.of("en-US", "en", "und"));
|
||||
|
||||
assertCandidateLocales("es", List.of("es", "und"));
|
||||
assertCandidateLocales("spa", List.of("es", "und"));
|
||||
|
||||
assertCandidateLocales("ru", List.of("ru", "und"));
|
||||
assertCandidateLocales("rus", List.of("ru", "und"));
|
||||
|
||||
assertCandidateLocales("uk", List.of("uk", "und"));
|
||||
assertCandidateLocales("ukr", List.of("uk", "und"));
|
||||
|
||||
assertCandidateLocales("und", List.of("en", "und"));
|
||||
assertCandidateLocalesEquals("zh", "cmn-Hans");
|
||||
assertCandidateLocalesEquals("zh-CN", "cmn-Hans-CN");
|
||||
assertCandidateLocalesEquals("zh-SG", "cmn-Hans-SG");
|
||||
assertCandidateLocalesEquals("zh-MY", "cmn-Hans-MY");
|
||||
assertCandidateLocalesEquals("zh-TW", "cmn-Hant-TW");
|
||||
assertCandidateLocalesEquals("zh-HK", "cmn-Hant-HK");
|
||||
assertCandidateLocalesEquals("zh-Hans", "cmn-Hans");
|
||||
assertCandidateLocalesEquals("zh-Hant", "cmn-Hant");
|
||||
assertCandidateLocalesEquals("zh-Hant-CN", "cmn-Hant-CN");
|
||||
assertCandidateLocalesEquals("zh-Hant-SG", "cmn-Hant-SG");
|
||||
assertCandidateLocalesEquals("zh-Latn", "cmn-Latn");
|
||||
assertCandidateLocalesEquals("zh-pinyin", "cmn-Latn-pinyin");
|
||||
assertCandidateLocalesEquals("zho", "zh");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,7 +154,9 @@ public final class LocaleUtilsTest {
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hant")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-CN")));
|
||||
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("en")));
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("zh-pinyin")));
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("ja-hepburn")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -192,20 +214,17 @@ public final class LocaleUtilsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMapToISO2Language() {
|
||||
assertEquals("en", LocaleUtils.mapToISO2Language("eng"));
|
||||
assertEquals("es", LocaleUtils.mapToISO2Language("spa"));
|
||||
assertEquals("ja", LocaleUtils.mapToISO2Language("jpn"));
|
||||
assertEquals("ru", LocaleUtils.mapToISO2Language("rus"));
|
||||
assertEquals("uk", LocaleUtils.mapToISO2Language("ukr"));
|
||||
assertEquals("zh", LocaleUtils.mapToISO2Language("zho"));
|
||||
assertEquals("zu", LocaleUtils.mapToISO2Language("zul"));
|
||||
|
||||
assertNull(LocaleUtils.mapToISO2Language(null));
|
||||
assertNull(LocaleUtils.mapToISO2Language(""));
|
||||
assertNull(LocaleUtils.mapToISO2Language("cmn"));
|
||||
assertNull(LocaleUtils.mapToISO2Language("lzh"));
|
||||
assertNull(LocaleUtils.mapToISO2Language("tlh"));
|
||||
public void testNormalizeLanguage() {
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage(""));
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage("eng"));
|
||||
assertEquals("es", LocaleUtils.normalizeLanguage("spa"));
|
||||
assertEquals("ja", LocaleUtils.normalizeLanguage("jpn"));
|
||||
assertEquals("ru", LocaleUtils.normalizeLanguage("rus"));
|
||||
assertEquals("uk", LocaleUtils.normalizeLanguage("ukr"));
|
||||
assertEquals("zh", LocaleUtils.normalizeLanguage("zho"));
|
||||
assertEquals("zu", LocaleUtils.normalizeLanguage("zul"));
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage(""));
|
||||
assertEquals("cmn", LocaleUtils.normalizeLanguage("cmn"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -220,4 +239,19 @@ 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.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")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("he")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("heb")));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import org.jackhuang.hmcl.gradle.ci.CheckUpdate
|
||||
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
|
||||
import org.jackhuang.hmcl.gradle.l10n.ParseLanguageSubtagRegistry
|
||||
|
||||
plugins {
|
||||
id("checkstyle")
|
||||
@@ -76,6 +77,12 @@ org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
|
||||
|
||||
defaultTasks("clean", "build")
|
||||
|
||||
tasks.register<ParseLanguageSubtagRegistry>("parseLanguageSubtagRegistry") {
|
||||
languageSubtagRegistryFile.set(layout.projectDirectory.file("language-subtag-registry"))
|
||||
|
||||
sublanguagesFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/sublanguages.csv"))
|
||||
defaultScriptFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/default_script.csv"))
|
||||
}
|
||||
|
||||
tasks.register<UpdateDocuments>("updateDocuments") {
|
||||
documentsDir.set(layout.projectDirectory.dir("docs"))
|
||||
|
||||
@@ -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/"))
|
||||
|
||||
@@ -103,12 +103,8 @@ public record Document(DocumentFileTree directory,
|
||||
|
||||
if (line == null)
|
||||
throw new IOException("Missing end line for macro: " + macroName);
|
||||
else if (line.startsWith("<!-- #END")) {
|
||||
if (line.equals(endLine)) {
|
||||
break;
|
||||
} else {
|
||||
throw new IOException("Invalid macro end line: " + line);
|
||||
}
|
||||
else if (line.equals(endLine)) {
|
||||
break;
|
||||
} else {
|
||||
lines.add(line);
|
||||
}
|
||||
|
||||
@@ -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) -> {
|
||||
|
||||
@@ -19,36 +19,42 @@ package org.jackhuang.hmcl.gradle.l10n;
|
||||
|
||||
import org.gradle.api.GradleException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
final class LocalizationUtils {
|
||||
public static final Map<String, String> subLanguageToParent;
|
||||
public static final Map<String, String> subLanguageToParent = loadCSV("sublanguages.csv");
|
||||
|
||||
static {
|
||||
InputStream input = LocalizationUtils.class.getResourceAsStream("sublanguages.csv");
|
||||
if (input == null)
|
||||
throw new GradleException("Missing sublanguages.csv file");
|
||||
|
||||
Map<String, String> map = new HashMap<>();
|
||||
try (input) {
|
||||
new String(input.readAllBytes()).lines()
|
||||
.filter(line -> !line.startsWith("#") && !line.isBlank())
|
||||
.forEach(line -> {
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2)
|
||||
throw new GradleException("Invalid line in sublanguages.csv: " + line);
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
map.put(languages[i], parent);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new GradleException("Failed to read sublanguages.csv", e);
|
||||
private static Map<String, String> loadCSV(String fileName) {
|
||||
InputStream resource = LocalizationUtils.class.getResourceAsStream(fileName);
|
||||
if (resource == null) {
|
||||
throw new GradleException("Resource not found: " + fileName);
|
||||
}
|
||||
subLanguageToParent = Collections.unmodifiableMap(map);
|
||||
|
||||
HashMap<String, String> result = new HashMap<>();
|
||||
try (resource) {
|
||||
new String(resource.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
if (line.startsWith("#") || line.isBlank())
|
||||
return;
|
||||
|
||||
String[] items = line.split(",");
|
||||
if (items.length < 2) {
|
||||
throw new GradleException("Invalid line in sublanguages.csv: " + line);
|
||||
}
|
||||
|
||||
String parent = items[0];
|
||||
for (int i = 1; i < items.length; i++) {
|
||||
result.put(items[i], parent);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new GradleException("Failed to load " + fileName, e);
|
||||
}
|
||||
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<String> resolveLanguage(String language) {
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.gradle.l10n;
|
||||
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
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.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/// @author Glavo
|
||||
/// @see [language-subtag-registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)
|
||||
public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
|
||||
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getLanguageSubtagRegistryFile();
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getSublanguagesFile();
|
||||
|
||||
/// CSV file storing the mapping from subtag to their default scripts.
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getDefaultScriptFile();
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
List<Item> items;
|
||||
|
||||
try (var reader = Files.newBufferedReader(getLanguageSubtagRegistryFile().getAsFile().get().toPath())) {
|
||||
var builder = new ItemsBuilder();
|
||||
builder.parse(reader);
|
||||
items = builder.items;
|
||||
}
|
||||
|
||||
MultiMap scriptToSubtag = new MultiMap();
|
||||
MultiMap languageToSub = new MultiMap();
|
||||
|
||||
// Classical Chinese should use Traditional Chinese characters by default
|
||||
scriptToSubtag.add("Hant", "lzh");
|
||||
|
||||
for (Item item : items) {
|
||||
String type = item.firstValueOrThrow("Type");
|
||||
if (type.equals("grandfathered") || type.equals("redundant")
|
||||
|| !item.allValues("Deprecated").isEmpty())
|
||||
continue;
|
||||
|
||||
String subtag = item.firstValueOrThrow("Subtag");
|
||||
|
||||
mainSwitch:
|
||||
switch (type) {
|
||||
case "language", "extlang" -> {
|
||||
item.firstValue("Macrolanguage")
|
||||
.ifPresent(macroLang -> languageToSub.add(macroLang, subtag));
|
||||
|
||||
item.firstValue("Suppress-Script")
|
||||
.ifPresent(script -> scriptToSubtag.add(script, subtag));
|
||||
}
|
||||
case "variant" -> {
|
||||
List<String> prefixes = item.allValues("Prefix");
|
||||
String defaultScript = null;
|
||||
for (String prefix : prefixes) {
|
||||
String script = Locale.forLanguageTag(prefix).getScript();
|
||||
if (script.isEmpty()) {
|
||||
break mainSwitch;
|
||||
}
|
||||
|
||||
if (defaultScript == null) {
|
||||
defaultScript = script;
|
||||
} else {
|
||||
if (!defaultScript.equals(script)) {
|
||||
break mainSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultScript != null) {
|
||||
scriptToSubtag.add(defaultScript, subtag);
|
||||
}
|
||||
}
|
||||
case "region", "script" -> {
|
||||
// ignored
|
||||
}
|
||||
default -> throw new GradleException(String.format("Unknown subtag type: %s", type));
|
||||
}
|
||||
}
|
||||
|
||||
languageToSub.saveToCSV(getSublanguagesFile());
|
||||
scriptToSubtag.saveToCSV(getDefaultScriptFile());
|
||||
}
|
||||
|
||||
private static final class MultiMap {
|
||||
private final TreeMap<String, Set<String>> allValues = new TreeMap<>(TAG_COMPARATOR);
|
||||
|
||||
void add(String key, String value) {
|
||||
allValues.computeIfAbsent(key, k -> new TreeSet<>(TAG_COMPARATOR)).add(value);
|
||||
}
|
||||
|
||||
void saveToCSV(RegularFileProperty csvFile) throws IOException {
|
||||
try (var writer = Files.newBufferedWriter(csvFile.getAsFile().get().toPath(),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : allValues.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Set<String> values = entry.getValue();
|
||||
|
||||
writer.write(key);
|
||||
|
||||
for (String value : values) {
|
||||
writer.write(',');
|
||||
writer.write(value);
|
||||
}
|
||||
|
||||
writer.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Item {
|
||||
final Map<String, List<String>> values = new LinkedHashMap<>();
|
||||
|
||||
public @NotNull List<String> allValues(String name) {
|
||||
return values.getOrDefault(name, List.of());
|
||||
}
|
||||
|
||||
public @NotNull Optional<String> firstValue(String name) {
|
||||
return Optional.ofNullable(values.get(name)).map(it -> it.get(0));
|
||||
}
|
||||
|
||||
public @Nullable String firstValueOrNull(String name) {
|
||||
return firstValue(name).orElse(null);
|
||||
}
|
||||
|
||||
public @NotNull String firstValueOrThrow(String name) {
|
||||
return firstValue(name).orElseThrow(() -> new GradleException("No value found for " + name + " in " + this));
|
||||
}
|
||||
|
||||
public void put(String name, String value) {
|
||||
values.computeIfAbsent(name, ignored -> new ArrayList<>(1)).add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
|
||||
values.forEach((name, values) -> {
|
||||
for (String value : values) {
|
||||
joiner.add(name + ": " + value);
|
||||
}
|
||||
});
|
||||
|
||||
return joiner.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ItemsBuilder {
|
||||
private final List<Item> items = new ArrayList<>(1024);
|
||||
private Item current = new Item();
|
||||
private String currentName = null;
|
||||
private String currentValue = null;
|
||||
|
||||
private void updateCurrent() {
|
||||
if (currentName != null) {
|
||||
current.put(currentName, currentValue);
|
||||
currentName = null;
|
||||
currentValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateItems() throws IOException {
|
||||
updateCurrent();
|
||||
|
||||
if (current.values.isEmpty())
|
||||
return;
|
||||
|
||||
if (current.firstValue("Type").isEmpty()) {
|
||||
if (current.firstValue("File-Date").isPresent()) {
|
||||
current.values.clear();
|
||||
return;
|
||||
} else {
|
||||
throw new GradleException("Invalid item: " + current);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(current);
|
||||
current = new Item();
|
||||
}
|
||||
|
||||
void parse(BufferedReader reader) throws IOException {
|
||||
Pattern linePattern = Pattern.compile("^(?<name>[A-Za-z\\-]+): (?<value>.*)$");
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
} else if (line.equals("%%")) {
|
||||
updateItems();
|
||||
} else if (line.startsWith(" ")) {
|
||||
if (currentValue != null) {
|
||||
currentValue = currentValue + " " + line;
|
||||
} else {
|
||||
throw new GradleException("Invalid line: " + line);
|
||||
}
|
||||
} else {
|
||||
updateCurrent();
|
||||
|
||||
Matcher matcher = linePattern.matcher(line);
|
||||
if (matcher.matches()) {
|
||||
currentName = matcher.group("name");
|
||||
currentValue = matcher.group("value");
|
||||
} else {
|
||||
throw new GradleException("Invalid line: " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItems();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Comparator<String> TAG_COMPARATOR = (lang1, lang2) -> {
|
||||
if (lang1.length() != lang2.length())
|
||||
return Integer.compare(lang1.length(), lang2.length());
|
||||
else
|
||||
return lang1.compareTo(lang2);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -15,4 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
versionRoot=3.7
|
||||
versionRoot=3.8
|
||||
@@ -0,0 +1,56 @@
|
||||
# Build Guide
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
**English** | [中文](Building_zh.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## Requirements
|
||||
|
||||
To build the HMCL launcher, you need to install JDK 17 (or higher). You can download it here: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts).
|
||||
|
||||
After installing the JDK, make sure the `JAVA_HOME` environment variable points to the required JDK directory.
|
||||
You can check the JDK version that `JAVA_HOME` points to like this:
|
||||
|
||||
<details>
|
||||
<summary>Windows</summary>
|
||||
|
||||
PowerShell:
|
||||
```
|
||||
PS > & "$env:JAVA_HOME/bin/java.exe" -version
|
||||
openjdk version "25" 2025-09-16 LTS
|
||||
OpenJDK Runtime Environment (build 25+37-LTS)
|
||||
OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Linux/macOS/FreeBSD</summary>
|
||||
|
||||
```
|
||||
> $JAVA_HOME/bin/java -version
|
||||
openjdk version "25" 2025-09-16 LTS
|
||||
OpenJDK Runtime Environment (build 25+37-LTS)
|
||||
OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Get HMCL Source Code
|
||||
|
||||
- You can get the latest source code via [Git](https://git-scm.com/downloads):
|
||||
```shell
|
||||
git clone https://github.com/HMCL-dev/HMCL.git
|
||||
cd HMCL
|
||||
```
|
||||
- You can manually download a specific version of the source code from the [GitHub Release page](https://github.com/HMCL-dev/HMCL/releases).
|
||||
|
||||
## Build HMCL
|
||||
|
||||
To build HMCL, switch to the root directory of the HMCL project and run the following command:
|
||||
|
||||
```shell
|
||||
./gradlew clean makeExecutables
|
||||
```
|
||||
|
||||
The built HMCL program files are located in the `HMCL/build/libs` subdirectory under the project root.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 构建指南
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
[English](Building.md) | **中文**
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## 环境需求
|
||||
|
||||
构建 HMCL 启动器需要安装 JDK 17 (或更高版本)。你可以从此处下载它: [Download Liberica JDK](https://bell-sw.com/pages/downloads/#jdk-25-lts)。
|
||||
|
||||
在安装 JDK 后,请确保 `JAVA_HOME` 环境变量指向符合需求的 JDK 目录。
|
||||
你可以这样查看 `JAVA_HOME` 指向的 JDK 版本:
|
||||
|
||||
<details>
|
||||
<summary>Windows</summary>
|
||||
|
||||
PowerShell:
|
||||
```
|
||||
PS > & "$env:JAVA_HOME/bin/java.exe" -version
|
||||
openjdk version "25" 2025-09-16 LTS
|
||||
OpenJDK Runtime Environment (build 25+37-LTS)
|
||||
OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Linux/macOS/FreeBSD</summary>
|
||||
|
||||
```
|
||||
> $JAVA_HOME/bin/java -version
|
||||
openjdk version "25" 2025-09-16 LTS
|
||||
OpenJDK Runtime Environment (build 25+37-LTS)
|
||||
OpenJDK 64-Bit Server VM (build 25+37-LTS, mixed mode, sharing)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 获取 HMCL 源码
|
||||
|
||||
- 通过 [Git](https://git-scm.com/downloads) 可以获取最新源码:
|
||||
```shell
|
||||
git clone https://github.com/HMCL-dev/HMCL.git
|
||||
cd HMCL
|
||||
```
|
||||
- 从 [GitHub Release 页面](https://github.com/HMCL-dev/HMCL/releases)可以手动下载特定版本的源码。
|
||||
|
||||
## 构建 HMCL
|
||||
|
||||
想要构建 HMCL,请切换到 HMCL 项目的根目录下,并执行以下命令:
|
||||
|
||||
```shell
|
||||
./gradlew clean makeExecutables
|
||||
```
|
||||
|
||||
构建出的 HMCL 程序文件位于根目录下的 `HMCL/build/libs` 子目录中。
|
||||
@@ -0,0 +1,337 @@
|
||||
# Localization
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
**English** | [中文](Localization_zh.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
HMCL provides localization support for multiple languages.
|
||||
|
||||
This document describes HMCL's support status for these languages and provides a guide for contributors who want to help with HMCL localization.
|
||||
|
||||
## Supported languages
|
||||
|
||||
Currently, HMCL supports the following languages:
|
||||
|
||||
| Language | Language Tag | Preferred Localization Key | Preferred Localization File Suffix | [Game Language Files](https://minecraft.wiki/w/Language) | Support Status | Volunteers |
|
||||
|-----------------------|--------------|----------------------------|------------------------------------|----------------------------------------------------------|----------------|-------------------------------------------|
|
||||
| English | `en` | `default` | (empty) | `en_us` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| English (Upside Down) | `en-Qabs` | `en-Qabs` | `en_Qabs` | `en_ud` | Automatic | |
|
||||
| Chinese (Simplified) | `zh-Hans` | `zh` | `_zh` | `zh_cn` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| Chinese (Traditional) | `zh-Hant` | `zh-Hant` | `_zh_Hant` | `zh_tw` <br/> `zh_hk` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| Chinese (Classical) | `lzh` | `lzh` | `_lzh` | `lzh` | Secondary | |
|
||||
| Japanese | `ja` | `ja` | `_ja` | `ja_jp` | Secondary | |
|
||||
| Spanish | `es` | `es` | `_es` | `es_es` | Secondary | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| Russian | `ru` | `ru` | `_ru` | `ru_ru` | Secondary | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| Ukrainian | `uk` | `uk` | `_uk` | `uk_ua` | Secondary | |
|
||||
|
||||
<details>
|
||||
<summary>About Language Tags</summary>
|
||||
|
||||
HMCL uses language tags that conform to the IETF BCP 47 standard.
|
||||
|
||||
When choosing language tags, we follow these principles:
|
||||
|
||||
1. For languages defined in the ISO 639 standard, if a tag has already been registered in the [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry),
|
||||
we always use the registered tag.
|
||||
|
||||
For example, for English, we use `en` instead of `eng` as the language code.
|
||||
|
||||
2. For non-standard languages defined by Minecraft, the code defined in the language file's `language.code` should be preferred over the game language file's name.
|
||||
|
||||
This is because Minecraft sometimes uses real-world country/region codes to represent joke languages
|
||||
(for example, Pirate English uses the language file `en_pt`, but `PT` is actually the country code for Portugal).
|
||||
|
||||
For example, for Upside down English, we use `en-Qabs` as the language code instead of `en-UD`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>About Localization Keys and File Suffixes</summary>
|
||||
|
||||
Localization file suffixes and keys are used to name [localization resources](#localization-resources).
|
||||
|
||||
Generally, the localization key is the language code for the resource, and the localization file suffix is obtained by replacing `-` with `_` in the language code and adding a leading underscore.
|
||||
|
||||
As a special case, for default resources, the localization key is `default` and the file suffix is empty.
|
||||
|
||||
Due to the existence of the [resource fallback mechanism](#resource-fallback-mechanism),
|
||||
if there is no resource that exactly matches the current locale,
|
||||
HMCL will derive a search list from the current language tag and search for resources in order.
|
||||
|
||||
We recommend always providing a default resource (with the `default` key and empty file suffix) when providing localization resources,
|
||||
to ensure all users can load resources properly.
|
||||
|
||||
We also recommend using broader language tags for localization resources whenever possible,
|
||||
so users are less likely to fall back to the default resource.
|
||||
|
||||
For example, if you provide a Simplified Chinese localization resource,
|
||||
we recommend using `zh` as the localization key instead of the more specific `zh-Hans`,
|
||||
so it will apply to all Chinese users and avoid falling back to the default resource for them.
|
||||
|
||||
If you want to provide both Simplified and Traditional Chinese resources,
|
||||
it is recommended to use the broader `zh` as the key for the resource with more users (as the default Chinese resource),
|
||||
and use the more specific `zh-Hans`/`zh-Hant` as the key for the resource with fewer users.
|
||||
|
||||
</details>
|
||||
|
||||
HMCL requires all pull requests that update documentation and localization resources to also update the resources
|
||||
for all **primary** supported languages.
|
||||
If the PR author is not familiar with the relevant languages, they can request translation help in the comments,
|
||||
and maintainers will help translate these texts before merging the PR.
|
||||
|
||||
For **secondary** supported languages, we cannot guarantee that these localization resources will always be updated in sync.
|
||||
We need collaborators who are proficient in these languages to help with maintenance.
|
||||
|
||||
We will record volunteers willing to help maintain these language resources in the documentation.
|
||||
If contributors want to have new localization texts translated into these languages in a timely manner,
|
||||
they can @ these volunteers in the PR for help.
|
||||
|
||||
If you are willing to help maintain localization resources for any language, please open a PR
|
||||
and add your GitHub username to the volunteer list above.
|
||||
We greatly appreciate your help.
|
||||
|
||||
## Adding Support for a New Language
|
||||
|
||||
HMCL welcomes anyone to participate in translation and contribution.
|
||||
However, maintaining translations for more languages requires more maintenance effort, so we have some requirements for newly added languages.
|
||||
Please confirm the following requirements before contributing:
|
||||
|
||||
- We prioritize [languages officially supported by Minecraft](https://minecraft.wiki/w/Language).
|
||||
|
||||
Unless there are special reasons, we do not provide support for languages not yet supported by Minecraft.
|
||||
- We hope to provide long-term maintenance support for all languages.
|
||||
|
||||
Since the maintainers of this project are proficient in only a limited number of languages,
|
||||
to avoid support for new languages quickly becoming outdated due to lack of maintainers,
|
||||
we hope to find people proficient in the language to help us maintain the newly added localization files in the long term.
|
||||
If there may be a lack of long-term maintainers, we will be more cautious about adding support for that language.
|
||||
|
||||
We recommend that contributors submit a [feature request](https://github.com/HMCL-dev/HMCL/issues/new?template=feature.yml) before providing a new language translation,
|
||||
discuss with other contributors first, and determine the future maintenance plan before starting the translation work.
|
||||
|
||||
### Getting Started with Translation
|
||||
|
||||
If you want to add support for a new language to HMCL, please start by translating [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties).
|
||||
The vast majority of HMCL's texts are in this file, and translating it will translate the entire interface.
|
||||
|
||||
This is a Java Properties file, which is very simple in format.
|
||||
Before translating, please read the introduction to this format: [Properties file](https://en.wikipedia.org/wiki/.properties).
|
||||
|
||||
As the first step of translation, please look up the two- or three-letter language tag for your language in [this table](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry).
|
||||
For example, the language tag for English is `en`.
|
||||
|
||||
After determining the language tag, please create a file named `I18N_<localization file suffix>.properties` (e.g., `I18N_en.properties`) next to the [`I18N.properties` file](../HMCL/src/main/resources/assets/lang).
|
||||
Then you can start translating in this file.
|
||||
|
||||
The `I18N.properties` file follows the [resource fallback mechanism](#resource-fallback-mechanism) to look up missing translations.
|
||||
That is, you can translate entry by entry, and any untranslated entries will automatically fall back to English.
|
||||
|
||||
After translating part of the file, you can [build HMCL yourself](./README.md#compilation),
|
||||
and your translations will be included in the compiled HMCL.
|
||||
If your computer's default environment is not the target language,
|
||||
you can set the environment variable `HMCL_LANGUAGE` to the language tag you just found from the table,
|
||||
and HMCL will automatically switch to that language.
|
||||
|
||||
At this point, you can push the file to GitHub and submit a PR to HMCL.
|
||||
The maintainers of HMCL will complete the remaining steps for you.
|
||||
|
||||
## Localization Resources
|
||||
|
||||
All documentation and localization resource files follow the naming rule `<resource name><localization file suffix>.<extension>`.
|
||||
|
||||
For example, for `README.md`, the localized versions in different languages are named as follows:
|
||||
|
||||
- English: `README.md`
|
||||
- Chinese (Simplified): `README_zh.md`
|
||||
- Chinese (Traditional): `README_zh_Hant.md`
|
||||
- Chinese (Classical): `README_lzh.md`
|
||||
|
||||
In addition to localized files, HMCL also supports localizing certain text fields in JSON. Localized text in JSON uses the following format:
|
||||
|
||||
```json5
|
||||
{
|
||||
"<Localization Key 1>": "<Localized Text 1>",
|
||||
"<Localization Key 2>": "<Localized Text 2>",
|
||||
// ...
|
||||
"<Localization Key N>": "<Localized Text N>"
|
||||
}
|
||||
```
|
||||
|
||||
For example, for the following text field:
|
||||
|
||||
```json
|
||||
{
|
||||
"meow": "Meow"
|
||||
}
|
||||
```
|
||||
|
||||
It can be rewritten as localized text:
|
||||
|
||||
```json
|
||||
{
|
||||
"meow": {
|
||||
"default": "Meow",
|
||||
"zh": "喵呜",
|
||||
"zh-Hant": "喵嗚"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resource Fallback Mechanism
|
||||
|
||||
For missing resources in a certain language, HMCL supports a resource fallback mechanism,
|
||||
which derives a search list based on different language tags and searches for resources in order according to this list.
|
||||
|
||||
Before searching, we first refine the language tag through the following steps.
|
||||
|
||||
1. Normalize Language Codes
|
||||
|
||||
If the language code subtag in the current language tag is not registered in the IANA Language Subtag Registry,
|
||||
HMCL will try to map it to a registered tag.
|
||||
|
||||
For example, HMCL will replace the language code `eng` with `en`.
|
||||
|
||||
2. Map Macrolanguages to Individual Languages
|
||||
|
||||
If the current language code is an [ISO 639 macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
and the macrolanguage usually refers to a specific individual language, HMCL will replace it with that individual language.
|
||||
|
||||
For example, `zh` (Chinese) usually actually refers to `cmn` (Mandarin), so we replace the language code `zh` with `cmn`.
|
||||
|
||||
3. Derive Script
|
||||
|
||||
If the current language tag does not specify a script, HMCL will try to derive the script according to the following rules in order:
|
||||
|
||||
1. If the current language tag specifies a variant, and the variant is registered in the IANA Language Subtag Registry,
|
||||
and all its `Prefix` entries in the registry contain the same script, then set the current script to that script.
|
||||
|
||||
For example, if the current variant is `pinyin` (Chinese Pinyin), the script will be set to `Latn` (Latin).
|
||||
|
||||
2. If the current language code is assigned a `Suppress-Script` in the IANA Language Subtag Registry, set the current script to that script.
|
||||
|
||||
For example, if the current language code is `en` (English), the script will be set to `Latn` (Latin);
|
||||
if the code is `ru` (Russian), the script will be set to `Cyrl` (Cyrillic).
|
||||
|
||||
3. If the current language code is `lzh` (Classical Chinese), set the script to `Hant` (Traditional Chinese Characters).
|
||||
|
||||
4. If the current language code is `zh` or a sublanguage of `zh`, check if the current region code is one of `TW`, `HK`, or `MO`.
|
||||
If true, set the script to `Hant` (Traditional Chinese Characters);
|
||||
otherwise, set it to `Hans` (Simplified Chinese Characters).
|
||||
|
||||
After refining the language code, HMCL will derive a list of language tags based on this language tag.
|
||||
|
||||
For example, for the language tag `en-US`, HMCL will refine it to `en-Latn-US` and derive the following search list:
|
||||
|
||||
1. `en-Latn-US`
|
||||
2. `en-Latn`
|
||||
3. `en-US`
|
||||
4. `en`
|
||||
5. `und`
|
||||
|
||||
For the language tag `zh-CN`, HMCL will refine it to `cmn-Hans-CN` and derive the following search list:
|
||||
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
|
||||
For resources that can be merged (such as `.properties` files),
|
||||
HMCL will merge resources according to the priority of this list; for resources that are difficult to merge (such as font files),
|
||||
HMCL will load the highest-priority resource found in this list.
|
||||
|
||||
If the current language uses a three-letter ISO 639 code, but there is also a corresponding two-letter code,
|
||||
HMCL will map it to the two-letter code before searching for resources.
|
||||
|
||||
For example, if the current environment's language tag is `eng-US`,
|
||||
HMCL will map it to `en-US` and then search for localization resources according to the above rules.
|
||||
|
||||
### Additional Rules for Chinese
|
||||
|
||||
HMCL always adds `zh-CN` to the search list for all Chinese environments,
|
||||
and adds `zh-TW` to the search list for all Traditional Chinese environments.
|
||||
|
||||
Below are the localization resource search lists for several common Chinese environments.
|
||||
|
||||
- `zh-CN`:
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
- `zh-SG`:
|
||||
1. `cmn-Hans-SG`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-SG`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-SG`
|
||||
6. `zh-Hans`
|
||||
7. `zh-SG`
|
||||
8. `zh-CN`
|
||||
9. `zh`
|
||||
10. `und`
|
||||
- `zh-TW`:
|
||||
1. `zh-Hant-TW`
|
||||
2. `zh-Hant`
|
||||
3. `zh-TW`
|
||||
4. `zh`
|
||||
5. `zh-CN`
|
||||
6. `und`
|
||||
- `zh-HK`:
|
||||
1. `cmn-Hant-HK`
|
||||
2. `cmn-Hant`
|
||||
3. `cmn-HK`
|
||||
4. `cmn`
|
||||
5. `zh-Hant-HK`
|
||||
6. `zh-Hant`
|
||||
7. `zh-HK`
|
||||
8. `zh-TW`
|
||||
9. `zh`
|
||||
10. `zh-CN`
|
||||
11. `und`
|
||||
- `lzh`:
|
||||
1. `lzh-Hant`
|
||||
2. `lzh`
|
||||
3. `zh-Hant`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
|
||||
## Automatic Synchronization of Documentation Content
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY PROCESS_LINK=false -->
|
||||
To simplify documentation maintenance, HMCL uses a macro mechanism to automatically maintain parts of the documentation content.
|
||||
Run the following command in the terminal:
|
||||
|
||||
```bash
|
||||
./gradlew updateDocuments
|
||||
```
|
||||
|
||||
This will automatically update all documentation content.
|
||||
|
||||
For example, to create links for switching between different language versions of the same document,
|
||||
add the following content under the document title:
|
||||
|
||||
```markdown
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
```
|
||||
|
||||
After running `./gradlew updateDocuments`, these two lines will be automatically replaced with language switcher links like the following:
|
||||
|
||||
```markdown
|
||||
**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)
|
||||
```
|
||||
|
||||
For more about macros, see [MacroProcessor.java](../buildSrc/src/main/java/org/jackhuang/hmcl/gradle/docs/MacroProcessor.java).
|
||||
<!-- #END BLOCK -->
|
||||
+102
-46
@@ -1,6 +1,8 @@
|
||||
# 本地化
|
||||
|
||||
<!-- TODO: 本文档需要进一步完善。为了便于修改,我们暂时不将本文件翻译至其他语言,在完善后再在独立 PR 中提供其他语言的译文。 -->
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
[English](Localization.md) | **中文**
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
HMCL 为多种语言提供本地化支持。
|
||||
|
||||
@@ -27,19 +29,17 @@ HMCL 为多种语言提供本地化支持。
|
||||
|
||||
HMCL 使用符合 IETF BCP 47 规范的语言标签。
|
||||
|
||||
对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。
|
||||
在选择语言标签时,我们会遵循以下原则:
|
||||
|
||||
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
|
||||
1. 对于 ISO 639 标准中定义的语言,如果已经在 [IANA 语言子标签注册表](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)中注册,我们总是使用经过注册的标签。
|
||||
|
||||
对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称
|
||||
(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。
|
||||
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
|
||||
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
|
||||
|
||||
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
|
||||
2. 对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称。
|
||||
|
||||
此外,语言代码中应当尽可能选择地区中立的语言标签。
|
||||
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
|
||||
|
||||
例如,对于简体中文和繁体中文,我们使用 `zh-Hans`和 `zh-Hant` 作为语言代码,而不是 `zh-CN` 和 `zh-TW`。
|
||||
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -106,10 +106,10 @@ HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译
|
||||
这是一个 Java Properties 文件,格式非常简单。
|
||||
在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
|
||||
|
||||
作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。
|
||||
作为翻译的第一步,请从[这张表格](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)中查询这个语言对应的两字母或三字母语言标签。
|
||||
例如,英语的语言标签为 `en`。
|
||||
|
||||
在确定了语言标签后,请在 [`I18N.properties` 文件旁](../HMCL/src/main/resources/assets/lang)创建 `I18N_<语言标签>.properites` (例如 `I18N_en.properties`) 文件。
|
||||
在确定了语言标签后,请在 [`I18N.properties` 文件旁](../HMCL/src/main/resources/assets/lang)创建 `I18N_<本地化文件后缀>.properties` (例如 `I18N_en.properties`) 文件。
|
||||
随后,你就可以开始在这个文件中进行翻译工作了。
|
||||
|
||||
`I18N.properties` 文件会遵循[资源回退机制](#资源回退机制)查询缺失的译文。
|
||||
@@ -169,12 +169,62 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
对于某个语言下的缺失的资源,HMCL 支持一套资源回退机制,会根据不同的语言标签推导出一个搜索列表,
|
||||
根据该列表依次搜索资源。
|
||||
|
||||
例如,如果当前环境的语言标签为 `en-US`,那么 HMCL 会根据以下列表的顺序搜索对应的本地化资源:
|
||||
在搜索前,我们会先通过以下步骤对语言标签进行细化推导。
|
||||
|
||||
1. `en-US`
|
||||
1. 归一化语言代码
|
||||
|
||||
如果当前语言标签中的语言代码子标签未在 IANA 语言子标签注册表中进行注册,HMCL 会先尝试将其映射为注册表中已注册的标签。
|
||||
|
||||
例如,HMCL会将语言代码 `eng` 替换为 `en`。
|
||||
|
||||
2. 映射宏语言至子语言
|
||||
|
||||
如果当前语言代码是一个 [ISO 639 宏语言](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
且该宏语言通常指代某个个体语言,HMCL 会将其替换为该个体语言。
|
||||
|
||||
例如 `zh` (中文) 通常实际指代 `cmn` (官话),所以我们会将语言代码 `zh` 替换为 `cmn`。
|
||||
|
||||
3. 推导拼写脚本
|
||||
|
||||
如果当前语言标签中未指定拼写脚本,HMCL 会依次根据以下规则尝试推导拼写脚本:
|
||||
|
||||
1. 如果当前语言标签指定了语言变体,该语言变体已在 IANA 语言子标签注册表中,
|
||||
且注册表中其所有 `Prefix` 都包含相同的拼写脚本,则将当前拼写脚本指定为该脚本。
|
||||
|
||||
例如,如果当前语言变体为 `pinyin` (汉语拼音),则当前拼写脚本会被指定为 `Latn` (拉丁文)。
|
||||
|
||||
2. 如果当前语言代码在 IANA 语言子标签注册表中被指定了 `Suppress-Script`,则将当前拼写脚本指定为该脚本。
|
||||
|
||||
例如,如果当前语言代码为 `en` (英语),则当前拼写脚本会被指定为 `Latn` (拉丁文);
|
||||
如果当前语言代码为 `ru` (俄语),则当前拼写脚本会被指定为 `Cyrl` (西里尔文)。
|
||||
|
||||
3. 如果当前语言代码是 `lzh` (文言),则将当前拼写脚本指定为 `Hant` (繁体汉字)。
|
||||
|
||||
4. 如果当前语言代码是 `zh` 或 `zh` 的子语言,则检查当前国家/地区代码是否为 `TW`、`HK`、`MO` 之一。
|
||||
如果结果为真,则将当前拼写脚本指定为 `Hant` (繁体汉字);否则将当前拼写脚本指定为 `Hans` (简体汉字)。
|
||||
|
||||
在对语言代码细化推导完成后,HMCL 会开始根据此语言标签推导出一个语言标签列表。
|
||||
|
||||
例如,对于语言标签 `en-US`,HMCL 会将其细化为 `en-Latn-US`,并据此推导出以下搜索列表:
|
||||
|
||||
1. `en-Latn-US`
|
||||
2. `en-Latn`
|
||||
3. `en-US`
|
||||
2. `en`
|
||||
3. `und`
|
||||
|
||||
对于语言标签 `zh-CN`,HMCL 会将其细化为 `cmn-Hans-CN`,并据此推导出以下搜索列表:
|
||||
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
|
||||
对于能够混合的资源 (例如 `.properties` 文件),HMCL 会根据此列表的优先级混合资源;
|
||||
对于难以混合的资源 (例如字体文件),HMCL 会根据此列表加载找到的最高优先级的资源。
|
||||
|
||||
@@ -182,35 +232,33 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
|
||||
例如,如果当前环境的语言标签为 `eng-US`,那么 HMCL 会将其映射至 `en-US` 后再根据上述规则搜索本地化资源。
|
||||
|
||||
如果当前语言是一个 [ISO 639 宏语言](https://en.wikipedia.org/wiki/ISO_639_macrolanguage)的子语言,那么 HMCL 也会搜索宏语言对应的资源。
|
||||
|
||||
### 对于中文的额外规则
|
||||
|
||||
对于中文 (以及其子语言标签,例如文言文 (`lzh`)、普通话 (`cmn`)、粤语 (`yue`) 等等),HMCL 有着额外的支持。
|
||||
|
||||
如果当前环境的语言为中文 (及其子语言),且未指定书写脚本,那么 HMCL 会根据语言和地区标签推导出默认的书写脚本。
|
||||
|
||||
对于语言为 `lzh` 或地区为 `TW`、`HK`、`MO` 的情况,默认书写脚本为繁体中文 (`Hant`);
|
||||
而对于其他语言和地区,默认书写脚本为简体中文 (`Hans`)。
|
||||
|
||||
此外,HMCL 会将 `zh-CN` 加入所有中文环境的搜索列表中,将 `zh-TW` 加入所有繁体中文环境的搜索列表中,
|
||||
从而适应更多场景。
|
||||
HMCL 总是会将 `zh-CN` 加入所有中文环境的搜索列表中,将 `zh-TW` 加入所有繁体中文环境的搜索列表中。
|
||||
|
||||
以下是几个常见中文环境对应的本地化资源搜索列表。
|
||||
|
||||
- `zh-CN`:
|
||||
1. `zh-Hans-CN`
|
||||
2. `zh-Hans`
|
||||
3. `zh-CN`
|
||||
4. `zh`
|
||||
5. `und`
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
- `zh-SG`:
|
||||
1. `zh-Hans-SG`
|
||||
2. `zh-Hans`
|
||||
3. `zh-SG`
|
||||
4. `zh-CN`
|
||||
5. `zh`
|
||||
6. `und`
|
||||
1. `cmn-Hans-SG`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-SG`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-SG`
|
||||
6. `zh-Hans`
|
||||
7. `zh-SG`
|
||||
8. `zh-CN`
|
||||
9. `zh`
|
||||
10. `und`
|
||||
- `zh-TW`:
|
||||
1. `zh-Hant-TW`
|
||||
2. `zh-Hant`
|
||||
@@ -219,22 +267,30 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
5. `zh-CN`
|
||||
6. `und`
|
||||
- `zh-HK`:
|
||||
1. `zh-Hant-HK`
|
||||
2. `zh-Hant`
|
||||
3. `zh-HK`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
1. `cmn-Hant-HK`
|
||||
2. `cmn-Hant`
|
||||
3. `cmn-HK`
|
||||
4. `cmn`
|
||||
5. `zh-Hant-HK`
|
||||
6. `zh-Hant`
|
||||
7. `zh-HK`
|
||||
8. `zh-TW`
|
||||
9. `zh`
|
||||
10. `zh-CN`
|
||||
11. `und`
|
||||
- `lzh`:
|
||||
1. `lzh-Hant`
|
||||
2. `lzh`
|
||||
3. `zh-Hant`
|
||||
4. `zh`
|
||||
5. `und`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
|
||||
## 自动同步文档内容
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY PROCESS_LINK=false -->
|
||||
为了简化文档的维护,HMCL 使用了一套宏机制自动维护文档的部分内容。在命令行中执行
|
||||
|
||||
```bash
|
||||
@@ -253,8 +309,8 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
随后执行 `./gradlew updateDocuments`,这两行内容会被自动替换为类似这样的跳转链接:
|
||||
|
||||
```markdown
|
||||
**English** |
|
||||
中文 ([简体](README_zh.md), [繁體](README_zh.md), [文言](README_zh.md)) | [日本語](README_zh.md) | [español](README_zh.md) | [русский](README_zh.md) | [українська](README_zh.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)
|
||||
```
|
||||
|
||||
关于宏的更多内容,请见 [MacroProcessor.java](../buildSrc/src/main/java/org/jackhuang/hmcl/gradle/docs/MacroProcessor.java)。
|
||||
<!-- #END BLOCK -->
|
||||
+200
-13
@@ -4,20 +4,131 @@
|
||||
**English** | 中文 ([简体](PLATFORM_zh.md), [繁體](PLATFORM_zh_Hant.md))
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
## Launcher Compatibility
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY NAME=PLATFORM_TABLE -->
|
||||
| | Windows | Linux | macOS | FreeBSD |
|
||||
|----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:---------------------------|
|
||||
| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.8) |
|
||||
| x86 | ✅️ (~1.20.4) | ✅️ (~1.20.4) | / | / |
|
||||
| ARM64 | 👌 (Minecraft 1.8~1.18.2)<br/>✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.8) | 👌 (Minecraft 1.6~1.18.2)<br/>✅ (Minecraft 1.19+)<br/>✅ (use Rosetta 2) | ❔ |
|
||||
| ARM32 | /️ | 👌 (Minecraft 1.8~1.20.1) | / | / |
|
||||
| MIPS64el | / | 👌 (Minecraft 1.8~1.20.1) | / | / |
|
||||
| RISC-V 64 | / | 👌 (Minecraft 1.13~1.21.8) | / | / |
|
||||
| LoongArch64 | / | 👌 (Minecraft 1.6~1.21.8) | / | / |
|
||||
| LoongArch64 (Old World) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
|
||||
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
|
||||
| S390x | / | ❔ | / | / |
|
||||
<!-- #PROPERTY NAME=LAUNCHER_COMPATIBILITY -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Windows</th>
|
||||
<th>Linux</th>
|
||||
<th>macOS</th>
|
||||
<th>FreeBSD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>x86-64</td>
|
||||
<td>
|
||||
✅️ Fully supported (Windows 7 ~ Windows 11)
|
||||
<br>
|
||||
✅️ Fully supported (Windows Server 2008 R2 ~ 2025)
|
||||
<br>
|
||||
🕰️ <a href="https://github.com/HMCL-dev/HMCL/releases?q=3.6">HMCL 3.6</a> (Windows Vista)
|
||||
<br>
|
||||
🕰️ <a href="https://github.com/HMCL-dev/HMCL/releases?q=3.6">HMCL 3.6</a> (Windows Server 2003 ~ 2008)
|
||||
</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>✅ Fully supported</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86</td>
|
||||
<td>
|
||||
🕰️ Limited support (Windows 7 ~ Windows 10)
|
||||
<br>
|
||||
🕰️ <a href="https://github.com/HMCL-dev/HMCL/releases?q=3.6">HMCL 3.6</a> (Windows XP/Vista)
|
||||
</td>
|
||||
<td>🕰️ Limited support</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ARM64</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ARM32</td>
|
||||
<td>/️</td>
|
||||
<td>🕰️ Limited support</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MIPS64el</td>
|
||||
<td>/</td>
|
||||
<td>🕰️ Limited support</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RISC-V 64</td>
|
||||
<td>/</td>
|
||||
<td>✅️ Fully supported</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LoongArch64</td>
|
||||
<td>/</td>
|
||||
<td>
|
||||
✅️ Fully supported (New World)
|
||||
<br>
|
||||
🕰️ Limited support (Old World)
|
||||
</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- #END BLOCK -->
|
||||
|
||||
Legend:
|
||||
|
||||
* ✅️ Fully supported
|
||||
|
||||
Platforms that are fully supported. HMCL will provide support for these platforms as much as possible.
|
||||
|
||||
* 🕰️ Limited support
|
||||
|
||||
These platforms are usually legacy platforms.
|
||||
|
||||
HMCL can work on these platforms, but some features may not be available.
|
||||
|
||||
We may drop some features for these platforms to reduce maintenance costs.
|
||||
|
||||
* 🕰️ HMCL 3.6 (Limited support)
|
||||
|
||||
The main branch of HMCL no longer supports these platforms.
|
||||
|
||||
We continue to provide security patches and bug fixes for these platforms through the HMCL 3.6 LTS branch,
|
||||
but no feature updates will be available.
|
||||
|
||||
* / (Not supported)
|
||||
|
||||
HMCL does not support these platforms yet. We may support them in the future.
|
||||
|
||||
## Game Compatibility
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY NAME=GAME_COMPATIBILITY -->
|
||||
| | Windows | Linux | macOS | FreeBSD |
|
||||
|-----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:----------------------------|
|
||||
| x86-64 | ✅️ | ✅️ | ✅️ | 👌 (Minecraft 1.13~1.21.10) |
|
||||
| x86 | ✅️ (~1.20.4) | ✅️ (~1.20.4) | / | / |
|
||||
| ARM64 | 👌 (Minecraft 1.8~1.18.2)<br/>✅ (Minecraft 1.19+) | 👌 (Minecraft 1.8~1.21.10) | 👌 (Minecraft 1.6~1.18.2)<br/>✅ (Minecraft 1.19+)<br/>✅ (use Rosetta 2) | ❔ |
|
||||
| ARM32 | /️ | 👌 (Minecraft 1.8~1.20.1) | / | / |
|
||||
| MIPS64el | / | 👌 (Minecraft 1.8~1.20.1) | / | / |
|
||||
| RISC-V 64 | / | 👌 (Minecraft 1.13~1.21.5) | / | / |
|
||||
| LoongArch64 (New World) | / | 👌 (Minecraft 1.6~1.21.10) | / | / |
|
||||
| LoongArch64 (Old World) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
|
||||
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
|
||||
| S390x | / | ❔ | / | / |
|
||||
<!-- #END BLOCK -->
|
||||
|
||||
Legend:
|
||||
@@ -41,3 +152,79 @@ Legend:
|
||||
|
||||
We have no plans to support these platforms at this time, mainly because we do not have the equipment to test them.
|
||||
If you can help us adapt, please file a support request via GitHub Issue.
|
||||
|
||||
## Terracotta Compatibility
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY NAME=TERRACOTTA_COMPATIBILITY -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Windows</th>
|
||||
<th>Linux</th>
|
||||
<th>macOS</th>
|
||||
<th>FreeBSD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>x86-64</td>
|
||||
<td>
|
||||
✅️ (Windows 10 ~ Windows 11)
|
||||
<br>
|
||||
✅️ (Windows Server 2016 ~ 2025)
|
||||
</td>
|
||||
<td>✅️</td>
|
||||
<td>✅️</td>
|
||||
<td>✅️</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ARM64</td>
|
||||
<td>✅️</td>
|
||||
<td>✅️</td>
|
||||
<td>✅️</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ARM32</td>
|
||||
<td>/️</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MIPS64el</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RISC-V 64</td>
|
||||
<td>/</td>
|
||||
<td>❔</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LoongArch64</td>
|
||||
<td>/</td>
|
||||
<td>
|
||||
✅️ (New World)
|
||||
<br>
|
||||
❌ (Old World)
|
||||
</td>
|
||||
<td>/</td>
|
||||
<td>/</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- #END BLOCK -->
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user