Compare commits

...
46 Commits
Author SHA1 Message Date
Glavo 1bbc6ef777 更新模组数据 (2025-11-07) (#4770) 2025-11-09 20:57:10 +08:00
辞庐 f4c6a4f891 优化 Github Actions 构建速度 (#4745) 2025-11-08 21:54:15 +08:00
Glavo 733b2c90c4 Fix #4538: 删除实例失败时弹出对话框 (#4540) 2025-11-08 21:52:01 +08:00
Glavo 81b61523df 使 NetworkUtils.doGet 支持更多 URI scheme (#4768) 2025-11-08 21:51:28 +08:00
Glavo d93f4d6bbb 添加对 25w45a_unobfuscated 的支持 (#4767) 2025-11-08 21:50:16 +08:00
neveler f0a16b0cd9 使用矩阵简化更新工作流 (#4725) 2025-11-08 21:20:01 +08:00
Glavo 783cadcf77 从注册表中读取 Windows 版本信息 (#4760) 2025-11-08 20:13:38 +08:00
Glavo cbb4b0c51b 创建 ObservableSetting 辅助类 (#4719) 2025-11-02 21:33:54 +08:00
Glavo c4d9803177 更新 PLATFORM.md (#4750) 2025-11-02 20:52:47 +08:00
Glavo 2ce4fcabcb Revert #4453: 恢复对 JavaFX 14 的兼容性 (#4749) 2025-11-02 20:52:12 +08:00
3a30e5b048 修复 Terracotta 在 macOS 上的兼容性问题 (#4723)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-02 20:29:41 +08:00
Glavo 49b0a99ced 优化检查更新日志的方式 (#4743) 2025-11-02 20:27:25 +08:00
Wulian233 8f2c860242 更新 Chunkbase 种子版本 (#4742) 2025-11-01 22:32:50 +08:00
Glavo 8c10775de9 在 PLATFORM.md 中记录启动器对平台的支持状态 (#4737) 2025-11-01 16:46:38 +08:00
3gf8jv4dv b5bf7ce4d0 fix(i18n): update i18n for open dir button on instance mods tab (#4734) 2025-11-01 16:46:13 +08:00
Glavo bd6a6fa9b6 在不支持的平台上打开陶瓦联机页面时弹出提示 (#4735) 2025-10-31 21:07:25 +08:00
Glavo 1c0ae3918d 修复 Platform.FREEBSD_X86_64 架构错误的问题 (#4736) 2025-10-31 21:05:37 +08:00
Glavo c6ce2df731 将官方群组入口重新移动到主界面 (#4727) 2025-10-28 20:05:59 +08:00
Burning_TNT 18985c0b63 Bump Terracotta to 0.3.12 (#4724) 2025-10-27 22:09:28 +08:00
Minecraft-温迪andGlavo 322e29642d 只在未被打包为 App 时设置 macOS Dock 栏图标 (#4698)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-27 21:45:13 +08:00
Glavo a86e5d7bf5 Update PLATFORM.md (#4717) 2025-10-27 21:23:29 +08:00
Glavo c90eaa2937 Fix #4720: 修复 JFXListViewSkin 创建大量平台线程的问题 (#4721) 2025-10-27 21:20:32 +08:00
Glavo defacb3554 限制 ./gradlew run 运行时的默认内存占用 (#4722) 2025-10-27 20:51:51 +08:00
Glavo 0a66863272 重构 TerracottaMetadata (#4718) 2025-10-26 20:21:29 +08:00
Glavo d90df221bc 添加详细构建指南 (#4524) 2025-10-26 15:53:15 +08:00
Glavo fd7f909561 将 Localization.md 翻译至英语 (#4669) 2025-10-24 11:55:14 +08:00
neveler a921a11be6 Add tooltip to external link button (#4696) 2025-10-22 21:53:17 +08:00
Glavoand3gf8jv4dv 7fbc99f3eb 优化没有可启动的游戏时的提示 (#4697)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2025-10-22 20:45:56 +08:00
3gf8jv4dv 7e40c799e5 style(l10n): simplify update button text in mod management header (#4712) 2025-10-22 20:40:59 +08:00
Glavo f2032ab7a8 使用 Translator 格式化下载速度 (#4709) 2025-10-22 20:35:42 +08:00
GlavoandCopilot d7c6a23dbe 通过解析 IANA 语言子标签注册表增强本地化功能 (#4675)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 15:37:32 +08:00
Glavo 27e1e021d7 优化更新日志展示功能 (#4703)
1. 如果检测到是降级更新,则不展示更新日志;
2. 如果检测到最新更新日志和最新版本不匹配,则不显示更新日志;
3. 展示当前版本与 `nowchange`/`nowpreview` 之间的所有更新日志,而不只展示一个版本的日志。
2025-10-20 20:21:13 +08:00
Glavo 2516fb5d69 在陶瓦联机界面的启动游戏按钮中显示实例名称 (#4705) 2025-10-20 20:08:31 +08:00
Burning_TNTandGlavo d66c080dea Feature: Download Terracotta binary from Gitee, CNB, alist (sponsored by 8mi) first in China mainland. (#4700)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-10-19 21:03:32 +08:00
Glavo 94ca81035b 优化陶瓦联机的复制邀请码功能 (#4687) 2025-10-18 22:18:14 +08:00
Glavo 1ab414e702 优化 Task 的资源占用 (#4694) 2025-10-18 21:50:23 +08:00
Glavo 16f6e89e45 删除 CompletableFutureTask.resolveException (#4690) 2025-10-18 21:47:39 +08:00
Glavo 22f6bfd845 删除 VersionPage.Skin 中未使用的 BorderPane (#4695) 2025-10-18 21:21:41 +08:00
Glavo c4823b3e55 优化陶瓦联机用户须知对话框 (#4686) 2025-10-18 20:25:00 +08:00
Glavo e34ec9f801 调整陶瓦联机界面左侧按钮 (#4688) 2025-10-18 20:20:17 +08:00
Burning_TNT d2e87ba024 Bump Terracotta to 0.3.10. (#4682) 2025-10-17 20:59:10 +08:00
Glavo ffb50423af Bump version root to 3.8 (#4676) 2025-10-16 20:43:16 +08:00
Glavo 69f19115ea Bump HMCLauncher to 3.7.0.1 (#4681) 2025-10-16 15:29:50 +08:00
Glavo ecb4eedd12 指定使用 UTF-8 编码编译 buildSrc 子项目 (#4677) 2025-10-15 21:11:00 +08:00
Glavo df6f35bc8e Fix #4670: 修复不在场景中的按钮尝试取消自身焦点时触发 NPE 的问题 (#4674) 2025-10-15 15:05:21 +08:00
Glavo 58d3780553 Fix #4663: 排序模组时应忽略大小写 (#4665) 2025-10-13 15:13:46 +08:00
95 changed files with 4138 additions and 1365 deletions

No files matched your search

+4
View File
@@ -21,5 +21,9 @@ jobs:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Check Codes
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
+19 -59
View File
@@ -9,9 +9,19 @@ permissions:
contents: write
jobs:
dev-check-update:
check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- channel: dev
task: checkUpdateDev
- channel: stable
task: checkUpdateStable
runs-on: ubuntu-latest
name: check-update-${{ matrix.channel }}
steps:
- uses: actions/checkout@v5
- name: Set up JDK
@@ -22,7 +32,7 @@ jobs:
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace
- name: Check for existing tags
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
@@ -34,66 +44,16 @@ jobs:
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
echo "| --- | --- |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
- name: Create release
if: ${{ env.continue == 'true' }}
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTE
files: |
HMCL-${{ env.HMCL_VERSION }}.exe
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.sh
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
prerelease: true
stable-check-update:
if: ${{ github.repository_owner == 'HMCL-dev' }}
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '25'
- name: Fetch tags
run: git fetch --all --tags
- name: Fetch last version
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
- name: Check for existing tags
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
- name: Download artifacts
if: ${{ env.continue == 'true' }}
run: |
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/stable.html" >> RELEASE_NOTE
if [ "${{ matrix.channel }}" = "stable" ]; then
echo "**This version is a stable version.**" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
fi
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/${{ matrix.channel }}.html" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "*Notice: changelogs are written in Chinese.*" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
+6 -2
View File
@@ -19,8 +19,12 @@ jobs:
distribution: 'zulu'
java-version: '17'
java-package: 'jdk+fx'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-cleanup: never
- name: Build with Gradle
run: ./gradlew build --no-daemon
run: ./gradlew build --no-daemon --parallel
env:
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
@@ -47,4 +51,4 @@ jobs:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
HMCL/build/libs/HMCL-*.sh.sha256
+3
View File
@@ -19,6 +19,9 @@ hmcl-exported-logs-*
/.local/
/.cache/
# IANA Language Subtag Registry
language-subtag-registry
# gradle build
/build/
/HMCL/build/
+1 -1
View File
@@ -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()) {
@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
@@ -38,18 +37,15 @@ import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.*;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.*;
@JsonAdapter(value = Config.Adapter.class)
public final class Config implements Observable {
public final class Config extends ObservableSetting {
public static final int CURRENT_VERSION = 2;
public static final int CURRENT_UI_VERSION = 0;
@@ -67,54 +63,15 @@ public final class Config implements Observable {
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.create();
private static final List<ObservableField<Config>> FIELDS;
static {
final MethodHandles.Lookup lookup = MethodHandles.lookup();
Field[] fields = Config.class.getDeclaredFields();
var configFields = new ArrayList<ObservableField<Config>>(fields.length);
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers))
continue;
configFields.add(ObservableField.of(lookup, field));
}
FIELDS = List.copyOf(configFields);
}
@Nullable
public static Config fromJson(String json) throws JsonParseException {
return CONFIG_GSON.fromJson(json, Config.class);
}
private transient final ObservableHelper helper = new ObservableHelper(this);
private transient final DirtyTracker tracker = new DirtyTracker();
private transient final Map<String, JsonElement> unknownFields = new HashMap<>();
public Config() {
var shouldBeWrite = Collections.<Observable>newSetFromMap(new IdentityHashMap<>());
Collections.addAll(shouldBeWrite, configVersion, uiVersion);
for (var field : FIELDS) {
Observable observable = field.get(this);
if (shouldBeWrite.contains(observable))
tracker.markDirty(observable);
else
tracker.track(observable);
observable.addListener(helper);
}
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
tracker.markDirty(configVersion);
tracker.markDirty(uiVersion);
register();
}
public String toJson() {
@@ -775,53 +732,10 @@ public final class Config implements Observable {
return configurations;
}
public static final class Adapter implements JsonSerializer<Config>, JsonDeserializer<Config> {
public static final class Adapter extends ObservableSetting.Adapter<Config> {
@Override
public JsonElement serialize(Config config, Type typeOfSrc, JsonSerializationContext context) {
if (config == null)
return JsonNull.INSTANCE;
JsonObject result = new JsonObject();
for (var field : FIELDS) {
Observable observable = field.get(config);
if (config.tracker.isDirty(observable)) {
field.serialize(result, config, context);
}
}
config.unknownFields.forEach(result::add);
return result;
}
@Override
public Config deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json.isJsonNull())
return null;
if (!json.isJsonObject())
throw new JsonParseException("Config is not an object: " + json);
Config config = new Config();
var values = new LinkedHashMap<>(json.getAsJsonObject().asMap());
for (ObservableField<Config> field : FIELDS) {
JsonElement value = values.remove(field.getSerializedName());
if (value == null) {
for (String alternateName : field.getAlternateNames()) {
value = values.remove(alternateName);
if (value != null)
break;
}
}
if (value != null) {
config.tracker.markDirty(field.get(config));
field.deserialize(config, value, context);
}
}
config.unknownFields.putAll(values);
return config;
protected Config createInstance() {
return new Config();
}
}
}
@@ -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) {
@@ -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,7 +23,9 @@ 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;
@@ -42,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;
@@ -61,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(
@@ -138,36 +144,31 @@ public final class TerracottaMetadata {
@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 /\" 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);
}
}
@@ -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"),
@@ -102,7 +102,7 @@ public abstract class ToolbarListPageSkin<T extends ListPageBase<? extends Node>
ret.setText(text);
ret.setOnAction(e -> {
onClick.run();
ret.getScene().getRoot().requestFocus();
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));
});
@@ -181,7 +181,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> {
onOpenWiki();
wikiButton.getScene().getRoot().requestFocus();
FXUtils.clearFocus(wikiButton);
});
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
@@ -250,7 +250,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
twoLineListItem.addTag(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
}
case PENDING, SNAPSHOT -> {
case SNAPSHOT, PENDING, UNOBFUSCATED -> {
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
twoLineListItem.addTag(i18n("version.game.april_fools"));
@@ -455,7 +455,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
return switch (filter) {
case RELEASE -> versionType == RemoteVersion.Type.RELEASE;
case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT
|| versionType == RemoteVersion.Type.PENDING;
|| versionType == RemoteVersion.Type.PENDING
|| versionType == RemoteVersion.Type.UNOBFUSCATED;
case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools();
case OLD -> versionType == RemoteVersion.Type.OLD;
@@ -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);
@@ -34,11 +34,8 @@ 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;
@@ -138,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;
}
@@ -202,7 +199,7 @@ public class TerracottaControllerPage extends StackPane {
}
}
} 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) {
@@ -342,6 +339,7 @@ public class TerracottaControllerPage extends StackPane {
return;
} else {
String cs = hostOK.getCode();
copyCode(cs);
statusProperty.set(i18n("terracotta.status.host_ok"));
progressProperty.set(1);
@@ -350,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);
@@ -601,6 +587,10 @@ 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();
@@ -19,22 +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 {
@@ -43,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);
@@ -53,32 +64,34 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
});
AdvancedListItem feedback = new AdvancedListItem();
feedback.setLeftGraphic(wrap(SVG.FEEDBACK));
feedback.setActionButtonVisible(false);
feedback.setTitle(i18n("terracotta.feedback.title"));
feedback.setOnAction(e -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
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(feedback)
.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);
}
@@ -104,7 +104,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
lock.lock();
try {
modManager.refreshMods();
return modManager.getMods();
return modManager.getMods().stream().map(ModListPageSkin.ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
@@ -114,7 +114,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
updateSupportedLoaders(modManager);
if (exception == null) {
getItems().setAll(list.stream().map(ModListPageSkin.ModInfoObject::new).toList());
getItems().setAll(list);
} else {
LOG.warning("Failed to load mods", exception);
getItems().clear();
@@ -18,7 +18,6 @@
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;
@@ -61,7 +60,6 @@ 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;
@@ -152,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))
@@ -289,7 +287,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
}
}
static final class ModInfoObject extends RecursiveTreeObject<ModInfoObject> implements Comparable<ModInfoObject> {
static final class ModInfoObject {
private final BooleanProperty active;
private final LocalModFile localModFile;
private final @Nullable ModTranslations.Mod modTranslations;
@@ -403,12 +401,6 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
imageView.setImage(image);
}, Schedulers.javafx());
}
@Override
public int compareTo(@NotNull ModListPageSkin.ModInfoObject o) {
return localModFile.getFileName().toLowerCase(Locale.ROOT)
.compareTo(o.localModFile.getFileName().toLowerCase(Locale.ROOT));
}
}
final class ModInfoDialog extends JFXDialogLayout {
@@ -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"));
@@ -112,7 +112,14 @@ public final class Versions {
JFXButton deleteButton = new JFXButton(i18n("button.delete"));
deleteButton.getStyleClass().add("dialog-error");
deleteButton.setOnAction(e -> profile.getRepository().removeVersionFromDisk(version));
deleteButton.setOnAction(e -> {
Task.supplyAsync(Schedulers.io(), () -> profile.getRepository().removeVersionFromDisk(version))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || !Boolean.TRUE.equals(result)) {
Controllers.dialog(i18n("version.manage.remove.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
}).start();
});
Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton);
}
@@ -222,9 +229,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 = {
@@ -236,6 +236,7 @@ public final class NativePatcher {
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)) {
@@ -71,6 +71,10 @@ public final class I18n {
return getTranslator().formatDateTime(time);
}
public static String formatSpeed(long bytes) {
return getTranslator().formatSpeed(bytes);
}
public static String getDisplaySelfVersion(RemoteVersion version) {
return getTranslator().getDisplayVersion(version);
}
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.translator.Translator_lzh;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -79,6 +80,8 @@ public final class MinecraftWiki {
if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
if (wikiVersion.equals("22w13oneblockatatime"))
wikiVersion = "22w13oneBlockAtATime";
else
wikiVersion = StringUtils.removeSuffix(wikiVersion, "_unobfuscated");
return locale.i18n("wiki.version.game.snapshot", wikiVersion) + variantSuffix;
}
}
@@ -59,4 +59,13 @@ public class Translator {
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));
}
}
}
@@ -23,25 +23,20 @@ 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 class Translator_en_Qabs extends Translator {
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
.withZone(ZoneId.systemDefault());
private static final Map<Integer, Integer> MAPPER;
private static final Map<Integer, Integer> MAPPER = loadMap();
private static Map<Integer, Integer> loadMap() {
static {
var map = new LinkedHashMap<Integer, Integer>();
InputStream inputStream = Translator_en_Qabs.class.getResourceAsStream("/assets/lang/upside_down.txt");
if (inputStream != null) {
try (inputStream) {
@@ -62,7 +57,7 @@ public class Translator_en_Qabs extends Translator {
} else {
LOG.warning("upside_down.txt not found");
}
return Collections.unmodifiableMap(map);
MAPPER = Collections.unmodifiableMap(map);
}
public static String translate(String str) {
@@ -71,6 +66,8 @@ public class Translator_en_Qabs extends Translator {
return builder.reverse().toString();
}
private final SupportedLocale originalLocale = SupportedLocale.getLocale(Locale.ENGLISH);
public Translator_en_Qabs(SupportedLocale locale) {
super(locale);
}
@@ -82,6 +79,11 @@ public class Translator_en_Qabs extends Translator {
@Override
public String formatDateTime(TemporalAccessor time) {
return translate(BASE_FORMATTER.format(time));
return translate(originalLocale.getTranslator().formatDateTime(time));
}
@Override
public String formatSpeed(long bytes) {
return translate(originalLocale.getTranslator().formatSpeed(bytes));
}
}
+1 -1
View File
@@ -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 {
@@ -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\
@@ -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.
@@ -1460,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
@@ -1476,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.
@@ -1486,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.
@@ -1525,7 +1533,8 @@ 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
@@ -1560,6 +1569,7 @@ version.manage.redownload_assets_index=Update Game Assets
version.manage.remove=Delete Instance
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
version.manage.remove.failed=Failed to delete the instance. Some files might be in use.
version.manage.remove_assets=Delete All Assets
version.manage.remove_libraries=Delete All Libraries
version.manage.rename=Rename Instance
@@ -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!
@@ -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.
@@ -1452,7 +1453,6 @@ 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=リリース
@@ -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\
@@ -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\
@@ -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\
@@ -1248,6 +1252,7 @@ terracotta.status.host_starting.back=這將會取消建立房間。
terracotta.status.host_ok=已建立房間
terracotta.status.host_ok.code=邀請碼 (已自動複製到剪貼簿)
terracotta.status.host_ok.code.copy=複製邀請碼
terracotta.status.host_ok.code.copy.toast=已將邀請碼複製到剪貼簿
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
terracotta.status.guest_starting=正在加入房間
@@ -1264,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=嚴重錯誤:無法與線上核心通訊
@@ -1274,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。我們無法保證其安全性,請注意甄別。
@@ -1309,7 +1317,8 @@ 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=愚人節
@@ -1344,6 +1353,7 @@ version.manage.redownload_assets_index=更新遊戲資源檔案
version.manage.remove=刪除該實例
version.manage.remove.confirm.trash=真的要刪除實例「%s」嗎? 你可以在系統的資源回收筒 (或垃圾桶) 中還原目錄「%s」來找回該實例。
version.manage.remove.confirm.independent=由於該實例啟用了「(全域/實例特定) 遊戲設定 → 執行路徑 → 各實例獨立」設定,刪除該實例將導致該遊戲的存檔等資料一同被刪除!真的要刪除實例「%s」嗎?
version.manage.remove.failed=刪除實例失敗。可能檔案被占用。
version.manage.remove_assets=刪除所有遊戲資源檔案
version.manage.remove_libraries=刪除所有支援庫檔案
version.manage.rename=重新命名該實例
@@ -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\
@@ -1258,6 +1262,7 @@ terracotta.status.host_starting.back=这将会取消创建房间。
terracotta.status.host_ok=已启动房间
terracotta.status.host_ok.code=邀请码 (已自动复制到剪贴板)
terracotta.status.host_ok.code.copy=复制邀请码
terracotta.status.host_ok.code.copy.toast=已将邀请码复制到剪贴板
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
terracotta.status.guest_starting=正在加入房间
@@ -1274,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=严重错误:无法与联机核心通讯
@@ -1284,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。我们无法保证其安全性,请注意甄别。
@@ -1319,7 +1327,8 @@ 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=愚人节
@@ -1354,6 +1363,7 @@ version.manage.redownload_assets_index=更新游戏资源文件
version.manage.remove=删除该实例
version.manage.remove.confirm.trash=真的要删除实例“%s”吗?你可以在系统的回收站中还原“%s”文件夹来找回该实例。
version.manage.remove.confirm.independent=由于该游戏启用了“(全局/实例特定) 游戏设置 → 版本隔离 → 各实例独立”选项,删除该实例将导致该游戏的存档等数据一同被删除!真的要删除实例“%s”吗?
version.manage.remove.failed=删除实例失败。可能文件被占用。
version.manage.remove_assets=删除所有游戏资源文件
version.manage.remove_libraries=删除所有库文件
version.manage.rename=重命名该实例
File diff suppressed because it is too large. Load diff
@@ -548,7 +548,7 @@ gregic-skies;555;;Gregic Skies;;
;557;;齿轮颂歌:暮光;Gear Carol;
pluma;558;;Pluma, a Journey to the Future!;;Pluma
all-the-mods-7-to-the-sky;559;;All the Mods 7 - To the Sky;;ATM7S
;560;;月见苍穹传说:大地演化;TskimiCanopyCraft: Terra Evolution;TSCC:TE
;560;;月见苍穹传说;TskimiCanopyCraft;TSCC
;561;;最后的战役:劫后余生;The Last Stand:Aftermath;LSA/TLS
;562;;小猪配平;Peppa Pig's Burning Broom;2P2B
zerblands-remastered;563;;Zerblands-Remastered;;
@@ -623,7 +623,7 @@ crop-block;632;;Crop Block;;
;634;;Technical Electrical;;
cobblemon-forge;635;;Cobblemon Official Modpack;;
sbeevs-industrial-revolution;636;;Sbeev's Industrial Revolution;;
;637;;Gensokyo Reimagined QOL;;
;637;;Gensokyo Reimagined QOL;;GR
statech-industry;638;;StaTech Industry;;
vault-hunters-1-18-2;639;;宝藏猎人3;Vault Hunters 3rd Edition;
survival-hard-rock;640;;生存硬摇滚;Survival Hard Rock;
@@ -791,7 +791,7 @@ craftingcraft;800;;合成工艺;CraftingCraft;
chemillas-reborn;804;;化学:重生;Chemilla's Reborn;CR
;805;;群峦传说·异世界行纪;Terra Firma Craft:Travel in Another World;TFC:TAW
milkyway;806;;Create: Milkyway;;
farming-crossing-4;807;;Farming Crossing 4;;
farming-crossing-4;807;;农业森友会 4;Farming Crossing 4;
hardrock-terrafirmacraft-4-realistic-extreme;808;;HardRock TerraFirmaCraft;;TFCH/HTFC
terrafirma-rebirth;809;;群峦:重生;TerraFirma: Rebirth;TFCR
create-live-5-skyblock;810;;Create Live 5;;
@@ -868,7 +868,7 @@ aeon-automata;881;;Aeon Automata;;AA
;882;;被恐惧支配2:收容失效;Dominated by Fear 2 Containment Breach;DoF2
;883;;剑拔弩张之时;;TC
project-architect-2;884;;Project Architect 2;;
;885;;路网通途 / 更好的我的世界铁路;Railway Network Access;RNA
;885;;路网通途 / 接入铁路网络;Railway Network Access;RNA
create-mekanized;886;;Create: Mekanized;;
create-perfect-world-2;887;;Create: Perfect World 2;;
create-stellar;888;;Create Stellar;;
@@ -1048,7 +1048,7 @@ beyond-depth;1062;;Beyond Depth;;
;1064;;真幻世界;Realfantacy World;R.F.
minenufactory;1065;;创世工厂;Minenufactory;MF
craft-to-exile-2;1066;;放逐之路2;Craft to Exile 2 (VR Support);CTE2
;1067;;龙境III:菌土重来;Dragon Odyssey:Return of the Fungal Earth;DO3
;1067;;龙境III:菌土重来;Dragon Odyssey: Fungreda;DO3
the-culinary-journey;1068;;美食的旅途;The Culinary Journey;TCJ
;1069;;缥缈方舟;;PMFZ
;1070;;东方匠魂传;Touhou Tinker Fantasy;TTF
@@ -1075,7 +1075,7 @@ roguelike-adventures-and-dungeons-3;1090;;冒险与地牢3;Roguelike Adventures
;1092;;玄理2:洗剪版;EnigTech 2 - Dishwasher's Cut;ET2DC
wheelchair-gregtech;1093;;Wheelchair Gregtech;;
egged-up;1094;;Egged Up;;
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;VEFC
;1096;;愚者;The Fool;TF
;1097;;时间之书2:星梦指南;BookofTime II Stardream Compass;BKSC
;1098;;群峦之上:无垠伊土;EndlessFirma;EnF
@@ -1131,7 +1131,7 @@ endless-horror-the-ultimate-horror-modpack;1146;;Endless Horror - The ULTIMATE H
picky;1148;;Picky;;
resource-factory;1149;;资源工厂;Resource Factory;
stacia-2-expert;1150;;Stacia 2 Expert;;S2E
;1151;;田园乐事;;
;1151;;田园乐事;Conutryside_Delight;
;1152;;世界之旅;Journey of the world;JOTW
;1153;;清新优化;Fresh optimization;FRO
;1154;;遗世之匠;Touhou:The Forgotten Swordsmith;
@@ -1159,8 +1159,7 @@ society-sunlit-valley;1176;;Society: Sunlit Valley;;
ciscos-fantasy-medieval-rpg-dragonfyre;1177;;Cisco's Fantasy Medieval RPG [Dragonfyre];;
all-the-mods-10-sky;1178;;All the Mods 10: To the Sky;;ATM10S
;1179;;谐伴匠途;;
the-cursed-wanderer-reborn;1180;;咒游诸界:重生;The Cursed Wanderer: Reborn;TCWR
;1181;;Turbo Vulkan;;TuVk
;1181;;Turbo Vulkan;;TV
uls-zsurvival-craft;1182;;Z-Liminex;;
;1183;;MITE 重生 3;MITE Reborn 3;MR
reclamation-reclaim-the-world;1184;;Reclamation - Reclaim the World!;;
@@ -1179,3 +1178,49 @@ pvzw;1195;;植物大战僵尸:世界;Plants VS Zombies : World;PVZW
new-century-adventure;1197;;新世纪之险;New Century Adventure;NCA
repalmon;1198;;Re帕鲁梦;;RPLM
structural-ingenuity;1199;;结构型奇思妙想;Structural Ingenuity;
arcaneaxis-ars;1200;;秘法象限;ArcaneAxis;ARS
gregtech-easy;1201;;格雷科技简单版;GregTech Easy;GTE
;1202;;Creation Exploring the Road of;;CETR
multiblockful-skies;1203;;Multiblockful Skies;;
;1204;;刀剑物语;SwordStory;SS
;1205;;无垠征途;The Unbounded Expedition;TUE
;1206;;云栖谧土;Nephosia;
custom-battleroyale-complete;1207;;完整自定义大逃杀;Custome BattleRoyale Complete;CBRC
paradise-of-lucky-blocks;1208;;Paradise of Lucky Blocks;;
;1209;;极致优化;Xavier Optimized;XO
;1210;;神秘时代4花园;Thaumcraft IV Sky Islands;
;1211;;弥散往生;Diffusion: Soulfare;
mana-metal-new-horizons;1212;;魔法金屬:新視野;Mana Metal: New Horizons;M3NH
farmy-skyblock;1213;;Farmy Skyblock;;
bloody-skyblock-modpack;1214;;Bloody Skyblock;;
starages;1215;;Star Space Ages;;
create-spectral-cuisine;1216;;Create: Spectral Cuisine;;
project-infinity-0-1;1217;;Project Infinity 0.1;;
create-live-6;1218;;Create Live 6;;
;1219;;全面包围-硬核惊变;Full encirclement;FLET
;1220;;科波菲尔;Copperfield;CPF
;1221;;试用核心/I.O.P的水槽基础整合;Trial Essential;
tnp-limitless-8;1222;;TNP Limitless 8;;
;1223;;重度机械症;Mechanomania;
;1224;;寻此苦旅3;Per Aspera3;PA3
calamity-rpg-iii-wounds-of-the-world;1225;;Calamity RPG III : Wounds of the World;;
;1226;;烛枫公会优化整合包;;ZFGH
legends-reborn-medieval;1227;;Legends Reborn: Medieval;;LRM
;1228;;双衰变;;DEGENE
nd-new-dawn;1229;;New Dawn;;ND
;1230;;星遥优化;StarCarefree Optimized;SO
;1231;;寄生仙途;;JSXT
;1232;;神秘启旅;Thaum Journey;
nightfallcraft-the-casket-of-reveries;1233;;远梦之棺;NightfallCraft - The Casket of Reveries;
openworld-battleroyale;1234;;开放世界大逃杀;Openworld BattleRoyale;OBR
;1235;;未尽之路;Unfinished Path;UP
;1236;;命运齿轮;Fate's Machinery;FOM
linggango;1237;;Linggango;;
create-industrialized-tech;1238;;Create: Industrialized Tech;;
;1239;;灵璟铁路都市;LingJing Train City;LJTC
erstwhile-journey-2;1240;;往昔之旅2;Erstwhile Journey 2;EJ2
;1241;;诡厄:使徒;;
;1242;;时间之书 : 星之旅行-重制版;BookofTime StarTravel;BKSL
cook-in-skyland-elegant-feast;1243;;天空厨房:典雅盛宴;Cook in Skyland Elegant Feast;CiS2
ftb-stoneblock-4;1244;;FTB Stoneblock 4;;SB4
techevdiscovery;1245;;TechEv || Discovery;;
+19 -16
View File
@@ -1,26 +1,29 @@
{
"version_legacy": "0\\.3\\.[89]-rc\\.([0-9]|10)",
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|10|11|12)",
"version_recent": [
"0.3.9-rc.10"
"0.3.12"
],
"version_latest": "0.3.10",
"version_latest": "0.3.13",
"classifiers": {
"linux-arm64": "sha256:001b6b1e220e98dffd84198a3e16855644cad27900562c78d610b0bf50138d17",
"linux-x86_64": "sha256:b133ddb796ff52879fb0c38e68ef7ea968d3eaec77f27ea274c701888e795782",
"macos-arm64": "sha256:4bb2eb432ea0ee3e3dca6b1019d8864aa14bedfe20b3d804865e216e36e856df",
"macos-arm64.pkg": "sha256:e4bf3d2014e417565e3ab0df579f4280e0af42bc28516f640067a52dca77098f",
"macos-x86_64": "sha256:17a2cc46d884951a88da6f3f0725e38ffca6fdfd1da021807121c29b944bd830",
"macos-x86_64.pkg": "sha256:a4f08a706605262485c1889dfae00554fba753141f6d6ada76e29bfc3e0338aa",
"windows-arm64.exe": "sha256:245e0faf2ee1cdc5e430b5c18fbab4c4e5e3f333f0de51a621ae772db3a16924",
"windows-x86_64.exe": "sha256:ab99ddb729860c939b2085d8852fd62007b86d97568b687cf2a7147718b64eea"
"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": [
{
@@ -128,6 +128,7 @@ public class RemoteVersion implements Comparable<RemoteVersion> {
RELEASE,
SNAPSHOT,
OLD,
PENDING
PENDING,
UNOBFUSCATED,
}
}
@@ -73,6 +73,7 @@ public final class GameRemoteVersion extends RemoteVersion {
case SNAPSHOT -> Type.SNAPSHOT;
case UNKNOWN -> Type.UNCATEGORIZED;
case PENDING -> Type.PENDING;
case UNOBFUSCATED -> Type.UNOBFUSCATED;
default -> Type.OLD;
};
}
@@ -28,6 +28,7 @@ public enum ReleaseType {
OLD_BETA("old-beta"),
OLD_ALPHA("old-alpha"),
PENDING("pending"),
UNOBFUSCATED("unobfuscated"),
UNKNOWN("unknown");
private final String id;
@@ -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
@@ -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);
});
}
@@ -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);
});
}
}
@@ -1,232 +0,0 @@
/*
* 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.gson;
import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import javafx.beans.Observable;
import javafx.beans.property.ListProperty;
import javafx.beans.property.MapProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SetProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.TypeUtils;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/// @author Glavo
public abstract class ObservableField<T> {
public static <T> ObservableField<T> of(MethodHandles.Lookup lookup, Field field) {
String name;
List<String> alternateNames;
SerializedName serializedName = field.getAnnotation(SerializedName.class);
if (serializedName == null) {
name = field.getName();
alternateNames = List.of();
} else {
name = serializedName.value();
alternateNames = List.of(serializedName.alternate());
}
VarHandle varHandle;
try {
varHandle = lookup.unreflectVarHandle(field);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e);
}
if (ObservableList.class.isAssignableFrom(field.getType())) {
Type listType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), List.class);
if (!(listType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the list type of " + field.getName());
return new CollectionField<>(name, alternateNames, varHandle, listType, listType);
} else if (ObservableSet.class.isAssignableFrom(field.getType())) {
Type setType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Set.class);
if (!(setType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the set type of " + field.getName());
ParameterizedType listType = TypeUtils.newParameterizedTypeWithOwner(
null,
List.class,
((ParameterizedType) setType).getActualTypeArguments()[0]
);
return new CollectionField<>(name, alternateNames, varHandle, setType, listType);
} else if (ObservableMap.class.isAssignableFrom(field.getType())) {
Type mapType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Map.class);
if (!(mapType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the map type of " + field.getName());
return new MapField<>(name, alternateNames, varHandle, mapType);
} else if (Property.class.isAssignableFrom(field.getType())) {
Type propertyType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Property.class);
if (!(propertyType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the element type of " + field.getName());
Type elementType = ((ParameterizedType) propertyType).getActualTypeArguments()[0];
return new PropertyField<>(name, alternateNames, varHandle, elementType);
} else {
throw new IllegalArgumentException("Field " + field.getName() + " is not a property or observable collection");
}
}
protected final String serializedName;
protected final List<String> alternateNames;
protected final VarHandle varHandle;
private ObservableField(String serializedName, List<String> alternateNames, VarHandle varHandle) {
this.serializedName = serializedName;
this.alternateNames = alternateNames;
this.varHandle = varHandle;
}
public String getSerializedName() {
return serializedName;
}
public List<String> getAlternateNames() {
return alternateNames;
}
public Observable get(T value) {
return (Observable) varHandle.get(value);
}
public abstract void serialize(JsonObject result, T value, JsonSerializationContext context);
public abstract void deserialize(T value, JsonElement element, JsonDeserializationContext context);
private static final class PropertyField<T> extends ObservableField<T> {
private final Type elementType;
PropertyField(String serializedName, List<String> alternate, VarHandle varHandle, Type elementType) {
super(serializedName, alternate, varHandle);
this.elementType = elementType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
Property<?> property = (Property<?>) get(value);
if (property instanceof RawPreservingProperty<?> rawPreserving) {
JsonElement rawJson = rawPreserving.getRawJson();
if (rawJson != null) {
result.add(getSerializedName(), rawJson);
return;
}
}
JsonElement serialized = context.serialize(property.getValue(), elementType);
if (serialized != null && !serialized.isJsonNull())
result.add(getSerializedName(), serialized);
}
@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
Property property = (Property) get(value);
try {
property.setValue(context.deserialize(element, elementType));
} catch (Throwable e) {
if (property instanceof RawPreservingProperty<?>) {
((RawPreservingProperty<?>) property).setRawJson(element);
} else {
throw e;
}
}
}
}
private static final class CollectionField<T> extends ObservableField<T> {
private final Type collectionType;
/// When deserializing a Set, we first deserialize it into a `List`, then put the elements into the Set.
private final Type listType;
CollectionField(String serializedName, List<String> alternate, VarHandle varHandle,
Type collectionType, Type listType) {
super(serializedName, alternate, varHandle);
this.collectionType = collectionType;
this.listType = listType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), collectionType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
List<?> deserialized = context.deserialize(element, listType);
Object fieldValue = get(value);
if (fieldValue instanceof ListProperty) {
((ListProperty<Object>) fieldValue).set(FXCollections.observableList((List<Object>) deserialized));
} else if (fieldValue instanceof ObservableList) {
((ObservableList<Object>) fieldValue).setAll(deserialized);
} else if (fieldValue instanceof SetProperty) {
((SetProperty<Object>) fieldValue).set(FXCollections.observableSet(new HashSet<>(deserialized)));
} else if (fieldValue instanceof ObservableSet) {
ObservableSet<Object> set = (ObservableSet<Object>) fieldValue;
set.clear();
set.addAll(deserialized);
} else {
throw new JsonParseException("Unsupported field type: " + fieldValue.getClass());
}
}
}
private static final class MapField<T> extends ObservableField<T> {
private final Type mapType;
MapField(String serializedName, List<String> alternate, VarHandle varHandle, Type mapType) {
super(serializedName, alternate, varHandle);
this.mapType = mapType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), mapType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T config, JsonElement element, JsonDeserializationContext context) {
Map<Object, Object> deserialized = context.deserialize(element, mapType);
ObservableMap<Object, Object> map = (ObservableMap<Object, Object>) varHandle.get(config);
if (map instanceof MapProperty<?, ?>)
((MapProperty<Object, Object>) map).set(FXCollections.observableMap(deserialized));
else {
map.clear();
map.putAll(deserialized);
}
}
}
}
@@ -0,0 +1,369 @@
/*
* 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.gson;
import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ListProperty;
import javafx.beans.property.MapProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SetProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import org.jackhuang.hmcl.util.TypeUtils;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.NotNull;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;
/// Represents a settings object with multiple [Observable] fields.
///
/// All instance fields in this object, unless marked as `transient`, are considered observable fields.
/// The field types should be subclasses of one of [javafx.beans.property.Property], [javafx.collections.ObservableList], [javafx.collections.ObservableSet], or [javafx.collections.ObservableMap].
///
/// This class implements the [Observable] interface.
/// If any field in a settings object changes, all listeners installed via [#addListener(InvalidationListener)] will be triggered.
///
/// For each observable field, this object tracks whether it has been modified. When serializing, fields that have never been modified will not be serialized by default.
///
/// All subclasses of this class must call [#register()] once in their constructor.
///
/// @author Glavo
public abstract class ObservableSetting implements Observable {
private static final ClassValue<List<? extends ObservableField<?>>> FIELDS = new ClassValue<>() {
@Override
protected List<? extends ObservableField<?>> computeValue(@NotNull Class<?> type) {
if (!ObservableSetting.class.isAssignableFrom(type))
throw new AssertionError("Type: " + type);
try {
var allFields = new ArrayList<ObservableField<?>>();
for (Class<?> current = type;
current != ObservableSetting.class;
current = current.getSuperclass()) {
final MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(current, MethodHandles.lookup());
Field[] fields = current.getDeclaredFields();
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers))
continue;
allFields.add(ObservableField.of(lookup, field));
}
}
return allFields;
} catch (IllegalAccessException e) {
throw new ExceptionInInitializerError(e);
}
}
};
protected transient final ObservableHelper helper = new ObservableHelper(this);
protected transient final Map<String, JsonElement> unknownFields = new HashMap<>();
protected transient final DirtyTracker tracker = new DirtyTracker();
private boolean registered = false;
protected final void register() {
if (registered)
return;
registered = true;
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<ObservableSetting>>) FIELDS.get(this.getClass());
for (var field : fields) {
Observable observable = field.get(this);
tracker.track(observable);
observable.addListener(helper);
}
}
@Override
public void addListener(InvalidationListener listener) {
helper.addListener(listener);
}
@Override
public void removeListener(InvalidationListener listener) {
helper.removeListener(listener);
}
private static sealed abstract class ObservableField<T> {
public static <T> ObservableField<T> of(MethodHandles.Lookup lookup, Field field) {
String name;
List<String> alternateNames;
SerializedName serializedName = field.getAnnotation(SerializedName.class);
if (serializedName == null) {
name = field.getName();
alternateNames = List.of();
} else {
name = serializedName.value();
alternateNames = List.of(serializedName.alternate());
}
VarHandle varHandle;
try {
varHandle = lookup.unreflectVarHandle(field);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e);
}
if (ObservableList.class.isAssignableFrom(field.getType())) {
Type listType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), List.class);
if (!(listType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the list type of " + field.getName());
return new CollectionField<>(name, alternateNames, varHandle, listType, listType);
} else if (ObservableSet.class.isAssignableFrom(field.getType())) {
Type setType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Set.class);
if (!(setType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the set type of " + field.getName());
ParameterizedType listType = TypeUtils.newParameterizedTypeWithOwner(
null,
List.class,
((ParameterizedType) setType).getActualTypeArguments()[0]
);
return new CollectionField<>(name, alternateNames, varHandle, setType, listType);
} else if (ObservableMap.class.isAssignableFrom(field.getType())) {
Type mapType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Map.class);
if (!(mapType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the map type of " + field.getName());
return new MapField<>(name, alternateNames, varHandle, mapType);
} else if (Property.class.isAssignableFrom(field.getType())) {
Type propertyType = TypeUtils.getSupertype(field.getGenericType(), field.getType(), Property.class);
if (!(propertyType instanceof ParameterizedType))
throw new IllegalArgumentException("Cannot resolve the element type of " + field.getName());
Type elementType = ((ParameterizedType) propertyType).getActualTypeArguments()[0];
return new PropertyField<>(name, alternateNames, varHandle, elementType);
} else {
throw new IllegalArgumentException("Field " + field.getName() + " is not a property or observable collection");
}
}
protected final String serializedName;
protected final List<String> alternateNames;
protected final VarHandle varHandle;
private ObservableField(String serializedName, List<String> alternateNames, VarHandle varHandle) {
this.serializedName = serializedName;
this.alternateNames = alternateNames;
this.varHandle = varHandle;
}
public String getSerializedName() {
return serializedName;
}
public List<String> getAlternateNames() {
return alternateNames;
}
public Observable get(T value) {
return (Observable) varHandle.get(value);
}
public abstract void serialize(JsonObject result, T value, JsonSerializationContext context);
public abstract void deserialize(T value, JsonElement element, JsonDeserializationContext context);
private static final class PropertyField<T> extends ObservableField<T> {
private final Type elementType;
PropertyField(String serializedName, List<String> alternate, VarHandle varHandle, Type elementType) {
super(serializedName, alternate, varHandle);
this.elementType = elementType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
Property<?> property = (Property<?>) get(value);
if (property instanceof RawPreservingProperty<?> rawPreserving) {
JsonElement rawJson = rawPreserving.getRawJson();
if (rawJson != null) {
result.add(getSerializedName(), rawJson);
return;
}
}
JsonElement serialized = context.serialize(property.getValue(), elementType);
if (serialized != null && !serialized.isJsonNull())
result.add(getSerializedName(), serialized);
}
@Override
@SuppressWarnings({"unchecked", "rawtypes"})
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
Property property = (Property) get(value);
try {
property.setValue(context.deserialize(element, elementType));
} catch (Throwable e) {
if (property instanceof RawPreservingProperty<?>) {
((RawPreservingProperty<?>) property).setRawJson(element);
} else {
throw e;
}
}
}
}
private static final class CollectionField<T> extends ObservableField<T> {
private final Type collectionType;
/// When deserializing a Set, we first deserialize it into a `List`, then put the elements into the Set.
private final Type listType;
CollectionField(String serializedName, List<String> alternate, VarHandle varHandle,
Type collectionType, Type listType) {
super(serializedName, alternate, varHandle);
this.collectionType = collectionType;
this.listType = listType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), collectionType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T value, JsonElement element, JsonDeserializationContext context) {
List<?> deserialized = context.deserialize(element, listType);
Object fieldValue = get(value);
if (fieldValue instanceof ListProperty) {
((ListProperty<Object>) fieldValue).set(FXCollections.observableList((List<Object>) deserialized));
} else if (fieldValue instanceof ObservableList) {
((ObservableList<Object>) fieldValue).setAll(deserialized);
} else if (fieldValue instanceof SetProperty) {
((SetProperty<Object>) fieldValue).set(FXCollections.observableSet(new HashSet<>(deserialized)));
} else if (fieldValue instanceof ObservableSet) {
ObservableSet<Object> set = (ObservableSet<Object>) fieldValue;
set.clear();
set.addAll(deserialized);
} else {
throw new JsonParseException("Unsupported field type: " + fieldValue.getClass());
}
}
}
private static final class MapField<T> extends ObservableField<T> {
private final Type mapType;
MapField(String serializedName, List<String> alternate, VarHandle varHandle, Type mapType) {
super(serializedName, alternate, varHandle);
this.mapType = mapType;
}
@Override
public void serialize(JsonObject result, T value, JsonSerializationContext context) {
result.add(getSerializedName(), context.serialize(get(value), mapType));
}
@SuppressWarnings({"unchecked"})
@Override
public void deserialize(T config, JsonElement element, JsonDeserializationContext context) {
Map<Object, Object> deserialized = context.deserialize(element, mapType);
ObservableMap<Object, Object> map = (ObservableMap<Object, Object>) varHandle.get(config);
if (map instanceof MapProperty<?, ?>)
((MapProperty<Object, Object>) map).set(FXCollections.observableMap(deserialized));
else {
map.clear();
map.putAll(deserialized);
}
}
}
}
public static abstract class Adapter<T extends ObservableSetting>
implements JsonSerializer<T>, JsonDeserializer<T> {
protected abstract T createInstance();
@Override
public JsonElement serialize(T setting, Type typeOfSrc, JsonSerializationContext context) {
if (setting == null)
return JsonNull.INSTANCE;
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<T>>) FIELDS.get(setting.getClass());
JsonObject result = new JsonObject();
for (var field : fields) {
Observable observable = field.get(setting);
if (setting.tracker.isDirty(observable)) {
field.serialize(result, setting, context);
}
}
setting.unknownFields.forEach(result::add);
return result;
}
@Override
public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (json == null || json.isJsonNull())
return null;
if (!json.isJsonObject())
throw new JsonParseException("Config is not an object: " + json);
T setting = createInstance();
@SuppressWarnings("unchecked")
var fields = (List<ObservableField<T>>) FIELDS.get(setting.getClass());
var values = new LinkedHashMap<>(json.getAsJsonObject().asMap());
for (ObservableField<T> field : fields) {
JsonElement value = values.remove(field.getSerializedName());
if (value == null) {
for (String alternateName : field.getAlternateNames()) {
value = values.remove(alternateName);
if (value != null)
break;
}
}
if (value != null) {
setting.tracker.markDirty(field.get(setting));
field.deserialize(setting, value, context);
}
}
setting.unknownFields.putAll(values);
return setting;
}
}
}
@@ -22,6 +22,7 @@ import javafx.beans.property.SimpleObjectProperty;
import org.jetbrains.annotations.Nullable;
/// @author Glavo
/// @see ObservableSetting
public class RawPreservingObjectProperty<T> extends SimpleObjectProperty<T> implements RawPreservingProperty<T> {
private JsonElement rawJson;
@@ -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,16 +17,23 @@
*/
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.platform.NativeUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -41,66 +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 Set<String> rtl = new HashSet<>();
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);
}
try {
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/rtl.txt")).lines())) {
if (line.startsWith("#") || line.isBlank()) {
continue;
}
rtl.add(line.trim());
}
} catch (Throwable e) {
LOG.warning("Failed to load rtl.txt", e);
}
return Map.copyOf(result);
}
private static Locale getInstance(String language, String script, String region,
@@ -123,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());
}
@@ -133,54 +173,54 @@ 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) {
TextDirection direction = rtl.contains(getRootLanguage(locale))
return RTL_SCRIPTS.contains(getScript(locale))
? TextDirection.RIGHT_TO_LEFT
: TextDirection.LEFT_TO_RIGHT;
if ("Qabs".equals(getScript(locale))) {
direction = switch (direction) {
case RIGHT_TO_LEFT -> TextDirection.LEFT_TO_RIGHT;
case LEFT_TO_RIGHT -> TextDirection.RIGHT_TO_LEFT;
};
}
return direction;
}
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
@@ -189,13 +229,8 @@ public final class LocaleUtils {
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()
@@ -204,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);
@@ -360,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() {
}
}
@@ -144,8 +144,7 @@ public final class NetworkUtils {
}
connection.setConnectTimeout(TIME_OUT);
connection.setReadTimeout(TIME_OUT);
if (connection instanceof HttpURLConnection) {
var httpConnection = (HttpURLConnection) connection;
if (connection instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setInstanceFollowRedirects(false);
}
@@ -276,7 +275,11 @@ public final class NetworkUtils {
}
public static String doGet(URI uri) throws IOException {
return readFullyAsString(resolveConnection(createHttpConnection(uri)));
URLConnection connection = createConnection(uri);
if (connection instanceof HttpURLConnection httpURLConnection) {
connection = resolveConnection(httpURLConnection);
}
return readFullyAsString(connection);
}
public static String doGet(List<URI> uris) throws IOException {
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.util.platform;
import org.jackhuang.hmcl.util.KeyValuePairUtils;
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
import org.jackhuang.hmcl.util.platform.windows.WinTypes;
import org.jackhuang.hmcl.util.platform.windows.WinReg;
import java.io.BufferedReader;
import java.io.IOException;
@@ -150,13 +150,35 @@ public enum OperatingSystem {
OSVersion.Windows windowsVersion = null;
Kernel32 kernel32 = Kernel32.INSTANCE;
WinReg reg = WinReg.INSTANCE;
// Get Windows version number
if (kernel32 != null) {
WinTypes.OSVERSIONINFOEXW osVersionInfo = new WinTypes.OSVERSIONINFOEXW();
if (kernel32.GetVersionExW(osVersionInfo)) {
windowsVersion = new OSVersion.Windows(osVersionInfo.dwMajorVersion, osVersionInfo.dwMinorVersion, osVersionInfo.dwBuildNumber);
} else
System.err.println("Failed to obtain OS version number (" + kernel32.GetLastError() + ")");
if (reg != null) {
var baseVersion = OSVersion.Windows.parse(System.getProperty("os.version"));
int majorVersion = baseVersion.major();
int minorVersion = baseVersion.minor();
int buildNumber = baseVersion.build();
int revision = baseVersion.revision();
Object currentBuild = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuild");
if (currentBuild instanceof String currentBuildStr) {
try {
buildNumber = Integer.parseInt(currentBuildStr);
} catch (NumberFormatException e) {
System.err.println("Invalid Windows build number: " + currentBuildStr);
}
}
if (majorVersion >= 10) {
Object ubr = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "UBR");
if (ubr instanceof Integer ubrValue)
revision = ubrValue;
}
windowsVersion = new OSVersion.Windows(majorVersion, minorVersion, buildNumber, revision);
}
if (windowsVersion == null) {
@@ -201,6 +223,16 @@ public enum OperatingSystem {
if (osName.equals("Windows 10") && windowsVersion.isAtLeast(OSVersion.WINDOWS_11))
osName = "Windows 11";
if (windowsVersion.isAtLeast(OSVersion.WINDOWS_10) && reg != null) {
Object displayVersion = reg.queryValue(WinReg.HKEY.HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "DisplayVersion");
if (displayVersion instanceof String displayVersionStr
&& displayVersionStr.matches("\\d{2}H\\d")) {
osName = osName + " " + displayVersionStr;
}
}
SYSTEM_NAME = osName;
SYSTEM_VERSION = windowsVersion;
SYSTEM_BUILD_NUMBER = windowsVersion.build();
@@ -1,3 +1,20 @@
/*
* 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;
public record Platform(OperatingSystem os, Architecture arch) {
@@ -19,7 +36,7 @@ public record Platform(OperatingSystem os, Architecture arch) {
public static final Platform MACOS_X86_64 = new Platform(OperatingSystem.MACOS, Architecture.X86_64);
public static final Platform MACOS_ARM64 = new Platform(OperatingSystem.MACOS, Architecture.ARM64);
public static final Platform FREEBSD_X86_64 = new Platform(OperatingSystem.FREEBSD, Architecture.X86);
public static final Platform 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);
@@ -100,7 +100,7 @@ public abstract sealed class GameVersionNumber implements Comparable<GameVersion
}
public boolean isAprilFools() {
if (this instanceof Special)
if (this instanceof Special && !value.endsWith("_unobfuscated"))
return true;
if (this instanceof Snapshot snapshot) {
@@ -1,5 +1,12 @@
{
"versions": [
{
"id": "25w45a_unobfuscated",
"type" : "unobfuscated",
"url": "https://piston-meta.mojang.com/v1/packages/7a3c149f148b6aa5ac3af48c4f701adea7e5b615/25w45a_unobfuscated.json",
"time" : "2025-11-04T14:07:08+00:00",
"releaseTime" : "2025-11-04T14:07:08+00:00"
},
{
"id": "1.19_deep_dark_experimental_snapshot-1",
"type": "pending",
@@ -848,4 +848,10 @@
1.21.9-rc1
1.21.9
1.21.10-rc1
1.21.10
1.21.10
25w41a
25w42a
25w43a
25w44a
25w45a
25w45a_unobfuscated
@@ -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 Arab,ar,fa,ps,ur
2 Armn,hy
3 Beng,as,bn
4 Blis,zbl
5 Cyrl,ab,be,bg,kk,mk,ru,uk
6 Deva,hi,mr,ne,kok,mai
7 Ethi,am,ti
8 Geor,ka
9 Grek,el
10 Gujr,gu
11 Guru,pa
12 Hant,lzh
13 Hebr,he,yi
14 Jpan,ja
15 Khmr,km
16 Knda,kn
17 Kore,ko
18 Laoo,lo
19 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
20 Mlym,ml
21 Mymr,my
22 Nkoo,nqo
23 Orya,or
24 Sinh,si
25 Taml,ta
26 Telu,te
27 Thaa,dv
28 Thai,th
29 Tibt,dz
@@ -73,7 +73,6 @@ io,ido
is,isl
it,ita
iu,iku
iw,heb
ja,jpn
ji,yid
jv,jav
1 aa aar
73 is isl
74 it ita
75 iu iku
iw heb
76 ja jpn
77 ji yid
78 jv jav
@@ -1,6 +0,0 @@
ar
fa
he
ps
ur
yi
@@ -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
1 zh ak,tw,fat cdo cjy cmn cnp cpx csp czh czo gan hak hnm hsn luh lzh mnp nan sjc wuu yue
2 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
3 ay,ayc,ayr
4 az,azb,azj
5 cr,crj,crk,crl,crm,csw,cwd
6 et,ekk,vro
7 fa,pes,prs
8 ff,ffm,fub,fuc,fue,fuf,fuh,fui,fuq,fuv
9 gn,gnw,gug,gui,gun,nhd
10 ik,esi,esk
11 iu,ike,ikt
12 kg,kng,kwy,ldi
13 kr,kby,knc,krt
14 ku,ckb,kmr,sdh
15 kv,koi,kpv
16 lv,ltg,lvs
17 mg,bhr,bmm,bzc,msh,plt,skg,tdx,tkg,txy,xmv,xmw
18 mn,khk,mvf
19 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
20 ne,dty,npi
21 no,nb,nn
22 oj,ciw,ojb,ojc,ojg,ojs,ojw,otw
23 om,gax,gaz,hae,orc
24 or,ory,spv
25 ps,pbt,pbu,pst
26 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
27 sa,cls,vsn
28 sc,sdc,sdn,src,sro
29 sh,bs,hr,sr,cnr
30 sq,aae,aat,aln,als
31 sw,swc,swh
32 uz,uzn,uzs
33 yi,ydd,yih
34 za,zch,zeh,zgb,zgm,zgn,zhd,zhn,zlj,zln,zlq,zqe,zyb,zyg,zyj,zyn,zzj
35 zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
36 bal,bcc,bgn,bgp
37 bik,bcl,bln,bto,cts,fbl,lbl,rbl,ubl
38 bnc,ebk,lbk,obk,rbk,vbk
39 bua,bxm,bxr,bxu
40 chm,mhr,mrj
41 del,umu,unm
42 den,scs,xsl
43 din,dib,dik,dip,diw,dks
44 doi,dgo,xnr
45 gba,bdt,gbp,gbq,gmm,gso,gya
46 gon,esg,gno,wsg
47 grb,gbo,gec,grj,grv,gry
48 hai,hax,hdn
49 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
50 jrb,aju,jye,yhd,yud
51 kln,enb,eyo,niq,oki,pko,sgc,spy,tec,tuy
52 kok,gom,knn
53 kpe,gkp,xpe
54 lah,hnd,hno,jat,phr,pnb,skr,xhe
55 luy,bxk,ida,lkb,lko,lks,lri,lrm,lsm,lto,lts,lwg,nle,nyd,rag
56 man,emk,mku,mlq,mnk,msc,mwk
57 mwr,dhd,mtr,mve,rwr,swv,wry
58 raj,bgq,gda,gju,hoj,mup,wbr
59 rom,rmc,rmf,rml,rmn,rmo,rmw,rmy
60 syr,aii,cld
61 tmh,taq,thv,thz,ttq
62 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
63 zza,diq,kiu
@@ -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
@@ -228,10 +247,11 @@ public final class LocaleUtilsTest {
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh")));
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-Hans")));
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-CN")));
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar-Qabs")));
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("en-Qabs")));
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar")));
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ara")));
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("he")));
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("heb")));
}
}
@@ -121,6 +121,7 @@ public final class GameVersionNumberTest {
assertFalse(isAprilFools("25w21a"));
assertFalse(isAprilFools("13w12~"));
assertFalse(isAprilFools("15w14b"));
assertFalse(isAprilFools("25w45a_unobfuscated"));
}
@Test
@@ -272,6 +273,8 @@ public final class GameVersionNumberTest {
"24w13a",
"24w14potato",
"24w14a",
"25w45a",
"25w45a_unobfuscated",
"Unknown",
"100.0"
);
+7
View File
@@ -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"))
+4
View File
@@ -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);
}
@@ -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) {
@@ -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);
};
}
@@ -58,19 +58,23 @@ public abstract class ParseModDataTask extends DefaultTask {
private static final String MOD_SEPARATOR = ",";
private static final Pattern[] CURSEFORGE_PATTERNS = {
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons)/+(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons|scripts)/+(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/projects/(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/mc-mods/minecraft/(?<modid>[\\w-]+)(/(.*?))?$"),
Pattern.compile("^/legacy/mc-mods/minecraft/(\\d+)-(?<modid>[\\w-]+)"),
};
private static String parseCurseforge(String url) {
URI res = URI.create(url);
URI res = URI.create(url.replace(" ", "%20"));
if (!"http".equals(res.getScheme()) && !"https".equals(res.getScheme())) {
return "";
}
if ("edge.forgecdn.net".equals(res.getHost())) {
return "";
}
for (Pattern pattern : CURSEFORGE_PATTERNS) {
Matcher matcher = pattern.matcher(res.getPath());
if (matcher.matches()) {
+1 -1
View File
@@ -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
+56
View File
@@ -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.
+56
View File
@@ -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` 子目录中。
+337
View File
@@ -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
View File
@@ -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
View File
@@ -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 -->
+208 -14
View File
@@ -4,22 +4,138 @@
[English](PLATFORM.md) | **中文** (**简体**, [繁體](PLATFORM_zh_Hant.md))
<!-- #END LANGUAGE_SWITCHER -->
## 启动器兼容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=PLATFORM_TABLE -->
<!-- #PROPERTY REPLACE="\\(Old World\\)" "(旧世界)" -->
<!-- #PROPERTY NAME=LAUNCHER_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="Fully supported" "完整支持" -->
<!-- #PROPERTY REPLACE="Limited support" "有限支持" -->
<!-- #PROPERTY REPLACE="Old World" "旧世界" -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<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 7 ~ Windows 11)
<br>
✅️ 完整支持 (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>✅️ 完整支持</td>
<td>✅️ 完整支持</td>
<td>✅ 完整支持</td>
</tr>
<tr>
<td>x86</td>
<td>
🕰️ 有限支持 (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>🕰️ 有限支持</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>
✅️ 完整支持 (新世界)
<br>
🕰️ 有限支持 (旧世界)
</td>
<td>/</td>
<td>/</td>
</tr>
</tbody>
</table>
<!-- #END COPY -->
图例:
* ✅️ 完整支持
受到完整支持的平台。HMCL 会尽可能为此平台提供支持。
* 🕰️ 有限支持
这些平台通常是老旧的遗留平台。
HMCL 可以在这些平台上工作,但部分功能可能无法使用。
我们可能会为了降低维护成本而放弃为此平台提供部分功能。
* 🕰️ HMCL 3.6 (有限支持)
HMCL 主分支不再支持此平台。
我们通过 HMCL 3.6 LTS 分支继续为该平台提供安全补丁和错误修复,
但此平台上将无法接受到功能更新。
* / (不受支持)
HMCL 尚不支持此平台。我们可能会在未来支持此平台。
## 游戏兼容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=GAME_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="Old World" "旧世界" -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<!-- #PROPERTY REPLACE="\\(use Rosetta 2\\)" "(使用 Rosetta 2)" -->
| | 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/>✅ (使用 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 (旧世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
| S390x | / | ❔ | / | / |
| | 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/>✅ (使用 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 (新世界) | / | 👌 (Minecraft 1.6~1.21.10) | / | / |
| LoongArch64 (旧世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
| S390x | / | ❔ | / | / |
<!-- #END COPY -->
图例:
@@ -43,3 +159,81 @@
我们目前还没有打算支持这些平台,主要是因为我们没有测试这些平台的设备。
如果你能帮助我们进行测试,请通过提交 Issue 提出支持请求。
## 陶瓦联机兼容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=TERRACOTTA_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="Old World" "旧世界" -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<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>
✅️ (新世界)
<br>
❌ (旧世界)
</td>
<td>/</td>
<td>/</td>
</tr>
</tbody>
</table>
<!-- #END COPY -->
+208 -14
View File
@@ -4,22 +4,138 @@
[English](PLATFORM.md) | **中文** ([简体](PLATFORM_zh.md), **繁體**)
<!-- #END LANGUAGE_SWITCHER -->
## 啟動器相容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=PLATFORM_TABLE -->
<!-- #PROPERTY REPLACE="\\(Old World\\)" "(舊世界)" -->
<!-- #PROPERTY NAME=LAUNCHER_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="Fully supported" "完整支援" -->
<!-- #PROPERTY REPLACE="Limited support" "有限支援" -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<!-- #PROPERTY REPLACE="Old World" "舊世界" -->
<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 7 ~ Windows 11)
<br>
✅️ 完整支援 (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>✅️ 完整支援</td>
<td>✅️ 完整支援</td>
<td>✅ 完整支援</td>
</tr>
<tr>
<td>x86</td>
<td>
🕰️ 有限支援 (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>🕰️ 有限支援</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>
✅️ 完整支援 (新世界)
<br>
🕰️ 有限支援 (舊世界)
</td>
<td>/</td>
<td>/</td>
</tr>
</tbody>
</table>
<!-- #END COPY -->
圖例:
* ✅️ 完整支援
受到完整支援的平臺。HMCL 會盡可能為此平臺提供支援。
* 🕰️ 有限支援
這些平臺通常是老舊的遺留平臺。
HMCL 可以在這些平臺上運作,但部分功能可能無法使用。
我們可能會為了降低維護成本而放棄為此平臺提供部分功能。
* 🕰️ HMCL 3.6(有限支援)
HMCL 主分支不再支援此平臺。
我們透過 HMCL 3.6 LTS 分支繼續為該平臺提供安全修補與錯誤修復,
但此平臺上將無法獲得功能更新。
* /(不支援)
HMCL 尚未支援此平臺。我們未來可能會支援此平臺。
## 遊戲相容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=GAME_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<!-- #PROPERTY REPLACE="Old World" "舊世界" -->
<!-- #PROPERTY REPLACE="\\(use Rosetta 2\\)" "(使用 Rosetta 2)" -->
| | 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/>✅ (使用 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 (舊世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
| S390x | / | ❔ | / | / |
| | 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/>✅ (使用 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 (新世界) | / | 👌 (Minecraft 1.6~1.21.10) | / | / |
| LoongArch64 (舊世界) | / | 👌 (Minecraft 1.6~1.20.1) | / | / |
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
| S390x | / | ❔ | / | / |
<!-- #END COPY -->
圖例:
@@ -43,3 +159,81 @@
我們目前還沒有打算支援這些平臺,主要是因為我們沒有測試這些平臺的裝置。
如果你能幫助我們進行測試,請透過提交 Issue 提出支援請求。
## 陶瓦聯機相容性
<!-- #BEGIN COPY -->
<!-- #PROPERTY NAME=TERRACOTTA_COMPATIBILITY -->
<!-- #PROPERTY REPLACE="New World" "新世界" -->
<!-- #PROPERTY REPLACE="Old World" "舊世界" -->
<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>
✅️ (新世界)
<br>
❌ (舊世界)
</td>
<td>/</td>
<td>/</td>
</tr>
</tbody>
</table>
<!-- #END COPY -->
+1 -7
View File
@@ -49,13 +49,7 @@ If you want to submit a pull request, here are some requirements:
### Compilation
Simply execute the following command in the project root directory:
```bash
./gradlew clean build
```
Make sure you have Java 17 or later installed.
See the [Build Guide](./Building.md) page.
## JVM Options (for debugging)
+1 -7
View File
@@ -42,13 +42,7 @@ Si deseas enviar un pull request, aquí tienes algunos requisitos:
### Compilación
Simplemente ejecuta el siguiente comando en el directorio raíz del proyecto:
```bash
./gradlew clean build
```
Asegúrate de tener instalado Java 17 o una versión posterior.
Consulta la página de la [Guía de compilación](./Building.md).
## Opciones de JVM (para depuración)
+1 -7
View File
@@ -41,13 +41,7 @@ HMCLが対応しているシステムやCPUアーキテクチャについては
### コンパイル方法
プロジェクトのルートディレクトリで次のコマンドを実行してください。
```bash
./gradlew clean build
```
Java 17以上がインストールされていることを確認してください。
ビルド方法については、[ビルドガイド](./Building.md)ページをご覧ください。
## JVMオプション(デバッグ用)
+1 -7
View File
@@ -42,13 +42,7 @@ HMCL 跨域甚廣。無論 Windows、Linux、macOS、FreeBSD 諸常見械綱,
### 編造
於項目之根目錄,執下列令:
```bash
./gradlew clean build
```
務必保汝已安裝爪哇十七或更高者。
請觀[編造指南](./Building_zh.md)。
## 爪哇虛機之通弦(以資勘誤)
+1 -7
View File
@@ -41,13 +41,7 @@ HMCL обладает отличной кроссплатформенность
### Сборка
Выполните следующую команду в корневой директории проекта:
```bash
./gradlew clean build
```
Убедитесь, что у вас установлена Java 17 или новее.
См. страницу [Руководство по сборке](./Building.md).
## Параметры JVM (для отладки)
+1 -7
View File
@@ -41,13 +41,7 @@ HMCL має чудові кросплатформні можливості. Ві
### Компіляція
Просто виконайте наступну команду в кореневому каталозі проєкту:
```bash
./gradlew clean build
```
Переконайтеся, що у вас встановлено Java 17 або новішої версії.
Дивіться сторінку [Посібник зі збірки](./Building.md).
## JVM Options (for debugging)
+2 -8
View File
@@ -47,15 +47,9 @@ HMCL 有着强大的跨平台能力。它不仅支持 Windows、Linux、macOS、
* IDE:IntelliJ IDEA
* 编译器:Java 17+
### 编译
### 构建 HMCL
于项目根文件夹执行以下命令:
```bash
./gradlew clean build
```
请确保你至少安装了 JDK 17 或更高版本。
参见[构建指南](./Building_zh.md)页面。
## JVM 选项 (用于调试)
+1 -7
View File
@@ -49,13 +49,7 @@ HMCL 有著強大的跨平臺能力。它不僅支援 Windows、Linux、macOS、
### 編譯
於項目根目錄執行以下指令:
```bash
./gradlew clean build
```
請確保你至少安裝了 JDK 17 或更高版本。
參閱[構建指南](./Building_zh.md)頁面。
## JVM 選項 (用於除錯)
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
hmclauncher = "3.7.0.0"
hmclauncher = "3.7.0.1"
jetbrains-annotations = "26.0.1"
kala-compress = "1.27.1-1"
simple-png-javafx = "0.3.0"