Compare commits

..
Author SHA1 Message Date
Glavo 5abc1f1f9d 修复“不自动添加 Java 虚拟机优化参数”选项未保存至配置文件的问题 (#4583) 2025-10-02 20:47:34 +08:00
Glavo 40b4ecd42a 从 synchronized 切换至 ReentrantLock (#4564) 2025-10-02 15:50:56 +08:00
Glavo e0f5c4d91e 优化 EventBus (#4565) 2025-10-02 15:38:03 +08:00
辞庐 35ecd58460 Fix #4556 : 修复在短时间内连续点击 OptionToggleButton 只会更改一次状态的问题 (#4568) 2025-10-02 15:31:20 +08:00
辞庐 f29c2bc202 Fix 4555: 修复外观 - 不透明度滑块 刻度线在不同滑块位置时排布不统一的问题 (#4571) 2025-10-02 15:25:52 +08:00
Glavo 361e928e73 支持从 WorkflowMultiBranchProject 中检查最新版本 (#4576) 2025-10-02 15:22:15 +08:00
Glavo 484b65b513 优化对颠倒的英语的支持 (#4577) 2025-10-02 15:21:03 +08:00
3gf8jv4dv 19f02e3967 chore: update versions.txt to 1.21.9 (#4566) 2025-10-01 13:08:43 +08:00
Glavo 495da6b6b0 更新 Jenkins CI 服务器的发布模型 (#4559) 2025-10-01 13:04:19 +08:00
Glavo 168c4ac708 支持通过环境变量控制 UI 缩放比例 (#4521) 2025-09-29 16:51:57 +08:00
Glavo 7e8a6679cc 将 EventManager 中的 synchronized 替换为 ReentrantLock (#4562) 2025-09-29 15:49:18 +08:00
Glavo e90d5185f6 增强 ArchiveFileTree (#4547) 2025-09-29 15:19:37 +08:00
Glavo 8a89baee09 使用 Gradle 获取更新信息 (#4560) 2025-09-28 20:29:07 +08:00
辞庐 7f4c5fc4a0 清理代码 (#4519) 2025-09-28 00:01:00 +08:00
Glavo 75789c774d 修复切换下载源后需要重启启动器才会生效的问题 (#4296) 2025-09-27 16:26:38 +08:00
Glavo f0a16b3d14 GitHub Action 按文件类型分别上传构建产物 (#4537) 2025-09-27 15:56:28 +08:00
Glavo 710e69751f 重构 FetchTask 以使用 HttpClient 和虚拟线程 (#4546) 2025-09-26 20:54:22 +08:00
Glavo 938d8647ce 删除 InvocationDispatcher 中的 synchronized 块 (#4550) 2025-09-26 20:44:34 +08:00
Glavo 4b4dd592c3 将 Schedulers.io() 切换至虚拟线程 (#4548) 2025-09-26 20:12:01 +08:00
Glavo 225dba7448 Bump JNA to 5.18.0 (#4549) 2025-09-26 17:06:01 +08:00
Glavo 5b1414428e 添加 Debug_zh.md (#4542) 2025-09-24 15:22:50 +08:00
Glavo cb467ae240 优化本地化支持 (#4539)
- 简化代码
- 修复错误用词
- 优化语言名称显示
2025-09-23 15:47:46 +08:00
rootwhois 9416c0cfb3 修复日志页面选中条目时 hover 背景闪烁两次的问题 (#4517)
fix #4245
2025-09-23 11:46:18 +08:00
3gf8jv4dv 16ea98c95a chore(l10n): revise chinese (traditional) and english (#4530) 2025-09-23 11:30:54 +08:00
3gf8jv4dv a8552a92d1 chore(l10n): add new translations to spanish (#4531) 2025-09-23 11:30:03 +08:00
Glavo 36d71bd14e 支持颠倒的英语 (#4527) 2025-09-22 22:07:06 +08:00
3gf8jv4dv cbe6554390 feat(l10n): add boot_es.properties (#4532) 2025-09-22 21:48:09 +08:00
Glavo 1b65e0b6d7 Fix #4526: 修复将空字符串注册为 Access Token 后日志输出异常的问题 (#4528) 2025-09-22 16:07:16 +08:00
Glavo bd9ae189f8 优化本地化支持 (#4525) 2025-09-21 15:15:22 +08:00
Glavo 867a04dfba 支持在“实例管理”按钮上通过滚动滚轮切换游戏实例 (#4509) 2025-09-20 20:15:11 +08:00
Wulian233 dd7703c7a9 修复删除世界时无法删除 zip 格式世界的问题 (#4500) 2025-09-20 17:14:19 +08:00
Glavo 756b95ca8c 添加本地化说明文档 (#4516) 2025-09-20 16:41:58 +08:00
rootwhoisandGlavo ac0a0c7604 修复 AdvancedListBox 在非必要的情况下显示滚动条的问题 (#4514)
Co-authored-by: Glavo <zjx001202@gmail.com>
2025-09-20 16:28:59 +08:00
Glavo 9ee611c0f6 Bump Gradle to 9.1.0 (#4520) 2025-09-20 15:55:20 +08:00
100 changed files with 3587 additions and 986 deletions

No files matched your search

+1
View File
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
ij_jspx_keep_indents_on_empty_lines = false
[{*.markdown,*.md}]
max_line_length = 200
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true
+15 -34
View File
@@ -13,26 +13,16 @@ jobs:
if: ${{ github.repository_owner == 'HMCL-dev' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=v$HMCL_VERSION" >> $GITHUB_ENV
run: ./gradlew checkUpdateDev --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
@@ -78,26 +68,17 @@ jobs:
needs: dev-check-update
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- 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: Install tools
run: sudo apt-get install -y jq
- name: Fetch last version
run: |
wget -O ci.json https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
echo "HMCL_TAG_NAME=release-$HMCL_VERSION" >> $GITHUB_ENV
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
+19 -3
View File
@@ -27,8 +27,24 @@ jobs:
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}
path: HMCL/build/libs
name: HMCL-${{ env.SHORT_SHA }}-jar
path: |
HMCL/build/libs/HMCL-*.jar
HMCL/build/libs/HMCL-*.jar.sha256
- name: Upload EXE
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-exe
path: |
HMCL/build/libs/HMCL-*.exe
HMCL/build/libs/HMCL-*.exe.sha256
- name: Upload SH
uses: actions/upload-artifact@v4
with:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: |
HMCL/build/libs/HMCL-*.sh
HMCL/build/libs/HMCL-*.sh.sha256
+1
View File
@@ -2,6 +2,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle
+63 -19
View File
@@ -1,5 +1,11 @@
import org.jackhuang.hmcl.gradle.CheckTranslations
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
@@ -13,30 +19,36 @@ plugins {
alias(libs.plugins.shadow)
}
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
.isNullOrEmpty())
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
if (number != null) {
(number - offset).toString()
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
val prefix = if (isOfficial) "dev" else "unofficial"
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
}
}
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
version = "$versionRoot.$buildNumber"
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
if (buildNumber != null) {
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
"$versionRoot.0.$buildNumber"
} else {
"$versionRoot.$buildNumber"
}
} else {
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
version = if (shortCommit.isNullOrBlank()) {
"$versionRoot.SNAPSHOT"
} else if (isOfficial) {
"$versionRoot.dev-$shortCommit"
} else {
"$versionRoot.unofficial-$shortCommit"
}
}
val embedResources by configurations.registering
@@ -47,7 +59,7 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
if (launcherExe == null) {
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
}
@@ -192,7 +204,7 @@ tasks.shadowJar {
"Enable-Native-Access" to "ALL-UNNAMED"
)
if (launcherExe != null) {
if (launcherExe.isNotBlank()) {
into("assets") {
from(file(launcherExe))
}
@@ -206,11 +218,20 @@ tasks.shadowJar {
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
}
}
val makeExecutables by tasks.registering {
@@ -344,6 +365,29 @@ tasks.register<CheckTranslations>("checkTranslations") {
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {
@@ -46,21 +46,7 @@ public final class EntryPoint {
createHMCLDirectories();
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
System.getProperties().putIfAbsent("prism.forceGPU", "true");
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
setupJavaFXVMOptions();
checkDirectoryPath();
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
@@ -80,6 +66,71 @@ public final class EntryPoint {
System.exit(exitCode);
}
private static void setupJavaFXVMOptions() {
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
LOG.info("HMCL_FORCE_GPU: true");
System.getProperties().putIfAbsent("prism.forceGPU", "true");
}
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
if (animationFrameRate != null) {
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
try {
if (Integer.parseInt(animationFrameRate) <= 0)
throw new NumberFormatException(animationFrameRate);
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
} catch (NumberFormatException e) {
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
}
}
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
if (uiScale != null) {
uiScale = uiScale.trim();
LOG.info("HMCL_UI_SCALE: " + uiScale);
try {
float scaleValue;
if (uiScale.endsWith("%")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
} else {
scaleValue = Float.parseFloat(uiScale);
}
float lowerBound;
float upperBound;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
lowerBound = 0.25f;
upperBound = 4f;
} else {
lowerBound = 0.01f;
upperBound = 10f;
}
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
} else {
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
}
} else {
LOG.warning("UI scale out of range: " + uiScale);
}
} catch (Throwable e) {
LOG.warning("Invalid UI scale: " + uiScale);
}
}
}
private static void createHMCLDirectories() {
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
try {
@@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Locale locale = Locale.getDefault();
if (LocaleUtils.isEnglish(locale))
return;
/*
* 1.0 : No language option, do not set for these versions
@@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();
return switch (LocaleUtils.getISO1Language(locale)) {
return switch (LocaleUtils.getRootLanguage(locale)) {
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
@@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
yield "zh_CN";
}
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}
yield "";
}
default -> "";
};
}
@@ -77,7 +77,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
return manifest;
}
private static class HMCLModpack extends Modpack {
private final static class HMCLModpack extends Modpack {
@Override
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
@@ -58,6 +58,7 @@ import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.lang.ref.WeakReference;
@@ -706,6 +707,7 @@ public final class LauncherHelper {
*/
private final class HMCLProcessListener implements ProcessListener {
private final ReentrantLock lock = new ReentrantLock();
private final HMCLGameRepository repository;
private final Version version;
private final LaunchOptions launchOptions;
@@ -849,21 +851,27 @@ public final class LauncherHelper {
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
logBuffer.add(new Log(log, level));
} else {
synchronized (this) {
lock.lock();
try {
logs.addLast(new Log(log, level));
if (logs.size() > Log.getLogLines())
logs.removeFirst();
} finally {
lock.unlock();
}
}
if (!lwjgl) {
String lowerCaseLog = log.toLowerCase(Locale.ROOT);
if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl) {
lwjgl = true;
finishLaunch();
}
} finally {
lock.unlock();
}
}
}
@@ -888,9 +896,12 @@ public final class LauncherHelper {
// Game crashed before opening the game window.
if (!lwjgl) {
synchronized (this) {
lock.lock();
try {
if (!lwjgl)
finishLaunch();
} finally {
lock.unlock();
}
}
@@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
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;
@@ -222,7 +221,7 @@ public final class Config implements Observable {
}
@SerializedName("localization")
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(Locales.DEFAULT);
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
public ObjectProperty<SupportedLocale> localizationProperty() {
return localization;
@@ -31,23 +31,21 @@ import java.io.FileNotFoundException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class DownloadProviders {
private DownloadProviders() {}
private DownloadProviders() {
}
private static DownloadProvider currentDownloadProvider;
private static final DownloadProviderWrapper provider;
public static final Map<String, DownloadProvider> providersById;
public static final Map<String, DownloadProvider> rawProviders;
@@ -59,6 +57,7 @@ public final class DownloadProviders {
public static final String DEFAULT_PROVIDER_ID = "balanced";
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
@SuppressWarnings("unused")
private static final InvalidationListener observer;
static {
@@ -68,71 +67,68 @@ public final class DownloadProviders {
MOJANG = new MojangDownloadProvider();
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
rawProviders = mapOf(
pair("mojang", MOJANG),
pair("bmclapi", BMCLAPI)
rawProviders = Map.of(
"mojang", MOJANG,
"bmclapi", BMCLAPI
);
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
providersById = mapOf(
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
pair("mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)));
providersById = Map.of(
"official", new AutoDownloadProvider(MOJANG, fileProvider),
"balanced", new AutoDownloadProvider(balanced, fileProvider),
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
observer = FXUtils.observeWeak(() -> {
FetchTask.setDownloadExecutorConcurrency(
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
provider = new DownloadProviderWrapper(MOJANG);
}
static void init() {
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
if (!providersById.containsKey(versionListSource)) {
config().setVersionListSource(DEFAULT_PROVIDER_ID);
return;
InvalidationListener onChangeDownloadSource = observable -> {
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
if (config().isAutoChooseDownloadType()) {
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
if (currentDownloadProvider == null)
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
"default provider is null");
provider.setProvider(currentDownloadProvider);
} else {
provider.setProvider(fileDownloadProvider);
}
};
config().versionListSourceProperty().addListener(onChangeDownloadSource);
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
});
if (!rawProviders.containsKey(config().getDownloadType())) {
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
}
onChangeDownloadSource.invalidated(null);
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
DownloadProvider primary = Optional.ofNullable(rawProviders.get(downloadType))
.orElse(rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
fileDownloadProvider.setDownloadProviderCandidates(
Stream.concat(
Stream.of(primary),
rawProviders.values().stream().filter(x -> x != primary)
).collect(Collectors.toList())
);
DownloadProvider primary = Objects.requireNonNullElseGet(
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
providers.add(primary);
for (DownloadProvider provider : rawProviders.values()) {
if (provider != primary)
providers.add(provider);
}
fileDownloadProvider.setDownloadProviderCandidates(providers);
});
}
public static String getPrimaryDownloadProviderId() {
String downloadType = config().getDownloadType();
if (providersById.containsKey(downloadType))
return downloadType;
else
return DEFAULT_PROVIDER_ID;
}
public static DownloadProvider getDownloadProviderByPrimaryId(String primaryId) {
return Optional.ofNullable(providersById.get(primaryId))
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
}
/**
* Get current primary preferred download provider
*/
public static DownloadProvider getDownloadProvider() {
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
return provider;
}
public static String localizeErrorMessage(Throwable exception) {
@@ -771,6 +771,7 @@ public final class VersionSetting implements Cloneable, Observable {
obj.addProperty("wrapper", src.getWrapper());
obj.addProperty("fullscreen", src.isFullscreen());
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
obj.addProperty("notCheckGame", src.isNotCheckGame());
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
@@ -841,6 +842,7 @@ public final class VersionSetting implements Cloneable, Observable {
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
@@ -93,10 +93,7 @@ import java.nio.file.PathMatcher;
import java.util.List;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -1218,7 +1215,6 @@ public final class FXUtils {
public static JFXButton newBorderButton(String text) {
JFXButton button = new JFXButton(text);
button.getStyleClass().add("jfx-button-border");
button.setButtonType(JFXButton.ButtonType.RAISED);
return button;
}
@@ -1370,6 +1366,27 @@ public final class FXUtils {
});
}
public static <T> void onScroll(Node node, List<T> list,
ToIntFunction<List<T>> finder,
Consumer<T> updater
) {
node.addEventHandler(ScrollEvent.SCROLL, event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
int index = finder.applyAsInt(list);
if (index < 0) return;
if (deltaY > 0) // up
index--;
else // down
index++;
updater.accept(list.get((index + list.size()) % list.size()));
event.consume();
});
}
public static void copyOnDoubleClick(Labeled label) {
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
@@ -64,11 +64,9 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
vBox.setAlignment(Pos.BOTTOM_RIGHT);
vBox.setPickOnBounds(false);
JFXButton btnAdd = new JFXButton();
JFXButton btnAdd = FXUtils.newRaisedButton("");
FXUtils.setLimitWidth(btnAdd, 40);
FXUtils.setLimitHeight(btnAdd, 40);
btnAdd.getStyleClass().add("jfx-button-raised-round");
btnAdd.setButtonType(JFXButton.ButtonType.RAISED);
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
btnAdd.setOnAction(e -> skinnable.add());
@@ -40,6 +40,7 @@ import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.Log;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
@@ -311,6 +312,7 @@ public final class LogWindow extends Stage {
Holder<Object> lastCell = new Holder<>();
listView.setCellFactory(x -> new ListCell<Log>() {
{
x.setSelectionModel(new NoneMultipleSelectionModel<>());
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
@@ -21,7 +21,6 @@ import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.canvas.Canvas;
import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
@@ -77,25 +76,9 @@ public class AccountAdvancedListItem extends AdvancedListItem {
setActionButtonVisible(false);
setOnScroll(event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
Account current = account.get();
if (current == null) return;
ObservableList<Account> accounts = Accounts.getAccounts();
int currentIndex = accounts.indexOf(current);
if (currentIndex < 0) return;
if (deltaY > 0) // up
currentIndex--;
else // down
currentIndex++;
Accounts.setSelectedAccount(accounts.get((currentIndex + accounts.size()) % accounts.size()));
});
FXUtils.onScroll(this, Accounts.getAccounts(),
accounts -> accounts.indexOf(account.get()),
Accounts::setSelectedAccount);
}
public ObjectProperty<Account> accountProperty() {
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -40,8 +41,16 @@ public class AdvancedListBox extends ScrollPane {
setFitToHeight(true);
setFitToWidth(true);
setHbarPolicy(ScrollBarPolicy.NEVER);
setVbarPolicy(ScrollBarPolicy.NEVER);
container.getStyleClass().add("advanced-list-box-content");
this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> {
if (container.getHeight() > getHeight())
setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
});
this.addEventFilter(MouseEvent.MOUSE_EXITED,
event -> setVbarPolicy(ScrollBarPolicy.NEVER));
}
public AdvancedListBox add(Node child) {
@@ -25,6 +25,8 @@ import javafx.beans.property.StringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -61,7 +63,12 @@ public class OptionToggleButton extends StackPane {
toggleButton.setSize(8);
FXUtils.setLimitHeight(toggleButton, 30);
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
if (e.getButton() == MouseButton.PRIMARY) {
toggleButton.setSelected(!toggleButton.isSelected());
e.consume();
}
});
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
if (StringUtils.isNotBlank(subtitle)) {
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
return StyleableProperties.FACTORY.getCssMetaData();
}
private static class StyleableProperties {
private final static class StyleableProperties {
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
@@ -225,7 +225,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class HeadersRegion extends StackPane {
private final class HeadersRegion extends StackPane {
private SideAction action;
private final ObjectProperty<Side> side = new SimpleObjectProperty<Side>() {
@Override
@@ -379,7 +379,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Top extends Horizontal {
private final class Top extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Bottom extends Horizontal {
private final class Bottom extends Horizontal {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Left extends Vertical {
private final class Left extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
}
}
private class Right extends Vertical {
private final class Right extends Vertical {
@Override
public void layoutChildren() {
super.layoutChildren();
@@ -40,6 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class TaskExecutorDialogPane extends BorderPane {
private TaskExecutor executor;
private TaskCancellationAction onCancel;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
private final Label lblTitle;
@@ -84,7 +85,7 @@ public class TaskExecutorDialogPane extends BorderPane {
}
});
speedEventHandler = speedEvent -> {
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
String unit = "B/s";
double speed = speedEvent.getSpeed();
if (speed > 1024) {
@@ -95,11 +96,10 @@ public class TaskExecutorDialogPane extends BorderPane {
speed /= 1024;
unit = "MiB/s";
}
double finalSpeed = speed;
String finalUnit = unit;
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
};
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
String message = String.format("%.1f %s", speed, unit);
Platform.runLater(() -> lblProgress.setText(message));
});
onEscPressed(this, btnCancel::fire);
}
@@ -82,8 +82,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
BorderPane descriptionPane = new BorderPane();
{
btnDescription = new JFXButton(i18n("modpack.description"));
btnDescription.getStyleClass().add("jfx-button-border");
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
btnDescription.setOnAction(e -> onDescribe());
descriptionPane.setLeft(btnDescription);
@@ -287,7 +287,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* the mark position becomes invalid.
* @see InputStream#reset()
*/
public synchronized void mark(int readlimit) {
public void mark(int readlimit) {
}
/**
@@ -302,7 +302,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
* @see InputStream#mark(int)
* @see IOException
*/
public synchronized void reset() throws IOException {
public void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
}
@@ -72,6 +72,7 @@ import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
@@ -115,6 +116,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
Label titleLabel = new Label(Metadata.FULL_TITLE);
if (I18n.isUpsideDown()) {
titleIcon.setRotate(180);
titleLabel.setRotate(180);
}
titleLabel.getStyleClass().add("jfx-decorator-title");
titleNode.getChildren().setAll(titleIcon, titleLabel);
@@ -208,20 +213,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
launchPane.getStyleClass().add("launch-pane");
launchPane.setMaxWidth(230);
launchPane.setMaxHeight(55);
launchPane.setOnScroll(event -> {
double deltaY = event.getDeltaY();
if (deltaY == 0)
return;
FXUtils.onScroll(launchPane, versions, list -> {
String currentId = getCurrentGame();
int index = Lang.indexWhere(versions, instance -> instance.getId().equals(currentId));
if (index < 0) return;
if (deltaY > 0) // up
index--;
else // down
index++;
profile.setSelectedVersion(versions.get((index + versions.size()) % versions.size()).getId());
});
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> profile.setSelectedVersion(it.getId()));
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
{
JFXButton launchButton = new JFXButton();
@@ -435,6 +431,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
return state;
}
public Profile getProfile() {
return profile;
}
public String getCurrentGame() {
return currentGame.get();
}
@@ -447,6 +447,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
this.currentGame.set(currentGame);
}
public ObservableList<Version> getVersions() {
return versions;
}
public boolean isShowUpdate() {
return showUpdate.get();
}
@@ -174,6 +174,7 @@ public class PersonalizationPage extends StackPane {
}
Label textOpacity = new Label();
FXUtils.setLimitWidth(textOpacity, 35);
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
textOpacity.textProperty().bind(valueBinding);
@@ -154,6 +154,10 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
Versions.modifyGameSettings(profile, version);
}
});
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
String currentId = getSkinnable().getMainPage().getCurrentGame();
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
}, it -> getSkinnable().getMainPage().getProfile().setSelectedVersion(it.getId()));
// third item in left sidebar
AdvancedListItem gameItem = new AdvancedListItem();
@@ -194,8 +198,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
.add(downloadItem)
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
.add(launcherSettingsItem)
.add(chatItem)
;
.add(chatItem);
// the root page, with the sidebar in left, navigator in center.
setLeft(sideBar);
@@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.tukaani.xz.XZInputStream;
@@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView {
FXUtils.smoothScrolling(scroll);
// ==== Languages ====
cboLanguage.getItems().setAll(Locales.LOCALES);
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
@@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import java.util.Arrays;
@@ -162,9 +162,8 @@ public abstract class SettingsView extends StackPane {
));
{
JFXButton cleanButton = new JFXButton(i18n("launcher.cache_directory.clean"));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
cleanButton.getStyleClass().add("jfx-button-border");
fileCommonLocationSublist.setHeaderRight(cleanButton);
}
@@ -216,9 +215,8 @@ public abstract class SettingsView extends StackPane {
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
JFXButton logButton = new JFXButton(i18n("settings.launcher.launcher_log.export"));
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
logButton.setOnAction(e -> onExportLogs());
logButton.getStyleClass().add("jfx-button-border");
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
@@ -27,6 +27,7 @@ import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
@@ -68,9 +69,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
actions.setPadding(new Insets(8));
actions.setAlignment(Pos.CENTER_RIGHT);
JFXButton saveButton = new JFXButton(i18n("button.save"));
saveButton.getStyleClass().add("jfx-button-raised");
saveButton.setButtonType(JFXButton.ButtonType.RAISED);
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
saveButton.setOnAction(e -> {
try {
save();
@@ -80,9 +79,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
}
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
cancelButton.getStyleClass().add("jfx-button-raised");
cancelButton.setButtonType(JFXButton.ButtonType.RAISED);
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
onEscPressed(this, cancelButton::fire);
@@ -198,7 +198,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
copyGlobalPane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
JFXButton copyAll = new JFXButton(i18n("settings.game.copy_global.copy_all"));
JFXButton copyAll = FXUtils.newBorderButton(i18n("settings.game.copy_global.copy_all"));
copyAll.disableProperty().bind(modpack);
copyGlobalPane.setRight(copyAll);
copyAll.setOnAction(e -> Controllers.confirm(i18n("settings.game.copy_global.copy_all.confirm"), null, () -> {
@@ -209,7 +209,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
PropertyUtils.copyProperties(profile.getGlobal(), lastVersionSetting, name -> !ignored.contains(name));
}, null));
copyAll.getStyleClass().add("jfx-button-border");
BorderPane.setAlignment(copyAll, Pos.CENTER_RIGHT);
}
@@ -456,7 +455,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
showAdvancedSettingPane.setLeft(label);
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
JFXButton button = new JFXButton(i18n("settings.advanced.modify"));
JFXButton button = FXUtils.newBorderButton(i18n("settings.advanced.modify"));
button.setOnAction(e -> {
if (lastVersionSetting != null) {
if (advancedVersionSettingPage == null)
@@ -465,7 +464,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
Controllers.navigate(advancedVersionSettingPage);
}
});
button.getStyleClass().add("jfx-button-border");
showAdvancedSettingPane.setRight(button);
}
@@ -19,7 +19,6 @@ package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
@@ -34,7 +33,7 @@ public final class I18n {
private I18n() {
}
private static volatile SupportedLocale locale = Locales.DEFAULT;
private static volatile SupportedLocale locale = SupportedLocale.DEFAULT;
public static void setLocale(SupportedLocale locale) {
I18n.locale = locale;
@@ -44,6 +43,10 @@ public final class I18n {
return locale;
}
public static boolean isUpsideDown() {
return LocaleUtils.getScript(locale.getLocale()).equals("Qabs");
}
public static boolean isUseChinese() {
return LocaleUtils.isChinese(locale.getLocale());
}
@@ -71,6 +74,11 @@ public final class I18n {
else
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
}
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
return UpsideDownUtils.translate(version.getSelfVersion());
}
return version.getSelfVersion();
}
@@ -1,273 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Locales {
private Locales() {
}
public static final SupportedLocale DEFAULT;
static {
String language = System.getenv("HMCL_LANGUAGE");
DEFAULT = new SupportedLocale(true, "def",
StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language));
}
/**
* English
*/
public static final SupportedLocale EN = new SupportedLocale("en");
/**
* Spanish
*/
public static final SupportedLocale ES = new SupportedLocale("es");
/**
* Russian
*/
public static final SupportedLocale RU = new SupportedLocale("ru");
/**
* Ukrainian
*/
public static final SupportedLocale UK = new SupportedLocale("uk");
/**
* Japanese
*/
public static final SupportedLocale JA = new SupportedLocale("ja");
/**
* Chinese (Simplified)
*/
public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS);
/**
* Chinese (Traditional)
*/
public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT);
/**
* Wenyan (Classical Chinese)
*/
public static final SupportedLocale WENYAN = new SupportedLocale("lzh");
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
public static SupportedLocale getLocaleByName(String name) {
if (name == null) return DEFAULT;
for (SupportedLocale locale : LOCALES) {
if (locale.getName().equalsIgnoreCase(name))
return locale;
}
return DEFAULT;
}
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public static final class SupportedLocale {
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale(boolean isDefault, String name, Locale locale) {
this.isDefault = isDefault;
this.name = name;
this.locale = locale;
}
SupportedLocale(String name) {
this(false, name, Locale.forLanguageTag(name));
}
SupportedLocale(String name, Locale locale) {
this(false, name, locale);
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale inJavaLocale = inLocale.getLocale();
if (LocaleUtils.isISO3Language(inJavaLocale.getLanguage())) {
String iso1 = LocaleUtils.getISO1Language(inJavaLocale);
if (LocaleUtils.isISO1Language(iso1)) {
Locale.Builder builder = new Locale.Builder()
.setLocale(inJavaLocale)
.setLanguage(iso1);
if (inJavaLocale.getScript().isEmpty())
builder.setScript(LocaleUtils.getScript(inJavaLocale));
inJavaLocale = builder.build();
}
}
if (this.locale.getLanguage().equals("lzh")) {
if (inJavaLocale.getLanguage().equals("zh"))
return "文言";
String name = locale.getDisplayName(inJavaLocale);
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
return locale.getDisplayName(inJavaLocale);
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (locale.getLanguage().equals("lzh"))
return WenyanUtils.formatDateTime(time);
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getISO1Language(this.getLocale())
.equals(LocaleUtils.getISO1Language(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
return getLocaleByName(in.nextString());
}
}
}
}
@@ -27,7 +27,7 @@ public final class MinecraftWiki {
private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$");
public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) {
public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) {
String wikiVersion = version.getSelfVersion();
var gameVersion = GameVersionNumber.asGameVersion(wikiVersion);
@@ -41,7 +41,7 @@ public final class MinecraftWiki {
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
return getWikiLink(Locales.ZH_HANT, version);
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
} else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
return locale.i18n("wiki.version.game.snapshot", translatedVersion);
} else {
@@ -0,0 +1,280 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.InputStream;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public final class SupportedLocale {
public static final SupportedLocale DEFAULT = new SupportedLocale();
private static final ConcurrentMap<Locale, SupportedLocale> LOCALES = new ConcurrentHashMap<>();
public static List<SupportedLocale> getSupportedLocales() {
List<SupportedLocale> list = new ArrayList<>();
list.add(DEFAULT);
InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json");
if (locales != null) {
try (locales) {
list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class)));
} catch (Throwable e) {
LOG.warning("Failed to load languages.json", e);
}
}
return List.copyOf(list);
}
public static SupportedLocale getLocale(Locale locale) {
return LOCALES.computeIfAbsent(locale, SupportedLocale::new);
}
public static SupportedLocale getLocaleByName(String name) {
if (name == null || name.isBlank() || "def".equals(name) || "default".equals(name))
return DEFAULT;
return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-')));
}
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private ResourceBundle localeNamesBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale() {
this.isDefault = true;
this.name = "def"; // TODO: Change to "default" after updating the Config format
String language = System.getenv("HMCL_LANGUAGE");
this.locale = StringUtils.isBlank(language)
? LocaleUtils.SYSTEM_DEFAULT
: Locale.forLanguageTag(language);
}
SupportedLocale(Locale locale) {
this.isDefault = false;
this.name = locale.toLanguageTag();
this.locale = locale;
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale currentLocale = this.getLocale();
String language = currentLocale.getLanguage();
String subLanguage;
{
String parentLanguage = LocaleUtils.getParentLanguage(language);
if (parentLanguage != null) {
subLanguage = language;
language = parentLanguage;
} else {
subLanguage = "";
}
}
ResourceBundle localeNames = inLocale.getLocaleNamesBundle();
String languageDisplayName = language;
try {
languageDisplayName = localeNames.getString(language);
} catch (Throwable e) {
LOG.warning("Failed to get localized name for language " + language, e);
}
// Currently, HMCL does not support any locales with regions or variants, so they are not handled for now
List<String> subTags = Stream.of(subLanguage, currentLocale.getScript())
.filter(it -> !it.isEmpty())
.map(it -> {
try {
return localeNames.getString(it);
} catch (Throwable e) {
LOG.warning("Failed to get localized name of " + it, e);
}
return it;
}).toList();
return subTags.isEmpty()
? languageDisplayName
: languageDisplayName + " (" + String.join(", ", subTags) + ")";
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public ResourceBundle getLocaleNamesBundle() {
ResourceBundle bundle = localeNamesBundle;
if (localeNamesBundle == null)
localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
return UpsideDownUtils.formatDateTime(time);
}
if (locale.getLanguage().equals("lzh")) {
return WenyanUtils.formatDateTime(time);
}
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getRootLanguage(this.getLocale())
.equals(LocaleUtils.getRootLanguage(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL)
return DEFAULT;
String language = in.nextString();
return getLocaleByName(switch (language) {
// TODO: Remove these compatibility codes after updating the Config format
case "zh_CN" -> "zh-Hans"; // For compatibility
case "zh" -> "zh-Hant"; // For compatibility
default -> language;
});
}
}
}
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
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.Map;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class UpsideDownUtils {
private static final Map<Integer, Integer> MAPPER = loadMap();
private static Map<Integer, Integer> loadMap() {
var map = new LinkedHashMap<Integer, Integer>();
InputStream inputStream = UpsideDownUtils.class.getResourceAsStream("/assets/lang/upside_down.txt");
if (inputStream != null) {
try (inputStream) {
new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
if (line.isBlank() || line.startsWith("#"))
return;
if (line.length() != 2) {
LOG.warning("Invalid line: " + line);
return;
}
map.put((int) line.charAt(0), (int) line.charAt(1));
});
} catch (IOException e) {
LOG.warning("Failed to load upside_down.txt", e);
}
} else {
LOG.warning("upside_down.txt not found");
}
return Collections.unmodifiableMap(map);
}
public static String translate(String str) {
StringBuilder builder = new StringBuilder(str.length());
str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch)));
return builder.reverse().toString();
}
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
.withZone(ZoneId.systemDefault());
public static String formatDateTime(TemporalAccessor time) {
return translate(BASE_FORMATTER.format(time));
}
private UpsideDownUtils() {
}
}
@@ -692,7 +692,7 @@ input.url=The input must be a valid URL.
install=New Instance
install.change_version=Change Version
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
install.change_version.process=Change Version Process
install.failed=Failed to install
install.failed.downloading=Failed to download some required files.
@@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s.
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
install.installer.change_version=%s Incompatible
install.installer.choose=Choose Your %s Version
install.installer.cleanroom=Cleanroom
@@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
@@ -906,7 +906,7 @@ modpack.installing=Installing modpack
modpack.installing.given=Installing %s modpack
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
modpack.invalid=Invalid modpack, you can try downloading it again.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
modpack.name=Modpack Name
modpack.not_a_valid_name=Invalid modpack name.
modpack.origin=Source
@@ -1236,7 +1236,7 @@ search.first_page=First
search.previous_page=Previous
search.next_page=Next
search.last_page=Last
search.page_n=%d / %s
search.page_n=%1$d / %2$s
selector.choose=Choose
selector.choose_file=Choose file
@@ -1256,6 +1256,7 @@ settings.advanced.custom_commands.hint=The following environment variables are p
\ · $INST_JAVA: java binary used for launch.\n\
\ · $INST_FORGE: set if Forge is installed.\n\
\ · $INST_NEOFORGE: set if NeoForge is installed.\n\
\ · $INST_CLEANROOM: set if Cleanroom is installed.\n\
\ · $INST_LITELOADER: set if LiteLoader is installed.\n\
\ · $INST_OPTIFINE: set if OptiFine is installed.\n\
\ · $INST_FABRIC: set if Fabric is installed.\n\
@@ -1333,7 +1334,7 @@ settings.game.java_directory.bit=%s bit
settings.game.java_directory.choose=Choose Java
settings.game.java_directory.invalid=Incorrect Java path
settings.game.java_directory.version=Specify Java Version
settings.game.java_directory.template=%s (%s)
settings.game.java_directory.template=%1$s (%2$s)
settings.game.management=Manage
settings.game.working_directory=Working Directory
settings.game.working_directory.choose=Choose the working directory
@@ -1479,7 +1480,7 @@ version.manage.manage=Edit Instance
version.manage.manage.title=Edit Instance - %1s
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 "%s"? You can still find its files in your recycle bin by the name of "%s".
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_assets=Delete All Assets
version.manage.remove_libraries=Delete All Libraries
@@ -1493,7 +1494,7 @@ version.update=Update Modpack
warning.java_interpreted_mode=HMCL is running in an interpreted Java environment, which will greatly affect performance.\n\
\n\
We recommend using a Java with JIT support to launch HMCL for the best experience.
We recommend using a Java with JIT support to open HMCL for the best experience.
warning.software_rendering=HMCL is currently using software rendering, which will greatly affect performance.
wiki.tooltip=Minecraft Wiki Page
@@ -180,6 +180,7 @@ button.change_source=Cambiar fuente de descarga
button.clear=Limpiar
button.copy_and_exit=Copiar y salir
button.delete=Borrar
button.do_not_show_again=No volver a mostrar
button.edit=Editar
button.install=Instalar
button.export=Exportar
@@ -391,6 +392,9 @@ fatal.config_loading_failure=No se pueden cargar los archivos de configuración.
Para macOS, intente colocar HMCL en un lugar con permiso que no sea «Escritorio», «Descargas» y «Documentos» y vuelva a intentarlo.
fatal.config_loading_failure.unix=No se pudo cargar el archivo de configuración porque fue creado por el usuario «%1$s».\n\
Por favor, abra HMCL como usuario root (no recomendado), o ejecute el siguiente comando en el terminal para cambiar la propiedad del archivo de configuración al usuario actual:\n%2$s
fatal.config_unsupported_version=El archivo de configuración actual fue creado por una versión más reciente del lanzador, y esta versión del lanzador no puede cargarlo correctamente.\n\
Por favor, actualice y reinicie el lanzador.\n\
Antes de actualizar el lanzador, cualquier configuración que modifiques no se guardará.
fatal.mac_app_translocation=El sistema operativo aísla Hello Minecraft! Launcher en un directorio temporal debido a los mecanismos de seguridad de macOS.\n\
Por favor, mueve HMCL a un directorio diferente antes de intentar abrirlo. De lo contrario, tus ajustes y datos de juego podrían perderse tras reiniciar.\n\
¿Todavía quieres continuar?
@@ -707,6 +711,7 @@ install.failed.optifine_forge_1.17=Para Minecraft 1.17.1, Forge sólo es compati
install.failed.version_mismatch=Este cargador requiere la versión del juego %s, pero la instalada es %s.
install.installer.change_version=%s Incompatible
install.installer.choose=Elija su versión %s
install.installer.cleanroom=Cleanroom
install.installer.depend=Requiere %s
install.installer.do_not_install=No instalar
install.installer.fabric=Fabric
@@ -778,6 +783,7 @@ launch.advice=%s ¿Todavía quieres continuar?
launch.advice.multi=Se han detectado los siguientes problemas:\n\n%s\n\nEstos problemas pueden impedir que inicies el juego o afectar a la experiencia de juego.\n\n¿Todavía quieres continuar?
launch.advice.java.auto=La versión actual de Java no es compatible con la instancia.\n\nHaga clic en «Sí» para elegir automáticamente la versión de Java más compatible. O bien, puede navegar hasta «Config. Global/Específica de instancia → Java» para elegir uno usted mismo.
launch.advice.java.modded_java_7=Minecraft 1.7.2 y versiones anteriores requieren Java 7 o anterior.
launch.advice.cleanroom=Cleanroom solo se puede ejecutar en Java 21 o versiones posteriores. Por favor, actualice su versión de Java.
launch.advice.corrected=Hemos resuelto el problema de Java. Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.uncorrected=Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
launch.advice.different_platform=Se recomienda la versión de 64 bits de Java para tu dispositivo, pero has instalado una de 32 bits.
@@ -1156,6 +1162,7 @@ world.info.player.food_level=Nivel de hambre
world.info.player.game_type=Modo de juego
world.info.player.game_type.adventure=Aventura
world.info.player.game_type.creative=Creativo
world.info.player.game_type.hardcore=Extremo
world.info.player.game_type.spectator=Espectador
world.info.player.game_type.survival=Supervivencia
world.info.player.health=Salud
@@ -1255,6 +1262,7 @@ settings.advanced.custom_commands.hint=Se proporcionan las siguientes variables
\ · $INST_JAVA: binario de java utilizado para la ejecución\n\
\ · $INST_FORGE: disponible si Forge está instalado.\n\
\ · $INST_NEOFORGE: disponible si NeoForge está instalado.\n\
\ · $INST_CLEANROOM: disponible si Cleanroom está instalado.\n\
\ · $INST_LITELOADER: disponible si LiteLoader está instalado.\n\
\ · $INST_OPTIFINE: disponible si OptiFine está instalado.\n\
\ · $INST_FABRIC: disponible si Fabric está instalado.\n\
@@ -1288,6 +1296,7 @@ settings.advanced.natives_directory.hint=Esta opción está pensada sólo para u
\n\
Antes de proceder, por favor, asegúrese de que todas las bibliotecas (por ejemplo, lwjgl.dll, libopenal.so) se proporcionan en su directorio deseado.
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
settings.advanced.precall_command=Comando pre-lanzamiento:
settings.advanced.precall_command.prompt=El comando se ejecuta antes de que los juegos se lance
settings.advanced.process_priority=Prioridad del proceso
@@ -1344,6 +1353,7 @@ settings.launcher=Configuración del launcher
settings.launcher.appearance=Apariencia
settings.launcher.common_path.tooltip=Esta aplicación pondrá todos los activos y dependencias del juego aquí. Si hay bibliotecas existentes en el directorio del juego, el launcher preferirá usarlas primero.
settings.launcher.debug=Depuración
settings.launcher.disable_auto_game_options=No cambiar el idioma del juego
settings.launcher.download=Descargas
settings.launcher.download.threads=Hilos
settings.launcher.download.threads.auto=Determinar automáticamente
@@ -1483,6 +1493,12 @@ version.search.prompt=Introduzca el nombre de la versión para buscar.
version.settings=Configuración
version.update=Actualizar Modpack
warning.java_interpreted_mode=El lanzador se ejecuta en un entorno Java interpretado, lo que afectará considerablemente al rendimiento.\n\
\n\
Recomendamos utilizar Java con soporte JIT para ejecutar el lanzador y obtener la mejor experiencia.
warning.software_rendering=El lanzador utiliza actualmente renderización por software, lo que afectará considerablemente al rendimiento.
wiki.tooltip=Página de Minecraft Wiki
wiki.version.game=https://es.minecraft.wiki/w/Java_Edition_%s
wiki.version.game.snapshot=https://es.minecraft.wiki/w/Java_Edition_%s
@@ -379,7 +379,7 @@ fatal.config_change_owner_root=你正在使用 root 帳戶開啟 Hello Minecraft
fatal.config_in_temp_dir=你正在暫存目錄中開啟 Hello Minecraft! Launcher,你的設定和遊戲資料可能會遺失。建議將 HMCL 移動至其他位置再開啟。\n是否繼續開啟?
fatal.config_loading_failure=Hello Minecraft! Launcher 無法載入設定檔案。\n請確保 HMCL 對「%s」目錄及該目錄下的檔案擁有讀寫權限。
fatal.config_loading_failure.unix=Hello Minecraft! Launcher 無法載入設定檔案,因為設定檔案是由使用者「%1$s」建立的。\n請使用 root 帳戶開啟 HMCL (不推薦),或在終端中執行以下指令將設定檔案的所有權變更為目前使用者:\n%2$s
fatal.config_unsupported_version=當前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
fatal.config_unsupported_version=目前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
fatal.mac_app_translocation=由於 macOS 的安全機制,Hello Minecraft! Launcher 被系統隔離至暫存目錄中。\n請將 HMCL 移動到其他目錄後再嘗試開啟,否則你的設定和遊戲資料可能會在重啟後遺失。\n是否繼續開啟?
fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即將升級完成,請重新開啟 HMCL。
fatal.apply_update_failure=我們很抱歉 Hello Minecraft! Launcher 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 HMCL 來完成升級。
@@ -1049,6 +1049,7 @@ settings.advanced.custom_commands.hint=自訂指令被呼叫時將包含如下
\ · $INST_JAVA: 遊戲執行使用的 Java 路徑;\n\
\ · $INST_FORGE: 若安裝了 Forge,將會存在本環境變數;\n\
\ · $INST_NEOFORGE: 若安裝了 NeoForge,將會存在本環境變數;\n\
\ · $INST_CLEANROOM: 若安裝了 Cleanroom,將會存在本環境變數;\n\
\ · $INST_LITELOADER: 若安裝了 LiteLoader,將會存在本環境變數;\n\
\ · $INST_OPTIFINE: 若安裝了 OptiFine,將會存在本環境變數;\n\
\ · $INST_FABRIC: 若安裝了 Fabric,將會存在本環境變數;\n\
@@ -1080,7 +1081,7 @@ settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本
settings.advanced.natives_directory.default.version_id=<實例名稱>
settings.advanced.natives_directory.hint=本選項提供給 Apple Silicon 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
settings.advanced.no_optimizing_jvm_args=不自動添加 Java 虛擬機優化參數
settings.advanced.no_optimizing_jvm_args=不自動新增 Java 虛擬機最佳化參數
settings.advanced.precall_command=遊戲啟動前執行指令
settings.advanced.precall_command.prompt=將在遊戲啟動前呼叫使用
settings.advanced.process_priority=處理程式優先度
@@ -1275,8 +1276,8 @@ version.search.prompt=輸入版本名稱進行搜尋
version.settings=遊戲設定
version.update=更新模組包
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 啟動 HMCL 以獲得最佳體驗。
warning.software_rendering=HMCL 正在使用軟體渲染,效能將會受到很大影響。
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 開啟 HMCL 以獲得最佳體驗。
warning.software_rendering=HMCL 正在使用軟體繪製,效能將會受到很大影響。
wiki.tooltip=Minecraft Wiki 頁面
wiki.version.game=https://zh.minecraft.wiki/w/Java版%s
@@ -0,0 +1,19 @@
#
# 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/>.
#
boot.unsupported_java_version=HMCL requiere Java 17 o posterior para ejecutarse, pero sigue admitiendo el inicio de juegos con Java 6~16.\nPor favor, instala la última versión de Java e intenta ejecutar HMCL de nuevo.\nPuedes conservar tu versión antigua de Java. HMCL puede detectar y gestionar múltiples instalaciones de Java,\ny seleccionará automáticamente la versión de Java adecuada para tu juego.
@@ -271,7 +271,7 @@ public class OAuth {
}
}
private static class DeviceTokenResponse extends ErrorResponse {
private final static class DeviceTokenResponse extends ErrorResponse {
@SerializedName("user_code")
public String userCode;
@@ -291,7 +291,7 @@ public class OAuth {
public int interval;
}
private static class TokenResponse extends ErrorResponse {
private final static class TokenResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@@ -329,7 +329,7 @@ public class OAuth {
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
public static class AuthorizationResponse extends ErrorResponse {
public final static class AuthorizationResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@@ -352,7 +352,7 @@ public class OAuth {
public String foci;
}
private static class RefreshResponse extends ErrorResponse {
private final static class RefreshResponse extends ErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@@ -321,16 +321,16 @@ public class MicrosoftService {
public static final long ADD_FAMILY = 2148916238L;
}
public static class XBox400Exception extends AuthenticationException {
public final static class XBox400Exception extends AuthenticationException {
}
public static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
public final static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
}
public static class NoXuiException extends AuthenticationException {
public final static class NoXuiException extends AuthenticationException {
}
private static class XBoxLiveAuthenticationResponseDisplayClaims {
private final static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui;
}
@@ -356,7 +356,7 @@ public class MicrosoftService {
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
* not linked to a family
*/
private static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
private final static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
@SerializedName("IssueInstant")
String issueInstant;
@@ -370,7 +370,7 @@ public class MicrosoftService {
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
}
private static class MinecraftLoginWithXBoxResponse {
private final static class MinecraftLoginWithXBoxResponse {
@SerializedName("username")
String username;
@@ -387,14 +387,14 @@ public class MicrosoftService {
int expiresIn;
}
private static class MinecraftStoreResponseItem {
private final static class MinecraftStoreResponseItem {
@SerializedName("name")
String name;
@SerializedName("signature")
String signature;
}
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
private final static class MinecraftStoreResponse extends MinecraftErrorResponse {
@SerializedName("items")
List<MinecraftStoreResponseItem> items;
@@ -405,7 +405,7 @@ public class MicrosoftService {
String keyId;
}
public static class MinecraftProfileResponseSkin implements Validation {
public final static class MinecraftProfileResponseSkin implements Validation {
public String id;
public String state;
public String url;
@@ -34,7 +34,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -244,7 +244,7 @@ public class Skin {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
return new Context() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -260,7 +260,7 @@ public class Skin {
setResult(new ByteArrayInputStream(baos.toByteArray()));
if (checkETag) {
repository.cacheBytes(connection, baos.toByteArray());
repository.cacheBytes(response, baos.toByteArray());
}
}
};
@@ -246,11 +246,11 @@ public class YggdrasilService {
}
}
private static class TextureResponse {
private final static class TextureResponse {
public Map<TextureType, Texture> textures;
}
private static class AuthenticationResponse extends ErrorResponse {
private final static class AuthenticationResponse extends ErrorResponse {
public String accessToken;
public String clientToken;
public GameProfile selectedProfile;
@@ -17,8 +17,9 @@
*/
package org.jackhuang.hmcl.download;
import org.jetbrains.annotations.Unmodifiable;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -29,10 +30,10 @@ import java.util.stream.Collectors;
*/
public class AdaptedDownloadProvider implements DownloadProvider {
private List<DownloadProvider> downloadProviderCandidates;
private @Unmodifiable List<DownloadProvider> downloadProviderCandidates;
public void setDownloadProviderCandidates(List<DownloadProvider> downloadProviderCandidates) {
this.downloadProviderCandidates = new ArrayList<>(downloadProviderCandidates);
this.downloadProviderCandidates = List.copyOf(downloadProviderCandidates);
}
public DownloadProvider getPreferredDownloadProvider() {
@@ -0,0 +1,82 @@
/*
* 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.download;
import java.net.URI;
import java.util.List;
import java.util.Objects;
/**
* @author Glavo
*/
public final class DownloadProviderWrapper implements DownloadProvider {
private DownloadProvider provider;
public DownloadProviderWrapper(DownloadProvider provider) {
this.provider = provider;
}
public DownloadProvider getProvider() {
return this.provider;
}
public void setProvider(DownloadProvider provider) {
this.provider = Objects.requireNonNull(provider);
}
@Override
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
return getProvider().getAssetObjectCandidates(assetObjectLocation);
}
@Override
public String getVersionListURL() {
return getProvider().getVersionListURL();
}
@Override
public String getAssetBaseURL() {
return getProvider().getAssetBaseURL();
}
@Override
public String injectURL(String baseURL) {
return getProvider().injectURL(baseURL);
}
@Override
public List<URI> injectURLWithCandidates(String baseURL) {
return getProvider().injectURLWithCandidates(baseURL);
}
@Override
public List<URI> injectURLsWithCandidates(List<String> urls) {
return getProvider().injectURLsWithCandidates(urls);
}
@Override
public VersionList<?> getVersionListById(String id) {
return getProvider().getVersionListById(id);
}
@Override
public int getConcurrency() {
return getProvider().getConcurrency();
}
}
@@ -62,7 +62,7 @@ public final class CleanroomVersionList extends VersionList<CleanroomRemoteVersi
});
}
private static class ReleaseResult {
private final static class ReleaseResult {
String name;
String created_at;
}
@@ -17,29 +17,32 @@
*/
package org.jackhuang.hmcl.event;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.NotNull;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
*
* @author huangyuhui
*/
public final class EventBus {
/// @author huangyuhui
public final class EventBus extends ClassValue<EventManager<?>> {
private final ConcurrentHashMap<Class<?>, EventManager<?>> events = new ConcurrentHashMap<>();
public static final EventBus EVENT_BUS = new EventBus();
private EventBus() {
}
@Override
protected EventManager<?> computeValue(@NotNull Class<?> type) {
return new EventManager<>();
}
@SuppressWarnings("unchecked")
public <T extends Event> EventManager<T> channel(Class<T> clazz) {
return (EventManager<T>) events.computeIfAbsent(clazz, ignored -> new EventManager<>());
return (EventManager<T>) get(clazz);
}
@SuppressWarnings("unchecked")
public Event.Result fireEvent(Event obj) {
LOG.info(obj + " gets fired");
return channel((Class<Event>) obj.getClass()).fireEvent(obj);
return ((EventManager<Event>) get(obj.getClass())).fireEvent(obj);
}
public static final EventBus EVENT_BUS = new EventBus();
}
@@ -18,26 +18,28 @@
package org.jackhuang.hmcl.event;
import org.jackhuang.hmcl.util.SimpleMultimap;
import org.jetbrains.annotations.Contract;
import java.lang.ref.WeakReference;
import java.util.EnumMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
/**
*
* @author huangyuhui
*/
/// @author huangyuhui
public final class EventManager<T extends Event> {
private final ReentrantLock lock = new ReentrantLock();
private final SimpleMultimap<EventPriority, Consumer<T>, CopyOnWriteArraySet<Consumer<T>>> handlers
= new SimpleMultimap<>(() -> new EnumMap<>(EventPriority.class), CopyOnWriteArraySet::new);
@Contract("_ -> param1")
public Consumer<T> registerWeak(Consumer<T> consumer) {
register(new WeakListener(consumer));
return consumer;
}
@Contract("_, _ -> param1")
public Consumer<T> registerWeak(Consumer<T> consumer, EventPriority priority) {
register(new WeakListener(consumer), priority);
return consumer;
@@ -47,9 +49,13 @@ public final class EventManager<T extends Event> {
register(consumer, EventPriority.NORMAL);
}
public synchronized void register(Consumer<T> consumer, EventPriority priority) {
if (!handlers.get(priority).contains(consumer))
handlers.put(priority, consumer);
public void register(Consumer<T> consumer, EventPriority priority) {
lock.lock();
try {
handlers.get(priority).add(consumer);
} finally {
lock.unlock();
}
}
public void register(Runnable runnable) {
@@ -60,23 +66,30 @@ public final class EventManager<T extends Event> {
register(t -> runnable.run(), priority);
}
public synchronized Event.Result fireEvent(T event) {
for (EventPriority priority : EventPriority.values()) {
for (Consumer<T> handler : handlers.get(priority))
handler.accept(event);
public Event.Result fireEvent(T event) {
lock.lock();
try {
for (EventPriority priority : EventPriority.values()) {
for (Consumer<T> handler : handlers.get(priority))
handler.accept(event);
}
} finally {
lock.unlock();
}
if (event.hasResult())
return event.getResult();
else
return Event.Result.DEFAULT;
return event.hasResult() ? event.getResult() : Event.Result.DEFAULT;
}
public synchronized void unregister(Consumer<T> consumer) {
handlers.removeValue(consumer);
public void unregister(Consumer<T> consumer) {
lock.lock();
try {
handlers.removeValue(consumer);
} finally {
lock.unlock();
}
}
private class WeakListener implements Consumer<T> {
private final class WeakListener implements Consumer<T> {
private final WeakReference<Consumer<T>> ref;
public WeakListener(Consumer<T> listener) {
@@ -279,7 +279,7 @@ public final class World {
if (isLocked()) {
throw new WorldLockedException("The world " + getFile() + " has been locked");
}
FileUtils.deleteDirectory(file);
FileUtils.forceDelete(file);
}
public CompoundTag readLevelDat() throws IOException {
@@ -24,7 +24,7 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -45,6 +45,9 @@ public final class CacheFileTask extends FetchTask<Path> {
public CacheFileTask(@NotNull URI uri) {
super(List.of(uri));
setName(uri.toString());
if (!NetworkUtils.isHttpUri(uri))
throw new IllegalArgumentException(uri.toString());
}
@Override
@@ -69,8 +72,9 @@ public final class CacheFileTask extends FetchTask<Path> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
assert checkETag;
assert response != null;
Path temp = Files.createTempFile("hmcl-download-", null);
OutputStream fileOutput = Files.newOutputStream(temp);
@@ -99,7 +103,7 @@ public final class CacheFileTask extends FetchTask<Path> {
}
try {
setResult(repository.cacheRemoteFile(connection, temp));
setResult(repository.cacheRemoteFile(response, temp));
} finally {
try {
Files.deleteIfExists(temp);
@@ -19,31 +19,43 @@ package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class FetchTask<T> extends Task<T> {
private static final HttpClient HTTP_CLIENT;
static {
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
.build();
}
protected static final int DEFAULT_RETRY = 3;
protected final List<URI> uris;
@@ -58,7 +70,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (this.uris.isEmpty())
throw new IllegalArgumentException("At least one URL is required");
setExecutor(download());
setExecutor(DOWNLOAD_EXECUTOR);
}
public void setRetry(int retry) {
@@ -79,12 +91,14 @@ public abstract class FetchTask<T> extends Task<T> {
protected abstract EnumCheckETag shouldCheckETag();
protected abstract Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException;
private Context getContext() throws IOException {
return getContext(null, false, null);
}
protected abstract Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException;
@Override
public void execute() throws Exception {
Exception exception = null;
URI failedURI = null;
boolean checkETag;
switch (shouldCheckETag()) {
case CHECK_E_TAG:
@@ -97,174 +111,258 @@ public abstract class FetchTask<T> extends Task<T> {
return;
}
int repeat = 0;
download:
for (URI uri : uris) {
if (checkETag) {
// Handle cache
ArrayList<DownloadException> exceptions = null;
if (SEMAPHORE != null)
SEMAPHORE.acquire();
try {
for (URI uri : uris) {
try {
Path cache = repository.getCachedRemoteFile(uri, true);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
if (NetworkUtils.isHttpUri(uri))
downloadHttp(uri, checkETag);
else
downloadNotHttp(uri);
return;
} catch (IOException ignored) {
} catch (DownloadException e) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(e);
}
}
} catch (InterruptedException ignored) {
// Cancelled
} finally {
if (SEMAPHORE != null)
SEMAPHORE.release();
}
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
break download;
if (exceptions != null) {
DownloadException last = exceptions.remove(exceptions.size() - 1);
for (DownloadException exception : exceptions) {
last.addSuppressed(exception);
}
throw last;
}
}
private void download(Context context,
InputStream inputStream,
long contentLength,
ContentEncoding contentEncoding) throws IOException, InterruptedException {
try (var ignored = context;
var counter = new CounterInputStream(inputStream);
var input = contentEncoding.wrap(counter)) {
long lastDownloaded = 0L;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
while (true) {
if (isCancelled()) break;
int len = input.read(buffer);
if (len == -1) break;
context.write(buffer, 0, len);
if (contentLength >= 0) {
// Update progress information per second
updateProgress(counter.downloaded, contentLength);
}
List<URI> redirects = null;
String bmclapiHash = null;
try {
beforeDownload(uri);
updateProgress(0);
updateDownloadSpeed(counter.downloaded - lastDownloaded);
lastDownloaded = counter.downloaded;
}
URLConnection conn = NetworkUtils.createConnection(uri);
if (isCancelled())
throw new InterruptedException();
if (conn instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Encoding", "gzip");
updateDownloadSpeed(counter.downloaded - lastDownloaded);
if (checkETag) repository.injectConnection(httpConnection);
Map<String, List<String>> requestProperties = httpConnection.getRequestProperties();
if (contentLength >= 0 && counter.downloaded != contentLength)
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
bmclapiHash = httpConnection.getHeaderField("x-bmclapi-hash");
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
} else {
bmclapiHash = null;
}
context.withResult(true);
}
}
while (true) {
int code = httpConnection.getResponseCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
httpConnection.disconnect();
throw new IOException("Too much redirects");
}
String location = httpConnection.getHeaderField("Location");
httpConnection.disconnect();
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = NetworkUtils.toURI(httpConnection.getURL())
.resolve(NetworkUtils.toURI(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
HttpURLConnection redirected = NetworkUtils.createHttpConnection(target);
redirected.setUseCaches(checkETag);
requestProperties
.forEach((key, values) ->
values.forEach(element ->
redirected.addRequestProperty(key, element)));
httpConnection = redirected;
} else {
break;
}
}
conn = httpConnection;
int responseCode = ((HttpURLConnection) conn).getResponseCode();
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL()), false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(conn.getURL().toURI());
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryTime--;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
}
}
long contentLength = conn.getContentLengthLong();
var encoding = ContentEncoding.fromConnection(conn);
try (var context = getContext(conn, checkETag, bmclapiHash);
var counter = new CounterInputStream(conn.getInputStream());
var input = encoding.wrap(counter)) {
long lastDownloaded = 0L;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
while (true) {
if (isCancelled()) break;
int len = input.read(buffer);
if (len == -1) break;
context.write(buffer, 0, len);
if (contentLength >= 0) {
// Update progress information per second
updateProgress(counter.downloaded, contentLength);
}
updateDownloadSpeed(counter.downloaded - lastDownloaded);
lastDownloaded = counter.downloaded;
}
if (isCancelled()) break download;
updateDownloadSpeed(counter.downloaded - lastDownloaded);
if (contentLength >= 0 && counter.downloaded != contentLength)
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
context.withResult(true);
}
return;
} catch (FileNotFoundException ex) {
failedURI = uri;
exception = ex;
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
break; // we will not try this URL again
} catch (IOException ex) {
failedURI = uri;
exception = ex;
LOG.warning("Failed to download " + uri + ", repeat times: " + (++repeat) + (redirects == null ? "" : ", redirects: " + redirects), ex);
}
private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, InterruptedException {
if (checkETag) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(uri, true);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (IOException ignored) {
}
}
if (exception != null)
throw new DownloadException(failedURI, exception);
ArrayList<IOException> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
List<URI> redirects = null;
try {
beforeDownload(uri);
updateProgress(0);
HttpResponse<InputStream> response;
String bmclapiHash;
URI currentURI = uri;
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
headers.put("accept-encoding", "gzip");
if (checkETag)
headers.putAll(repository.injectConnection(uri));
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI);
requestBuilder.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT));
headers.forEach(requestBuilder::header);
response = HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
}
int code = response.statusCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
String location = response.headers().firstValue("Location").orElse(null);
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
currentURI = target;
} else {
break;
}
} while (true);
int responseCode = response.statusCode();
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(currentURI, false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(currentURI);
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryTime--;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
}
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromResponse(response);
download(getContext(response, checkETag, bmclapiHash),
response.body(),
contentLength,
contentEncoding);
return;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(ex);
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
}
}
throw toDownloadException(uri, null, exceptions);
}
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
ArrayList<IOException> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
try {
beforeDownload(uri);
updateProgress(0);
URLConnection conn = NetworkUtils.createConnection(uri);
download(getContext(),
conn.getInputStream(),
conn.getContentLengthLong(),
ContentEncoding.fromConnection(conn));
return;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found", ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (IOException ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(ex);
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime, ex);
}
}
throw toDownloadException(uri, null, exceptions);
}
private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList<IOException> exceptions) {
if (exceptions == null || exceptions.isEmpty()) {
return new DownloadException(uri, last != null
? last
: new IOException("No exceptions"));
} else {
if (last == null)
last = exceptions.remove(exceptions.size() - 1);
for (IOException e : exceptions) {
last.addSuppressed(e);
}
return new DownloadException(uri, last);
}
}
private static final Timer timer = new Timer("DownloadSpeedRecorder", true);
private static final AtomicLong downloadSpeed = new AtomicLong(0L);
public static final EventBus speedEvent = new EventBus();
public static final EventManager<SpeedEvent> SPEED_EVENT = EventBus.EVENT_BUS.channel(SpeedEvent.class);
static {
timer.schedule(new TimerTask() {
@Override
public void run() {
speedEvent.channel(SpeedEvent.class).fireEvent(new SpeedEvent(speedEvent, downloadSpeed.getAndSet(0)));
SPEED_EVENT.fireEvent(new SpeedEvent(SPEED_EVENT, downloadSpeed.getAndSet(0)));
}
}, 0, 1000);
}
@@ -341,48 +439,74 @@ public abstract class FetchTask<T> extends Task<T> {
CACHED
}
private static final HttpResponse.BodyHandler<InputStream> BODY_HANDLER = responseInfo -> {
if (responseInfo.statusCode() / 100 == 2)
return HttpResponse.BodySubscribers.ofInputStream();
else
return HttpResponse.BodySubscribers.replacing(null);
};
public static int DEFAULT_CONCURRENCY = Math.min(Runtime.getRuntime().availableProcessors() * 4, 64);
private static int downloadExecutorConcurrency = DEFAULT_CONCURRENCY;
private static volatile ThreadPoolExecutor DOWNLOAD_EXECUTOR;
/**
* Get singleton instance of the thread pool for file downloading.
*
* @return Thread pool for FetchTask
*/
protected static ExecutorService download() {
if (DOWNLOAD_EXECUTOR == null) {
synchronized (Schedulers.class) {
if (DOWNLOAD_EXECUTOR == null) {
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
}
}
// For Java 21 or later, DOWNLOAD_EXECUTOR dispatches tasks to virtual threads, and concurrency is controlled by SEMAPHORE.
// For versions earlier than Java 21, DOWNLOAD_EXECUTOR is a ThreadPoolExecutor, SEMAPHORE is null, and concurrency is controlled by the thread pool size.
private static final ExecutorService DOWNLOAD_EXECUTOR;
private static final @Nullable Semaphore SEMAPHORE;
static {
ExecutorService executorService = Schedulers.newVirtualThreadPerTaskExecutor("Download");
if (executorService != null) {
DOWNLOAD_EXECUTOR = executorService;
SEMAPHORE = new Semaphore(DEFAULT_CONCURRENCY);
} else {
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
SEMAPHORE = null;
}
return DOWNLOAD_EXECUTOR;
}
@FXThread
public static void setDownloadExecutorConcurrency(int concurrency) {
concurrency = Math.max(concurrency, 1);
synchronized (Schedulers.class) {
downloadExecutorConcurrency = concurrency;
ThreadPoolExecutor downloadExecutor = DOWNLOAD_EXECUTOR;
if (downloadExecutor != null) {
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
downloadExecutor.setMaximumPoolSize(concurrency);
downloadExecutor.setCorePoolSize(concurrency);
} else {
downloadExecutor.setCorePoolSize(concurrency);
downloadExecutor.setMaximumPoolSize(concurrency);
int prevDownloadExecutorConcurrency = downloadExecutorConcurrency;
int change = concurrency - prevDownloadExecutorConcurrency;
if (change == 0)
return;
downloadExecutorConcurrency = concurrency;
if (SEMAPHORE != null) {
if (change > 0) {
SEMAPHORE.release(change);
} else {
int permits = -change;
if (!SEMAPHORE.tryAcquire(permits)) {
Schedulers.io().execute(() -> {
try {
for (int i = 0; i < permits; i++) {
SEMAPHORE.acquire();
}
} catch (InterruptedException e) {
throw new AssertionError("Unreachable", e);
}
});
}
}
} else {
var downloadExecutor = (ThreadPoolExecutor) DOWNLOAD_EXECUTOR;
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
downloadExecutor.setMaximumPoolSize(concurrency);
downloadExecutor.setCorePoolSize(concurrency);
} else {
downloadExecutor.setCorePoolSize(concurrency);
downloadExecutor.setMaximumPoolSize(concurrency);
}
}
}
public static int getDownloadExecutorConcurrency() {
synchronized (Schedulers.class) {
return downloadExecutorConcurrency;
}
return downloadExecutorConcurrency;
}
}
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -185,7 +185,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Path temp = Files.createTempFile(null, null);
String algorithm;
@@ -260,7 +260,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
if (checkETag) {
repository.cacheRemoteFile(connection, file);
repository.cacheRemoteFile(response, file);
}
}
};
@@ -24,7 +24,9 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -59,9 +61,11 @@ public final class GetTask extends FetchTask<String> {
}
@Override
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) {
int length = connection.getContentLength();
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : length);
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
long length = -1;
if (response != null)
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : (int) length);
return new Context() {
@Override
@@ -73,11 +77,15 @@ public final class GetTask extends FetchTask<String> {
public void close() throws IOException {
if (!isSuccess()) return;
String result = baos.toString(NetworkUtils.getCharsetFromContentType(connection.getContentType()));
Charset charset = StandardCharsets.UTF_8;
if (response != null)
charset = NetworkUtils.getCharsetFromContentType(response.headers().firstValue("content-type").orElse(null));
String result = baos.toString(charset);
setResult(result);
if (checkETag) {
repository.cacheText(connection, result);
repository.cacheText(response, result);
}
}
};
@@ -18,61 +18,103 @@
package org.jackhuang.hmcl.task;
import javafx.application.Platform;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.concurrent.*;
import java.util.function.Function;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/**
*
* @author huangyuhui
*/
/// @author huangyuhui
public final class Schedulers {
private Schedulers() {
}
private static volatile ExecutorService IO_EXECUTOR;
private static final @Nullable Function<String, ExecutorService> NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR;
/**
* Get singleton instance of the thread pool for I/O operations,
* usually for reading files from disk, or Internet connections.
*
* This thread pool has no more than 4 threads, and number of threads will get
* reduced if concurrency is less than thread number.
*
* @return Thread pool for I/O operations.
*/
public static ExecutorService io() {
if (IO_EXECUTOR == null) {
synchronized (Schedulers.class) {
if (IO_EXECUTOR == null) {
IO_EXECUTOR = threadPool("IO", true, 4, 10, TimeUnit.SECONDS);
}
static {
if (Runtime.version().feature() >= 21) {
try {
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
Class<?> vtBuilderCls = Class.forName("java.lang.Thread$Builder$OfVirtual");
MethodHandle ofVirtualHandle = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(vtBuilderCls));
MethodHandle setNameHandle = lookup.findVirtual(vtBuilderCls, "name", MethodType.methodType(vtBuilderCls, String.class, long.class));
MethodHandle toFactoryHandle = lookup.findVirtual(vtBuilderCls, "factory", MethodType.methodType(ThreadFactory.class));
MethodHandle newThreadPerTaskExecutorFactory = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class));
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = name -> {
try {
Object virtualThreadBuilder = ofVirtualHandle.invoke();
setNameHandle.invoke(virtualThreadBuilder, name, 1L);
ThreadFactory threadFactory = (ThreadFactory) toFactoryHandle.invoke(virtualThreadBuilder);
return (ExecutorService) newThreadPerTaskExecutorFactory.invokeExact(threadFactory);
} catch (Throwable e) {
throw new AssertionError("Unreachable", e);
}
};
} catch (Throwable e) {
throw new AssertionError("Unreachable", e);
}
} else {
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = null;
}
}
/// @return Returns null if the Java version is below 21, otherwise always returns a non-null value.
public static ExecutorService newVirtualThreadPerTaskExecutor(String name) {
if (NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR == null) {
return null;
}
return IO_EXECUTOR;
return NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR.apply(name);
}
/// This thread pool is suitable for network and local I/O operations.
///
/// For Java 21 or later, all tasks will be dispatched to virtual threads.
///
/// @return Thread pool for I/O operations.
public static ExecutorService io() {
return Holder.IO_EXECUTOR;
}
public static Executor javafx() {
return Platform::runLater;
}
/// Default thread pool, equivalent to [ForkJoinPool#commonPool()].
///
/// It is recommended to perform computation tasks on this thread pool. For I/O operations, please use [#io()].
public static Executor defaultScheduler() {
return ForkJoinPool.commonPool();
}
public static synchronized void shutdown() {
public static void shutdown() {
LOG.info("Shutting down executor services.");
// shutdownNow will interrupt all threads.
// So when we want to close the app, no threads need to be waited for finish.
// Sometimes it resolves the problem that the app does not exit.
}
if (IO_EXECUTOR != null)
IO_EXECUTOR.shutdownNow();
private static final class Holder {
private static final ExecutorService IO_EXECUTOR;
static {
//noinspection resource
ExecutorService vtExecutor = newVirtualThreadPerTaskExecutor("IO");
IO_EXECUTOR = vtExecutor != null
? vtExecutor
: Executors.newCachedThreadPool(Lang.counterThreadFactory("IO", true));
}
}
}
@@ -23,12 +23,13 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
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.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLConnection;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
@@ -184,15 +185,35 @@ public class CacheRepository {
}
}
public void injectConnection(HttpURLConnection conn) {
conn.setUseCaches(true);
URI uri;
public @NotNull Map<String, String> injectConnection(URI uri) {
try {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(conn.getURL()));
uri = NetworkUtils.dropQuery(uri);
} catch (IllegalArgumentException e) {
return Map.of();
}
ETagItem eTagItem;
lock.readLock().lock();
try {
eTagItem = index.get(uri);
} finally {
lock.readLock().unlock();
}
if (eTagItem == null) return Map.of();
if (eTagItem.eTag != null)
return Map.of("if-none-match", eTagItem.eTag);
// if (eTagItem.getRemoteLastModified() != null)
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
return Map.of();
}
public void injectConnection(URI uri, HttpRequest.Builder requestBuilder) {
try {
uri = NetworkUtils.dropQuery(uri);
} catch (IllegalArgumentException e) {
return;
}
ETagItem eTagItem;
lock.readLock().lock();
try {
@@ -202,25 +223,25 @@ public class CacheRepository {
}
if (eTagItem == null) return;
if (eTagItem.eTag != null)
conn.setRequestProperty("If-None-Match", eTagItem.eTag);
requestBuilder.header("if-none-match", eTagItem.eTag);
// if (eTagItem.getRemoteLastModified() != null)
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
}
public Path cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException {
return cacheData(connection, () -> {
public Path cacheRemoteFile(HttpResponse<?> response, Path downloaded) throws IOException {
return cacheData(response, () -> {
String hash = DigestUtils.digestToString(SHA1, downloaded);
Path cached = cacheFile(downloaded, SHA1, hash);
return new CacheResult(hash, cached);
});
}
public Path cacheText(URLConnection connection, String text) throws IOException {
return cacheBytes(connection, text.getBytes(UTF_8));
public Path cacheText(HttpResponse<?> response, String text) throws IOException {
return cacheBytes(response, text.getBytes(UTF_8));
}
public Path cacheBytes(URLConnection connection, byte[] bytes) throws IOException {
return cacheData(connection, () -> {
public Path cacheBytes(HttpResponse<?> response, byte[] bytes) throws IOException {
return cacheData(response, () -> {
String hash = DigestUtils.digestToString(SHA1, bytes);
Path cached = getFile(SHA1, hash);
Files.createDirectories(cached.getParent());
@@ -231,20 +252,15 @@ public class CacheRepository {
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = connection.getHeaderField("ETag");
private Path cacheData(HttpResponse<?> response, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = response.headers().firstValue("etag").orElse(null);
if (StringUtils.isBlank(eTag)) return null;
URI uri;
try {
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(connection.getURL()));
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
URI uri = NetworkUtils.dropQuery(response.uri());
long expires = 0L;
expires:
try {
String cacheControl = connection.getHeaderField("Cache-Control");
String cacheControl = response.headers().firstValue("cache-control").orElse(null);
if (StringUtils.isNotBlank(cacheControl)) {
if (cacheControl.contains("no-store"))
return null;
@@ -257,7 +273,7 @@ public class CacheRepository {
}
}
String expiresHeader = connection.getHeaderField("Expires");
String expiresHeader = response.headers().firstValue("expires").orElse(null);
if (StringUtils.isNotBlank(expiresHeader)) {
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant().toEpochMilli();
@@ -266,7 +282,7 @@ public class CacheRepository {
LOG.warning("Failed to parse expires time", e);
}
String lastModified = connection.getHeaderField("Last-Modified");
String lastModified = response.headers().firstValue("last-modified").orElse(null);
CacheResult cacheResult = cacheSupplier.get();
ETagItem eTagItem = new ETagItem(uri.toString(),
@@ -21,15 +21,14 @@ import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* When {@link #accept(T)} is called, this class invokes the handler on another thread.
* If {@link #accept(T)} is called more than one time before the handler starts processing,
* the handler will only be invoked once, taking the latest argument as its input.
*
* @author yushijinhun
*/
/// When [#accept(T)] is called, this class invokes the handler on another thread.
/// If [#accept(T)] is called more than one time before the handler starts processing,
/// the handler will only be invoked once, taking the latest argument as its input.
///
/// @author yushijinhun
public final class InvocationDispatcher<T> implements Consumer<T> {
/// @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);
}
@@ -47,9 +46,10 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
public void accept(T t) {
if (pendingArg.getAndSet(new Holder<>(t)) == null) {
executor.execute(() -> {
synchronized (InvocationDispatcher.this) {
action.accept(pendingArg.getAndSet(null).value);
}
// 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);
});
}
}
@@ -236,14 +236,22 @@ public final class Lang {
}
public static ThreadPoolExecutor threadPool(String name, boolean daemon, int threads, long timeout, TimeUnit timeunit) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
threads, threads,
timeout, timeunit,
new LinkedBlockingQueue<>(),
counterThreadFactory(name, daemon));
pool.allowCoreThreadTimeOut(true);
return pool;
}
public static ThreadFactory counterThreadFactory(String name, boolean daemon) {
AtomicInteger counter = new AtomicInteger(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, timeout, timeunit, new LinkedBlockingQueue<>(), r -> {
return r -> {
Thread t = new Thread(r, name + "-" + counter.getAndIncrement());
t.setDaemon(daemon);
return t;
});
pool.allowCoreThreadTimeOut(true);
return pool;
};
}
public static int parseInt(Object string, int defaultValue) {
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.util.i18n;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
@@ -31,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#toISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] 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
@@ -42,67 +41,15 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control {
public DefaultResourceBundleControl() {
}
private static List<Locale> ensureEditable(List<Locale> list) {
return list instanceof ArrayList<?>
? list
: new ArrayList<>(list);
@Override
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
return LocaleUtils.getCandidateLocales(locale);
}
@Override
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
if (locale.getLanguage().isEmpty())
return getCandidateLocales(baseName, Locale.ENGLISH);
else if (LocaleUtils.isChinese(locale)) {
String script = locale.getScript();
if (script.isEmpty()) {
script = LocaleUtils.getScript(locale);
if (!script.isEmpty())
return getCandidateLocales(baseName, new Locale.Builder()
.setLocale(locale)
.setScript(script)
.build());
}
}
String language = locale.getLanguage();
List<Locale> locales = super.getCandidateLocales(baseName, locale);
// Is ISO 639-3 language tag
if (language.length() == 3) {
String iso1 = LocaleUtils.toISO1Language(locale.getLanguage());
if (iso1.length() == 2) {
locales = ensureEditable(locales);
locales.removeIf(it -> !it.getLanguage().equals(language));
locales.addAll(getCandidateLocales(baseName, new Locale.Builder()
.setLocale(locale)
.setLanguage(iso1)
.build()));
}
} else if (language.equals("zh")) {
if (locales.contains(LocaleUtils.LOCALE_ZH_HANT) && !locales.contains(Locale.TRADITIONAL_CHINESE)) {
locales = ensureEditable(locales);
int chineseIdx = locales.indexOf(Locale.CHINESE);
if (chineseIdx >= 0)
locales.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
}
if (!locales.contains(Locale.SIMPLIFIED_CHINESE)) {
int chineseIdx = locales.indexOf(Locale.CHINESE);
if (chineseIdx >= 0) {
locales = ensureEditable(locales);
if (locales.contains(LocaleUtils.LOCALE_ZH_HANS))
locales.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
else
locales.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
}
}
}
return locales;
public Locale getFallbackLocale(String baseName, Locale locale) {
// By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup.
// Since we always use the base bundle as the English resource file, we want to suppress this behavior.
return null;
}
}
@@ -23,9 +23,13 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -42,6 +46,66 @@ public final class LocaleUtils {
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<>();
static {
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")) {
if (input != null) {
new String(input.readAllBytes()).lines()
.filter(line -> !line.startsWith("#") && !line.isBlank())
.forEach(line -> {
String[] languages = line.split(",");
if (languages.length < 2)
LOG.warning("Invalid line in sublanguages.csv: " + line);
String parent = languages[0];
for (int i = 1; i < languages.length; i++) {
subLanguageToParent.put(languages[i], parent);
}
});
}
} catch (Throwable e) {
LOG.warning("Failed to load sublanguages.csv", e);
}
// Line Format:
// (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")) {
if (input != null) {
int lineLength = 2 + 1 + 3;
byte[] bytes = input.readAllBytes();
for (int offset = 0; offset < bytes.length; ) {
if (offset > bytes.length - lineLength)
break;
if (bytes[offset + 2] != ',')
throw new IOException("iso_languages.csv format invalid");
String iso2 = new String(bytes, offset, 2, StandardCharsets.US_ASCII);
String iso3 = new String(bytes, offset + 3, 3, StandardCharsets.US_ASCII);
iso3To2.put(iso3, iso2);
offset += (lineLength + 1);
}
}
} catch (Throwable e) {
LOG.warning("Failed to load iso_languages.csv", e);
}
}
private static Locale getInstance(String language, String script, String region,
String variant) {
Locale.Builder builder = new Locale.Builder();
if (!language.isEmpty()) builder.setLanguage(language);
if (!script.isEmpty()) builder.setScript(script);
if (!region.isEmpty()) builder.setRegion(region);
if (!variant.isEmpty()) builder.setVariant(variant);
return builder.build();
}
/// Convert a locale to the language key.
///
/// The language key is in the format of BCP 47 language tag.
@@ -52,24 +116,38 @@ public final class LocaleUtils {
: locale.stripExtensions().toLanguageTag();
}
public static boolean isISO1Language(String language) {
return language.length() == 2;
public static @NotNull String getRootLanguage(Locale locale) {
return getRootLanguage(locale.getLanguage());
}
public static boolean isISO3Language(String language) {
return language.length() == 3;
}
public static @NotNull String getISO1Language(Locale locale) {
String language = locale.getLanguage();
/// - If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
/// return the macrolanguage;
/// - If `language` is an ISO 639 alpha-3 language code and there is a corresponding ISO 639 alpha-2 language code, return the ISO 639 alpha-2 code;
/// - If `language` is empty, return `en`;
/// - Otherwise, return the `language`.
public static @NotNull String getRootLanguage(String language) {
if (language.isEmpty()) return "en";
return isISO3Language(language) ? toISO1Language(language) : language;
if (language.length() <= 2)
return language;
String iso2 = mapToISO2Language(language);
if (iso2 != null)
return iso2;
String parent = getParentLanguage(language);
return parent != null ? parent : 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 (isChinese(locale)) {
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
return "Latn";
@@ -83,10 +161,94 @@ public final class LocaleUtils {
return locale.getScript();
}
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale);
return CANDIDATE_LOCALES.computeIfAbsent(locale, LocaleUtils::createCandidateLocaleList);
}
// -------------
private static List<Locale> createCandidateLocaleList(Locale locale) {
String language = locale.getLanguage();
if (language.isEmpty())
return List.of(Locale.ENGLISH, Locale.ROOT);
String script = getScript(locale);
String region = locale.getCountry();
List<String> variants = locale.getVariant().isEmpty()
? List.of()
: List.of(locale.getVariant().split("[_\\-]"));
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);
} while ((language = getParentLanguage(language)) != null);
result.add(Locale.ROOT);
return List.copyOf(result);
}
private static void addCandidateLocales(ArrayList<Locale> list,
String language,
String script,
String region,
List<String> variants) {
if (!variants.isEmpty()) {
for (String v : variants) {
list.add(getInstance(language, script, region, v));
}
}
if (!region.isEmpty()) {
list.add(getInstance(language, script, region, ""));
}
if (!script.isEmpty()) {
list.add(getInstance(language, script, "", ""));
if (!variants.isEmpty()) {
for (String v : variants) {
list.add(getInstance(language, "", region, v));
}
}
if (!region.isEmpty()) {
list.add(getInstance(language, "", region, ""));
}
}
list.add(getInstance(language, "", "", ""));
if (language.equals("zh")) {
if (list.contains(LocaleUtils.LOCALE_ZH_HANT) && !list.contains(Locale.TRADITIONAL_CHINESE)) {
int chineseIdx = list.indexOf(Locale.CHINESE);
if (chineseIdx >= 0)
list.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
}
if (!list.contains(Locale.SIMPLIFIED_CHINESE)) {
int chineseIdx = list.indexOf(Locale.CHINESE);
if (chineseIdx >= 0) {
if (list.contains(LocaleUtils.LOCALE_ZH_HANS))
list.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
else
list.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
}
}
}
}
// -------------
public static <T> @Nullable T getByCandidateLocales(Map<String, T> map, List<Locale> candidateLocales) {
for (Locale locale : candidateLocales) {
String key = toLanguageKey(locale);
@@ -178,29 +340,27 @@ public final class LocaleUtils {
// ---
/// Try to convert ISO 639-3 language codes to ISO 639-1 language codes.
public static String toISO1Language(String languageTag) {
return switch (languageTag) {
case "eng" -> "en";
case "spa" -> "es";
case "jpa" -> "ja";
case "rus" -> "ru";
case "ukr" -> "uk";
case "zho", "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh";
default -> languageTag;
};
/// 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(getISO1Language(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(getISO1Language(locale));
return "zh".equals(getRootLanguage(locale));
}
private LocaleUtils() {
@@ -22,6 +22,7 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.util.zip.GZIPInputStream;
/**
@@ -52,5 +53,16 @@ public enum ContentEncoding {
}
}
public static @NotNull ContentEncoding fromResponse(HttpResponse<?> connection) throws IOException {
String encoding = connection.headers().firstValue("content-encoding").orElse("");
if (encoding.isEmpty() || "identity".equals(encoding)) {
return IDENTITY;
} else if ("gzip".equalsIgnoreCase(encoding)) {
return GZIP;
} else {
throw new IOException("Unsupported content encoding: " + encoding);
}
}
public abstract InputStream wrap(InputStream inputStream) throws IOException;
}
@@ -25,6 +25,7 @@ import javafx.beans.value.ObservableValue;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -144,6 +145,7 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
private static final class AsyncMappedBinding<T, U> extends BindingMapping<T, U> {
private final ReentrantLock lock = new ReentrantLock();
private boolean initialized = false;
private T prev;
private U value;
@@ -159,13 +161,16 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
private void tryUpdateValue(T currentPrev) {
synchronized (this) {
lock.lock();
try {
if ((initialized && Objects.equals(prev, currentPrev))
|| isComputing(currentPrev)) {
return;
}
computing = true;
computingPrev = currentPrev;
} finally {
lock.unlock();
}
CompletableFuture<? extends U> task;
@@ -189,7 +194,8 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
private void valueUpdate(T currentPrev, U computed) {
synchronized (this) {
lock.lock();
try {
if (isComputing(currentPrev)) {
computing = false;
computingPrev = null;
@@ -197,15 +203,20 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
value = computed;
initialized = true;
}
} finally {
lock.unlock();
}
}
private void valueUpdateFailed(T currentPrev) {
synchronized (this) {
lock.lock();
try {
if (isComputing(currentPrev)) {
computing = false;
computingPrev = null;
}
} finally {
lock.unlock();
}
}
@@ -24,6 +24,7 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
@@ -37,6 +38,7 @@ import javafx.beans.binding.ObjectBinding;
*/
public final class ObservableCache<K, V, E extends Exception> {
private final ReentrantLock lock = new ReentrantLock();
private final ExceptionalFunction<K, V, E> source;
private final BiConsumer<K, Throwable> exceptionHandler;
private final V fallbackValue;
@@ -54,22 +56,29 @@ public final class ObservableCache<K, V, E extends Exception> {
}
public Optional<V> getImmediately(K key) {
synchronized (this) {
lock.lock();
try {
return Optional.ofNullable(cache.get(key));
} finally {
lock.unlock();
}
}
public void put(K key, V value) {
synchronized (this) {
lock.lock();
try {
cache.put(key, value);
invalidated.remove(key);
} finally {
lock.unlock();
}
Platform.runLater(observable::invalidate);
}
private CompletableFuture<V> query(K key, Executor executor) {
CompletableFuture<V> future;
synchronized (this) {
lock.lock();
try {
CompletableFuture<V> prev = pendings.get(key);
if (prev != null) {
return prev;
@@ -77,6 +86,8 @@ public final class ObservableCache<K, V, E extends Exception> {
future = new CompletableFuture<>();
pendings.put(key, future);
}
} finally {
lock.unlock();
}
executor.execute(() -> {
@@ -84,18 +95,24 @@ public final class ObservableCache<K, V, E extends Exception> {
try {
result = source.apply(key);
} catch (Throwable ex) {
synchronized (this) {
lock.lock();
try {
pendings.remove(key);
} finally {
lock.unlock();
}
exceptionHandler.accept(key, ex);
future.completeExceptionally(ex);
return;
}
synchronized (this) {
lock.lock();
try {
cache.put(key, result);
invalidated.remove(key);
pendings.remove(key, future);
} finally {
lock.unlock();
}
future.complete(result);
Platform.runLater(observable::invalidate);
@@ -106,11 +123,14 @@ public final class ObservableCache<K, V, E extends Exception> {
public V get(K key) {
V cached;
synchronized (this) {
lock.lock();
try {
cached = cache.get(key);
if (cached != null && !invalidated.containsKey(key)) {
return cached;
}
} finally {
lock.unlock();
}
try {
@@ -143,7 +163,9 @@ public final class ObservableCache<K, V, E extends Exception> {
return Bindings.createObjectBinding(() -> {
V result;
boolean refresh;
synchronized (this) {
lock.lock();
try {
result = cache.get(key);
if (result == null) {
result = fallbackValue;
@@ -151,6 +173,8 @@ public final class ObservableCache<K, V, E extends Exception> {
} else {
refresh = invalidated.containsKey(key);
}
} finally {
lock.unlock();
}
if (!quiet && refresh) {
query(key, executor);
@@ -160,10 +184,13 @@ public final class ObservableCache<K, V, E extends Exception> {
}
public void invalidate(K key) {
synchronized (this) {
lock.lock();
try {
if (cache.containsKey(key)) {
invalidated.put(key, Boolean.TRUE);
}
} finally {
lock.unlock();
}
Platform.runLater(observable::invalidate);
}
@@ -47,6 +47,9 @@ public final class Logger {
private static volatile String[] accessTokens = new String[0];
public static synchronized void registerAccessToken(String token) {
if (token == null || token.length() <= 1)
return;
final String[] oldAccessTokens = accessTokens;
final String[] newAccessTokens = Arrays.copyOf(oldAccessTokens, oldAccessTokens.length + 1);
@@ -22,17 +22,17 @@ import org.jackhuang.hmcl.util.Lang;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Predicate;
/**
* The managed process.
*
* @author huangyuhui
* @see org.jackhuang.hmcl.launch.ExitWaiter
* @see org.jackhuang.hmcl.launch.StreamPump
*/
/// The managed process.
///
/// @author huangyuhui
/// <!-- @see org.jackhuang.hmcl.launch.ExitWaiter -->
/// @see org.jackhuang.hmcl.launch.StreamPump
public final class ManagedProcess {
private final ReentrantLock lock = new ReentrantLock();
private final Process process;
private final List<String> commands;
private final String classpath;
@@ -54,7 +54,7 @@ public final class ManagedProcess {
*/
public ManagedProcess(Process process, List<String> commands) {
this.process = process;
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
this.commands = List.copyOf(commands);
this.classpath = null;
}
@@ -67,7 +67,7 @@ public final class ManagedProcess {
*/
public ManagedProcess(Process process, List<String> commands, String classpath) {
this.process = process;
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
this.commands = List.copyOf(commands);
this.classpath = classpath;
}
@@ -111,9 +111,11 @@ public final class ManagedProcess {
*
* @see #addLine
*/
public synchronized List<String> getLines(Predicate<String> lineFilter) {
public List<String> getLines(Predicate<String> lineFilter) {
lock.lock();
if (lineFilter == null)
return Collections.unmodifiableList(Arrays.asList(lines.toArray(new String[0])));
return List.copyOf(lines);
ArrayList<String> res = new ArrayList<>();
for (String line : this.lines) {
@@ -123,8 +125,13 @@ public final class ManagedProcess {
return Collections.unmodifiableList(res);
}
public synchronized void addLine(String line) {
lines.add(line);
public void addLine(String line) {
lock.lock();
try {
lines.add(line);
} finally {
lock.unlock();
}
}
/**
@@ -133,15 +140,20 @@ public final class ManagedProcess {
* If a thread is monitoring this raw process,
* you are required to add the instance by this method.
*/
public synchronized void addRelatedThread(Thread thread) {
relatedThreads.add(thread);
public void addRelatedThread(Thread thread) {
lock.lock();
try {
relatedThreads.add(thread);
} finally {
lock.unlock();
}
}
public synchronized void pumpInputStream(Consumer<String> onLogLine) {
public void pumpInputStream(Consumer<String> onLogLine) {
addRelatedThread(Lang.thread(new StreamPump(process.getInputStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessInputStreamPump", true));
}
public synchronized void pumpErrorStream(Consumer<String> onLogLine) {
public void pumpErrorStream(Consumer<String> onLogLine) {
addRelatedThread(Lang.thread(new StreamPump(process.getErrorStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessErrorStreamPump", true));
}
@@ -172,8 +184,13 @@ public final class ManagedProcess {
destroyRelatedThreads();
}
public synchronized void destroyRelatedThreads() {
relatedThreads.forEach(Thread::interrupt);
public void destroyRelatedThreads() {
lock.lock();
try {
relatedThreads.forEach(Thread::interrupt);
} finally {
lock.unlock();
}
}
@Override
@@ -24,15 +24,20 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import java.io.Closeable;
import java.io.FileNotFoundException;
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.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.util.*;
/**
* @author Glavo
*/
public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Closeable {
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Closeable {
public static ArchiveFileTree<?, ?> open(Path file) throws IOException {
Path namePath = file.getFileName();
@@ -50,23 +55,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
}
}
protected final F file;
protected final Dir<E> root = new Dir<>("");
protected final R reader;
protected final Dir<E> root = new Dir<>("", "");
public ArchiveFileTree(F file) {
this.file = file;
public ArchiveFileTree(R reader) {
this.reader = reader;
}
public F getFile() {
return file;
public R getReader() {
return reader;
}
public Dir<E> getRoot() {
return root;
}
public @Nullable E getEntry(@NotNull String entryPath) {
Dir<E> dir = root;
String fileName;
if (entryPath.indexOf('/') < 0) {
fileName = entryPath;
if (fileName.isEmpty())
return root.getEntry();
} else {
String[] path = entryPath.split("/");
if (path.length == 0)
return root.getEntry();
for (int i = 0; i < path.length - 1; i++) {
String item = path[i];
if (item.isEmpty())
continue;
dir = dir.getSubDirs().get(item);
if (dir == null)
return null;
}
fileName = path[path.length - 1];
E entry = dir.getFiles().get(fileName);
if (entry != null)
return entry;
}
Dir<E> subDir = dir.getSubDirs().get(fileName);
return subDir != null ? subDir.getEntry() : null;
}
protected void addEntry(E entry) throws IOException {
String[] path = entry.getName().split("/");
List<String> pathList = Arrays.asList(path);
Dir<E> dir = root;
@@ -81,7 +118,9 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
throw new IOException("A file and a directory have the same name: " + entry.getName());
}
dir = dir.subDirs.computeIfAbsent(item, Dir::new);
final int nameEnd = i + 1;
dir = dir.subDirs.computeIfAbsent(item, name ->
new Dir<>(name, String.join("/", pathList.subList(0, nameEnd))));
}
if (entry.isDirectory()) {
@@ -106,6 +145,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
public abstract InputStream getInputStream(E entry) throws IOException;
public @NotNull InputStream getInputStream(String entryPath) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
return getInputStream(entry);
}
public byte[] readBinaryEntry(@NotNull E entry) throws IOException {
try (InputStream input = getInputStream(entry)) {
return input.readAllBytes();
}
}
public String readTextEntry(@NotNull String entryPath) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
return readTextEntry(entry);
}
public String readTextEntry(@NotNull E entry) throws IOException {
return new String(readBinaryEntry(entry), StandardCharsets.UTF_8);
}
protected void copyAttributes(@NotNull E source, @NotNull Path targetFile) throws IOException {
FileTime lastModifiedTime = source.getLastModifiedTime();
if (lastModifiedTime != null)
Files.setLastModifiedTime(targetFile, lastModifiedTime);
}
public void extractTo(@NotNull String entryPath, @NotNull Path targetFile) throws IOException {
E entry = getEntry(entryPath);
if (entry == null)
throw new FileNotFoundException("Entry not found: " + entryPath);
extractTo(entry, targetFile);
}
public void extractTo(@NotNull E entry, @NotNull Path targetFile) throws IOException {
try (InputStream input = getInputStream(entry)) {
Files.copy(input, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
try {
copyAttributes(entry, targetFile);
} catch (Throwable e) {
LOG.warning("Failed to copy attributes to " + targetFile, e);
}
}
public abstract boolean isLink(E entry);
public abstract String getLink(E entry) throws IOException;
@@ -117,13 +205,15 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
public static final class Dir<E extends ArchiveEntry> {
private final String name;
private final String fullName;
private E entry;
final Map<String, Dir<E>> subDirs = new HashMap<>();
final Map<String, E> files = new HashMap<>();
public Dir(String name) {
public Dir(String name, String fullName) {
this.name = name;
this.fullName = fullName;
}
public boolean isRoot() {
@@ -134,6 +224,11 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
return name;
}
/// Get the normalized full path. Leading `/` and all `.` in the path will be removed.
public @NotNull String getFullName() {
return fullName;
}
public @Nullable E getEntry() {
return entry;
}
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl.util.tree;
import kala.compress.archivers.tar.TarArchiveEntry;
import kala.compress.archivers.tar.TarArchiveReader;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.util.zip.GZIPInputStream;
/**
@@ -98,9 +100,22 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
this.shutdownHook = null;
}
@Override
protected void copyAttributes(@NotNull TarArchiveEntry source, @NotNull Path targetFile) throws IOException {
var fileAttributeView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
if (fileAttributeView == null)
return;
fileAttributeView.setTimes(
source.getLastModifiedTime(),
source.getLastAccessTime(),
source.getCreationTime()
);
}
@Override
public InputStream getInputStream(TarArchiveEntry entry) throws IOException {
return file.getInputStream(entry);
return reader.getInputStream(entry);
}
@Override
@@ -121,7 +136,7 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
@Override
public void close() throws IOException {
try {
file.close();
reader.close();
} finally {
if (tempFile != null) {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
@@ -20,9 +20,17 @@ package org.jackhuang.hmcl.util.tree;
import kala.compress.archivers.zip.ZipArchiveEntry;
import kala.compress.archivers.zip.ZipArchiveReader;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Set;
/**
* @author Glavo
@@ -51,12 +59,53 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
@Override
public void close() throws IOException {
if (closeReader)
file.close();
reader.close();
}
@Override
@SuppressWarnings("OctalInteger")
protected void copyAttributes(@NotNull ZipArchiveEntry source, @NotNull Path targetFile) throws IOException {
BasicFileAttributeView targetView = Files.getFileAttributeView(targetFile, PosixFileAttributeView.class);
// target might not support posix even if source does
if (targetView == null)
targetView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
if (targetView == null)
return;
targetView.setTimes(
source.getLastModifiedTime(),
source.getLastAccessTime(),
source.getCreationTime()
);
int unixMode = source.getUnixMode();
if (unixMode != 0 && targetView instanceof PosixFileAttributeView posixView) {
Set<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
// Owner permissions
if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ);
if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE);
if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE);
// Group permissions
if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ);
if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE);
if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE);
// Others permissions
if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ);
if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE);
if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE);
posixView.setPermissions(permissions);
}
}
@Override
public InputStream getInputStream(ZipArchiveEntry entry) throws IOException {
return getFile().getInputStream(entry);
return getReader().getInputStream(entry);
}
@Override
@@ -66,7 +115,7 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
@Override
public String getLink(ZipArchiveEntry entry) throws IOException {
return getFile().getUnixSymlink(entry);
return getReader().getUnixSymlink(entry);
}
@Override
@@ -831,4 +831,19 @@
1.21.7-rc2
1.21.7
1.21.8-rc1
1.21.8
1.21.8
25w31a
25w32a
25w33a
25w34a
25w34b
25w35a
25w36a
25w36b
25w37a
1.21.9-pre1
1.21.9-pre2
1.21.9-pre3
1.21.9-pre4
1.21.9-rc1
1.21.9
@@ -0,0 +1,187 @@
aa,aar
ab,abk
ae,ave
af,afr
ak,aka
am,amh
an,arg
ar,ara
as,asm
av,ava
ay,aym
az,aze
ba,bak
be,bel
bg,bul
bh,bih
bi,bis
bm,bam
bn,ben
bo,bod
br,bre
bs,bos
ca,cat
ce,che
ch,cha
co,cos
cr,cre
cs,ces
cu,chu
cv,chv
cy,cym
da,dan
de,deu
dv,div
dz,dzo
ee,ewe
el,ell
en,eng
eo,epo
es,spa
et,est
eu,eus
fa,fas
ff,ful
fi,fin
fj,fij
fo,fao
fr,fra
fy,fry
ga,gle
gd,gla
gl,glg
gn,grn
gu,guj
gv,glv
ha,hau
he,heb
hi,hin
ho,hmo
hr,hrv
ht,hat
hu,hun
hy,hye
hz,her
ia,ina
id,ind
ie,ile
ig,ibo
ii,iii
ik,ipk
in,ind
io,ido
is,isl
it,ita
iu,iku
iw,heb
ja,jpn
ji,yid
jv,jav
ka,kat
kg,kon
ki,kik
kj,kua
kk,kaz
kl,kal
km,khm
kn,kan
ko,kor
kr,kau
ks,kas
ku,kur
kv,kom
kw,cor
ky,kir
la,lat
lb,ltz
lg,lug
li,lim
ln,lin
lo,lao
lt,lit
lu,lub
lv,lav
mg,mlg
mh,mah
mi,mri
mk,mkd
ml,mal
mn,mon
mo,mol
mr,mar
ms,msa
mt,mlt
my,mya
na,nau
nb,nob
nd,nde
ne,nep
ng,ndo
nl,nld
nn,nno
no,nor
nr,nbl
nv,nav
ny,nya
oc,oci
oj,oji
om,orm
or,ori
os,oss
pa,pan
pi,pli
pl,pol
ps,pus
pt,por
qu,que
rm,roh
rn,run
ro,ron
ru,rus
rw,kin
sa,san
sc,srd
sd,snd
se,sme
sg,sag
si,sin
sk,slk
sl,slv
sm,smo
sn,sna
so,som
sq,sqi
sr,srp
ss,ssw
st,sot
su,sun
sv,swe
sw,swa
ta,tam
te,tel
tg,tgk
th,tha
ti,tir
tk,tuk
tl,tgl
tn,tsn
to,ton
tr,tur
ts,tso
tt,tat
tw,twi
ty,tah
ug,uig
uk,ukr
ur,urd
uz,uzb
ve,ven
vi,vie
vo,vol
wa,wln
wo,wol
xh,xho
yi,yid
za,zha
zh,zho
zu,zul
1 aa aar
2 ab abk
3 ae ave
4 af afr
5 ak aka
6 am amh
7 an arg
8 ar ara
9 as asm
10 av ava
11 ay aym
12 az aze
13 ba bak
14 be bel
15 bg bul
16 bh bih
17 bi bis
18 bm bam
19 bn ben
20 bo bod
21 br bre
22 bs bos
23 ca cat
24 ce che
25 ch cha
26 co cos
27 cr cre
28 cs ces
29 cu chu
30 cv chv
31 cy cym
32 da dan
33 de deu
34 dv div
35 dz dzo
36 ee ewe
37 el ell
38 en eng
39 eo epo
40 es spa
41 et est
42 eu eus
43 fa fas
44 ff ful
45 fi fin
46 fj fij
47 fo fao
48 fr fra
49 fy fry
50 ga gle
51 gd gla
52 gl glg
53 gn grn
54 gu guj
55 gv glv
56 ha hau
57 he heb
58 hi hin
59 ho hmo
60 hr hrv
61 ht hat
62 hu hun
63 hy hye
64 hz her
65 ia ina
66 id ind
67 ie ile
68 ig ibo
69 ii iii
70 ik ipk
71 in ind
72 io ido
73 is isl
74 it ita
75 iu iku
76 iw heb
77 ja jpn
78 ji yid
79 jv jav
80 ka kat
81 kg kon
82 ki kik
83 kj kua
84 kk kaz
85 kl kal
86 km khm
87 kn kan
88 ko kor
89 kr kau
90 ks kas
91 ku kur
92 kv kom
93 kw cor
94 ky kir
95 la lat
96 lb ltz
97 lg lug
98 li lim
99 ln lin
100 lo lao
101 lt lit
102 lu lub
103 lv lav
104 mg mlg
105 mh mah
106 mi mri
107 mk mkd
108 ml mal
109 mn mon
110 mo mol
111 mr mar
112 ms msa
113 mt mlt
114 my mya
115 na nau
116 nb nob
117 nd nde
118 ne nep
119 ng ndo
120 nl nld
121 nn nno
122 no nor
123 nr nbl
124 nv nav
125 ny nya
126 oc oci
127 oj oji
128 om orm
129 or ori
130 os oss
131 pa pan
132 pi pli
133 pl pol
134 ps pus
135 pt por
136 qu que
137 rm roh
138 rn run
139 ro ron
140 ru rus
141 rw kin
142 sa san
143 sc srd
144 sd snd
145 se sme
146 sg sag
147 si sin
148 sk slk
149 sl slv
150 sm smo
151 sn sna
152 so som
153 sq sqi
154 sr srp
155 ss ssw
156 st sot
157 su sun
158 sv swe
159 sw swa
160 ta tam
161 te tel
162 tg tgk
163 th tha
164 ti tir
165 tk tuk
166 tl tgl
167 tn tsn
168 to ton
169 tr tur
170 ts tso
171 tt tat
172 tw twi
173 ty tah
174 ug uig
175 uk ukr
176 ur urd
177 uz uzb
178 ve ven
179 vi vie
180 vo vol
181 wa wln
182 wo wol
183 xh xho
184 yi yid
185 za zha
186 zh zho
187 zu zul
@@ -0,0 +1 @@
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
1 zh cdo cjy cmn cnp cpx csp czh czo gan hak hnm hsn luh lzh mnp nan sjc wuu yue
@@ -0,0 +1,71 @@
aɐ
bq
cɔ
dp
eǝ
fɟ
gᵷ
hɥ
iᴉ
jɾ
kʞ
lꞁ
mɯ
nu
oo
pd
qb
rɹ
ss
tʇ
un
vʌ
wʍ
xx
yʎ
zz
AⱯ
Bᗺ
CƆ
Dᗡ
EƎ
FℲ
G⅁
HH
II
Jſ
Kʞ
LꞀ
MW
NN
OO
PԀ
QὉ
Rᴚ
SS
T⟘
U∩
VΛ
WM
XX
Yʎ
ZZ
00
1Ɩ
2ᘔ
3Ɛ
4ㄣ
5ϛ
69
7ㄥ
88
96
_‾
,'
;⸵
.˙
?¿
!¡
//
\\
',
@@ -62,6 +62,7 @@ public final class LocaleUtilsTest {
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"));
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"));
@@ -70,23 +71,23 @@ public final class LocaleUtilsTest {
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("jpa", List.of("jpa", "ja", "und"));
assertCandidateLocales("jpa-JP", List.of("jpa-JP", "jpa", "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", List.of("eng", "en", "und"));
assertCandidateLocales("eng-US", List.of("eng-US", "eng", "en-US", "en", "und"));
assertCandidateLocales("eng-US", List.of("en-US", "en", "und"));
assertCandidateLocales("es", List.of("es", "und"));
assertCandidateLocales("spa", List.of("spa", "es", "und"));
assertCandidateLocales("spa", List.of("es", "und"));
assertCandidateLocales("ru", List.of("ru", "und"));
assertCandidateLocales("rus", List.of("rus", "ru", "und"));
assertCandidateLocales("rus", List.of("ru", "und"));
assertCandidateLocales("uk", List.of("uk", "und"));
assertCandidateLocales("ukr", List.of("ukr", "uk", "und"));
assertCandidateLocales("ukr", List.of("uk", "und"));
assertCandidateLocales("und", List.of("en", "und"));
}
@@ -189,4 +190,34 @@ public final class LocaleUtilsTest {
LocaleUtils.findAllLocalizedFiles(testDir, "meow", Set.of("json", "toml")));
}
}
@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"));
}
@Test
public void testGetParentLanguage() {
assertEquals("zh", LocaleUtils.getParentLanguage("cmn"));
assertEquals("zh", LocaleUtils.getParentLanguage("yue"));
assertEquals("zh", LocaleUtils.getParentLanguage("lzh"));
assertNull(LocaleUtils.getParentLanguage(""));
assertNull(LocaleUtils.getParentLanguage("en"));
assertNull(LocaleUtils.getParentLanguage("eng"));
assertNull(LocaleUtils.getParentLanguage("zh"));
assertNull(LocaleUtils.getParentLanguage("zho"));
}
}
+20 -2
View File
@@ -1,3 +1,4 @@
import org.jackhuang.hmcl.gradle.ci.CheckUpdate
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
plugins {
@@ -20,7 +21,14 @@ subprojects {
name = "libs"
dirs = setOf(rootProject.file("lib"))
}
mavenCentral()
System.getenv("MAVEN_CENTRAL_REPO").let { repo ->
if (repo.isNullOrBlank())
mavenCentral()
else
maven(url = repo)
}
maven(url = "https://jitpack.io")
maven(url = "https://libraries.minecraft.net")
}
@@ -66,4 +74,14 @@ defaultTasks("clean", "build")
tasks.register<UpdateDocuments>("updateDocuments") {
documentsDir.set(layout.projectDirectory.dir("docs"))
}
}
tasks.register<CheckUpdate>("checkUpdateDev") {
tagPrefix.set("v")
uri.set("https://ci.huangyuhui.net/job/HMCL")
}
tasks.register<CheckUpdate>("checkUpdateStable") {
tagPrefix.set("release-")
uri.set("https://ci.huangyuhui.net/job/HMCL-stable")
}
+13 -2
View File
@@ -1,5 +1,10 @@
repositories {
mavenCentral()
System.getenv("MAVEN_CENTRAL_REPO").let { repo ->
if (repo.isNullOrBlank())
mavenCentral()
else
maven(url = repo)
}
}
dependencies {
@@ -10,4 +15,10 @@ dependencies {
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
tasks.processResources {
into("org/jackhuang/hmcl/gradle/l10n") {
from(projectDir.resolve("../HMCLCore/src/main/resources/assets/lang/"))
}
}
@@ -0,0 +1,211 @@
/*
* 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.ci;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.TaskAction;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// @author Glavo
public abstract class CheckUpdate extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(CheckUpdate.class);
private static final String WORKFLOW_JOB = "org.jenkinsci.plugins.workflow.job.WorkflowJob";
private static final String WORKFLOW_MULTI_BRANCH_PROJECT = "org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject";
private static final String FREE_STYLE_PROJECT = "hudson.model.FreeStyleProject";
@Input
public abstract Property<String> getUri();
@Input
public abstract Property<String> getTagPrefix();
public CheckUpdate() {
getOutputs().upToDateWhen(task -> false);
}
private static URI toURI(String baseUri, String suffix) {
return URI.create(baseUri.endsWith("/")
? baseUri + suffix
: baseUri + "/" + suffix
);
}
@TaskAction
public void run() throws Exception {
String uri = getUri().get();
URI apiUri = toURI(uri, "api/json");
LOGGER.quiet("Fetching metadata from {}", apiUri);
BuildMetadata buildMetadata;
try (var helper = new Helper()) {
JsonObject body = Objects.requireNonNull(helper.fetch(apiUri, JsonObject.class));
String jobType = Objects.requireNonNull(body.getAsJsonPrimitive("_class"), "Missing _class property")
.getAsString();
if (WORKFLOW_MULTI_BRANCH_PROJECT.equals(jobType)) {
Pattern namePattern = Pattern.compile("release%2F3\\.\\d+");
List<BuildMetadata> metadatas = Objects.requireNonNull(helper.gson.fromJson(body.get("jobs"), new TypeToken<List<SubJobInfo>>() {
}), "jobs")
.stream()
.filter(it -> WORKFLOW_JOB.equals(it._class()))
.filter(it -> namePattern.matcher(it.name()).matches())
.filter(it -> !it.color().equals("disabled"))
.map(it -> {
try {
return fetchBuildInfo(helper, toURI(it.url, "lastSuccessfulBuild/api/json"));
} catch (Throwable e) {
throw new GradleException("Failed to retrieve build info from " + it.url(), e);
}
}).sorted(Comparator.comparing(BuildMetadata::timestamp))
.toList();
if (metadatas.isEmpty())
throw new GradleException("Failed to retrieve build metadata from " + apiUri);
buildMetadata = metadatas.get(metadatas.size() - 1);
} else if (WORKFLOW_JOB.equals(jobType) || FREE_STYLE_PROJECT.equals(jobType)) {
buildMetadata = fetchBuildInfo(helper, toURI(uri, "lastSuccessfulBuild/api/json"));
} else {
throw new GradleException("Unsupported job type: " + jobType);
}
}
LOGGER.quiet("Build metadata found: {}", buildMetadata);
String githubEnv = Objects.requireNonNullElse(System.getenv("GITHUB_ENV"), "");
if (githubEnv.isBlank())
LOGGER.warn("GITHUB_ENV is not set");
try (PrintWriter writer = githubEnv.isBlank()
? null
: new PrintWriter(Files.newBufferedWriter(Path.of(githubEnv), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND))) {
BiConsumer<String, String> addEnv = (name, value) -> {
String item = name + "=" + value;
LOGGER.quiet(item);
if (writer != null)
writer.println(item);
};
addEnv.accept("HMCL_COMMIT_SHA", buildMetadata.revision());
addEnv.accept("HMCL_VERSION", buildMetadata.version());
addEnv.accept("HMCL_TAG_NAME", getTagPrefix().get() + buildMetadata.version());
}
}
private record BuildMetadata(String version, String revision, long timestamp) {
}
private BuildMetadata fetchBuildInfo(Helper helper, URI uri) throws IOException, InterruptedException {
LOGGER.quiet("Fetching build info from {}", uri);
BuildInfo buildInfo = Objects.requireNonNull(helper.fetch(uri, BuildInfo.class), "build info");
String revision = Objects.requireNonNullElse(buildInfo.actions(), List.<ActionInfo>of())
.stream()
.filter(action -> "hudson.plugins.git.util.BuildData".equals(action._class()))
.map(ActionInfo::lastBuiltRevision)
.map(BuiltRevision::SHA1)
.findFirst()
.orElseThrow(() -> new GradleException("Could not find revision"));
if (!revision.matches("[0-9a-z]{40}"))
throw new GradleException("Invalid revision: " + revision);
Pattern fileNamePattern = Pattern.compile("HMCL-(?<version>\\d+(?:\\.\\d+)+)\\.jar");
String version = Objects.requireNonNullElse(buildInfo.artifacts(), List.<ArtifactInfo>of())
.stream()
.map(ArtifactInfo::fileName)
.map(fileNamePattern::matcher)
.filter(Matcher::matches)
.map(matcher -> matcher.group("version"))
.findFirst()
.orElseThrow(() -> new GradleException("Could not find .jar artifact"));
return new BuildMetadata(version, revision, buildInfo.timestamp());
}
private static final class Helper implements AutoCloseable {
private final HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
private final Gson gson = new Gson();
private <T> T fetch(URI uri, Class<T> type) throws IOException, InterruptedException {
HttpResponse<String> response = client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new IOException("Bad status code " + response.statusCode() + " for " + uri);
}
return gson.fromJson(response.body(), type);
}
@Override
public void close() throws Exception {
// HttpClient implements AutoCloseable since Java 21
if (((Object) client) instanceof AutoCloseable closeable) {
closeable.close();
}
}
}
private record SubJobInfo(String _class, String name, String url, String color) {
}
private record BuildInfo(long number,
long timestamp,
List<ArtifactInfo> artifacts,
List<ActionInfo> actions
) {
}
record ArtifactInfo(String fileName) {
}
record ActionInfo(String _class, BuiltRevision lastBuiltRevision) {
}
record BuiltRevision(String SHA1) {
}
}
@@ -0,0 +1,32 @@
/*
* 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.ci;
import java.util.Objects;
/// @author Glavo
public final class GitHubActionUtils {
private static final String OFFICIAL_ORGANIZATION = "HMCL-dev";
public static final boolean IS_ON_OFFICIAL_REPO =
OFFICIAL_ORGANIZATION.equalsIgnoreCase(System.getenv("GITHUB_REPOSITORY_OWNER"))
&& Objects.requireNonNullElse(System.getenv("GITHUB_BASE_REF"), "").isBlank();
private GitHubActionUtils() {
}
}
@@ -0,0 +1,27 @@
/*
* 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.ci;
/// @author Glavo
public final class JenkinsUtils {
public static final boolean IS_ON_CI = "1".equals(System.getenv("HMCL_CI"));
private JenkinsUtils() {
}
}
@@ -66,7 +66,7 @@ public final class JavaFXUtils {
arch = Platform.is64Bit() ? "arm64" : "arm32";
else if (Platform.isLoongArch() && Platform.is64Bit())
arch = "loongarch64";
else if ("riscv64".equals(System.getProperty("os.arch"))) // TODO: https://github.com/java-native-access/jna/pull/1671
else if (Platform.isRISCV())
arch = "riscv64";
else
return;
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle;
package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
@@ -0,0 +1,102 @@
/*
* 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.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Collectors;
/// @author Glavo
public abstract class CreateLanguageList extends DefaultTask {
@InputDirectory
public abstract DirectoryProperty getResourceBundleDir();
@Input
public abstract Property<@NotNull String> getResourceBundleBaseName();
@Input
public abstract ListProperty<@NotNull String> getAdditionalLanguages();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void run() throws IOException {
Path inputDir = getResourceBundleDir().get().getAsFile().toPath();
if (!Files.isDirectory(inputDir))
throw new GradleException("Input directory not exists: " + inputDir);
SortedSet<Locale> locales = new TreeSet<>(LocalizationUtils::compareLocale);
locales.addAll(getAdditionalLanguages().getOrElse(List.of()).stream()
.map(Locale::forLanguageTag)
.toList());
String baseName = getResourceBundleBaseName().get();
String suffix = ".properties";
try (var stream = Files.newDirectoryStream(inputDir, file -> {
String fileName = file.getFileName().toString();
return fileName.startsWith(baseName) && fileName.endsWith(suffix);
})) {
for (Path file : stream) {
String fileName = file.getFileName().toString();
if (fileName.length() == baseName.length() + suffix.length())
locales.add(Locale.ENGLISH);
else if (fileName.charAt(baseName.length()) == '_') {
String localeName = fileName.substring(baseName.length() + 1, fileName.length() - suffix.length());
// TODO: Delete this if the I18N file naming is changed
if (baseName.equals("I18N")) {
if (localeName.equals("zh"))
locales.add(Locale.forLanguageTag("zh-Hant"));
else if (localeName.equals("zh_CN"))
locales.add(Locale.forLanguageTag("zh-Hans"));
else
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
} else {
if (localeName.equals("zh"))
locales.add(Locale.forLanguageTag("zh-Hans"));
else
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
}
}
}
}
Path outputFile = getOutputFile().get().getAsFile().toPath();
Files.createDirectories(outputFile.getParent());
Files.writeString(outputFile, locales.stream().map(locale -> '"' + locale.toLanguageTag() + '"')
.collect(Collectors.joining(", ", "[", "]")));
}
}
@@ -0,0 +1,298 @@
/*
* 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 com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.TaskAction;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.stream.Collectors;
/// @author Glavo
public abstract class CreateLocaleNamesResourceBundle extends DefaultTask {
@InputFile
public abstract RegularFileProperty getLanguagesFile();
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
private static String mapToFileName(String base, String ext, Locale locale) {
if (locale.equals(Locale.ENGLISH))
return base + "." + ext;
else if (locale.toLanguageTag().equals("zh-Hans"))
return base + "_zh." + ext;
else
return base + "_" + locale.toLanguageTag().replace('-', '_') + "." + ext;
}
private static final ResourceBundle.Control CONTROL = new ResourceBundle.Control() {
};
@TaskAction
public void run() throws IOException {
Path languagesFile = getLanguagesFile().get().getAsFile().toPath();
Path outputDir = getOutputDirectory().get().getAsFile().toPath();
if (Files.isDirectory(outputDir)) {
Files.walkFileTree(outputDir, new SimpleFileVisitor<>() {
@Override
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
Files.deleteIfExists(file);
return FileVisitResult.CONTINUE;
}
@Override
public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, @Nullable IOException exc) throws IOException {
if (!dir.equals(outputDir))
Files.deleteIfExists(dir);
return FileVisitResult.CONTINUE;
}
});
}
Files.deleteIfExists(outputDir);
Files.createDirectories(outputDir);
List<Locale> supportedLanguages;
try (var reader = Files.newBufferedReader(languagesFile)) {
supportedLanguages = new Gson().fromJson(reader, new TypeToken<List<String>>() {
}).stream()
.map(Locale::forLanguageTag)
.sorted(LocalizationUtils::compareLocale)
.toList();
}
if (!supportedLanguages.contains(Locale.ENGLISH))
throw new GradleException("Missing english in supported languages: " + supportedLanguages);
// Ensure English is at the first position, this assumption will be used later
if (!supportedLanguages.get(0).equals(Locale.ENGLISH)) {
supportedLanguages = new ArrayList<>(supportedLanguages);
supportedLanguages.remove(Locale.ENGLISH);
supportedLanguages.add(0, Locale.ENGLISH);
}
EnumMap<LocaleField, SortedSet<String>> names = new EnumMap<>(LocaleField.class);
for (LocaleField field : LocaleField.values()) {
names.put(field, supportedLanguages.stream()
.map(field::get)
.filter(it -> !it.isBlank())
.collect(Collectors.toCollection(() -> new TreeSet<>(field))));
}
Map<Locale, Properties> overrides = new HashMap<>();
for (Locale currentLanguage : supportedLanguages) {
InputStream overrideFile = CreateLocaleNamesResourceBundle.class.getResourceAsStream(
mapToFileName("LocaleNamesOverride", "properties", currentLanguage));
Properties overrideProperties = new Properties();
if (overrideFile != null) {
try (var reader = new InputStreamReader(overrideFile, StandardCharsets.UTF_8)) {
overrideProperties.load(reader);
}
}
overrides.put(currentLanguage, overrideProperties);
}
Map<Locale, LocaleNames> allLocaleNames = new HashMap<>();
// For Upside Down English
UpsideDownTranslate.Translator upsideDownTranslator = new UpsideDownTranslate.Translator();
for (Locale currentLocale : supportedLanguages) {
Properties currentOverrides = overrides.get(currentLocale);
if (currentLocale.getLanguage().length() > 2 && currentOverrides.isEmpty()) {
// The JDK does not provide localized texts for these languages
continue;
}
LocaleNames currentDisplayNames = new LocaleNames();
for (LocaleField field : LocaleField.values()) {
SortedMap<String, String> nameToDisplayName = currentDisplayNames.getNameToDisplayName(field);
loop:
for (String name : names.get(field)) {
String displayName = currentOverrides.getProperty(name);
getDisplayName:
if (displayName == null) {
if (currentLocale.equals(UpsideDownTranslate.EN_QABS)) {
String englishDisplayName = allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name);
if (englishDisplayName != null) {
displayName = upsideDownTranslator.translate(englishDisplayName);
break getDisplayName;
}
}
// Although it cannot correctly handle the inheritance relationship between languages,
// we will not apply this function to sublanguages.
List<Locale> candidateLocales = CONTROL.getCandidateLocales("", currentLocale);
for (Locale candidateLocale : candidateLocales) {
Properties candidateOverride = overrides.get(candidateLocale);
if (candidateOverride != null && candidateOverride.containsKey(name)) {
continue loop;
}
}
displayName = field.getDisplayName(name, currentLocale);
// JDK does not have a built-in translation
if (displayName.isBlank() || displayName.equals(name)) {
continue loop;
}
// If it is just a duplicate of the parent content, ignored it
for (Locale candidateLocale : candidateLocales) {
LocaleNames candidateLocaleNames = allLocaleNames.get(candidateLocale);
if (candidateLocaleNames != null) {
String candidateDisplayName = candidateLocaleNames.getNameToDisplayName(field).get(name);
if (displayName.equals(candidateDisplayName)) {
continue loop;
}
break;
}
}
// Ignore it if the JDK falls back to English when querying the display name
if (!currentLocale.equals(Locale.ENGLISH)
&& displayName.equals(allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name))) {
continue loop;
}
}
nameToDisplayName.put(name, displayName);
}
}
allLocaleNames.put(currentLocale, currentDisplayNames);
}
for (Map.Entry<Locale, LocaleNames> entry : allLocaleNames.entrySet()) {
if (!entry.getValue().isEmpty()) {
Path targetFile = outputDir.resolve(mapToFileName("LocaleNames", "properties", entry.getKey()));
entry.getValue().writeTo(targetFile);
}
}
}
private static final class LocaleNames {
private final EnumMap<LocaleField, SortedMap<String, String>> displayNames = new EnumMap<>(LocaleField.class);
LocaleNames() {
for (LocaleField field : LocaleField.values()) {
displayNames.put(field, new TreeMap<>(field));
}
}
boolean isEmpty() {
return displayNames.values().stream().allMatch(Map::isEmpty);
}
SortedMap<String, String> getNameToDisplayName(LocaleField field) {
return displayNames.get(field);
}
void writeTo(Path file) throws IOException {
try (var writer = Files.newBufferedWriter(file, StandardOpenOption.CREATE_NEW)) {
boolean firstBlock = true;
for (var entry : displayNames.entrySet()) {
LocaleField field = entry.getKey();
SortedMap<String, String> values = entry.getValue();
if (!values.isEmpty()) {
if (firstBlock)
firstBlock = false;
else
writer.newLine();
writer.write("# " + field.blockHeader + "\n");
for (var nameToDisplay : values.entrySet()) {
writer.write(nameToDisplay.getKey() + "=" + nameToDisplay.getValue() + "\n");
}
}
}
}
}
}
private enum LocaleField implements Comparator<String> {
LANGUAGE("Languages") {
@Override
public String get(Locale locale) {
return locale.getLanguage();
}
@Override
public String getDisplayName(String fieldValue, Locale inLocale) {
return new Locale.Builder()
.setLanguage(fieldValue)
.build()
.getDisplayLanguage(inLocale);
}
@Override
public int compare(String l1, String l2) {
return LocalizationUtils.compareLanguage(l1, l2);
}
},
SCRIPT("Scripts") {
@Override
public String get(Locale locale) {
return locale.getScript();
}
@Override
public String getDisplayName(String fieldValue, Locale inLocale) {
return new Locale.Builder()
.setScript(fieldValue)
.build()
.getDisplayScript(inLocale);
}
@Override
public int compare(String s1, String s2) {
return LocalizationUtils.compareScript(s1, s2);
}
};
final String blockHeader;
LocaleField(String blockHeader) {
this.blockHeader = blockHeader;
}
public abstract String get(Locale locale);
public abstract String getDisplayName(String fieldValue, Locale inLocale);
}
}
@@ -0,0 +1,110 @@
/*
* 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.GradleException;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
final class LocalizationUtils {
public static final Map<String, String> subLanguageToParent;
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);
}
subLanguageToParent = Collections.unmodifiableMap(map);
}
private static List<String> resolveLanguage(String language) {
List<String> langList = new ArrayList<>();
String lang = language;
while (true) {
langList.add(0, lang);
String parent = subLanguageToParent.get(lang);
if (parent != null) {
lang = parent;
} else {
return langList;
}
}
}
public static int compareLanguage(String l1, String l2) {
var list1 = resolveLanguage(l1);
var list2 = resolveLanguage(l2);
int n = Math.min(list1.size(), list2.size());
for (int i = 0; i < n; i++) {
int c = list1.get(i).compareTo(list2.get(i));
if (c != 0)
return c;
}
return Integer.compare(list1.size(), list2.size());
}
public static int compareScript(String s1, String s2) {
return s1.compareTo(s2);
}
public static int compareLocale(Locale l1, Locale l2) {
int c = compareLanguage(l1.getLanguage(), l2.getLanguage());
if (c != 0)
return c;
c = compareScript(l1.getScript(), l2.getScript());
if (c != 0)
return c;
c = l1.getCountry().compareTo(l2.getCountry());
if (c != 0)
return c;
c = l1.getVariant().compareTo(l2.getVariant());
if (c != 0)
return c;
return l1.toString().compareTo(l2.toLanguageTag());
}
private LocalizationUtils() {
}
}
@@ -0,0 +1,157 @@
/*
* 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 java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// @author Glavo
public abstract class UpsideDownTranslate extends DefaultTask {
static final Locale EN_QABS = Locale.forLanguageTag("en-Qabs");
private static final Map<String, String> PROPERTIES = Map.of(
"datetime.format", "MMM d, yyyy, h:mm:ss a"
);
@InputFile
public abstract RegularFileProperty getInputFile();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void run() throws IOException {
Path inputFile = getInputFile().get().getAsFile().toPath();
Path outputFile = getOutputFile().get().getAsFile().toPath();
Properties english = new Properties();
try (var reader = Files.newBufferedReader(inputFile)) {
english.load(reader);
}
Properties output = new Properties();
Translator translator = new Translator();
english.forEach((k, v) -> {
if (PROPERTIES.containsKey(k.toString())) {
output.setProperty(k.toString(), PROPERTIES.get(k.toString()));
} else {
output.put(k, translator.translate(v.toString()));
}
});
Files.createDirectories(outputFile.getParent());
try (var writer = Files.newBufferedWriter(outputFile)) {
output.store(writer, "This file is automatically generated, please do not modify it manually");
}
}
static final class Translator {
private static final Map<Integer, Integer> MAPPER = loadMap();
private static Map<Integer, Integer> loadMap() {
var map = new LinkedHashMap<Integer, Integer>();
InputStream inputStream = UpsideDownTranslate.class.getResourceAsStream("upside_down.txt");
if (inputStream != null) {
try (inputStream) {
new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
if (line.isBlank() || line.startsWith("#"))
return;
if (line.length() != 2) {
throw new GradleException("Invalid line: " + line);
}
map.put((int) line.charAt(0), (int) line.charAt(1));
});
} catch (IOException e) {
throw new GradleException("Failed to load upside_down.txt", e);
}
} else {
throw new GradleException("upside_down.txt not found");
}
return Collections.unmodifiableMap(map);
}
private static final Pattern FORMAT_PATTERN = Pattern.compile("^%(\\d\\$)?(\\d+)?(\\.\\d+)?([sdf])");
private static final Pattern XML_TAG_PATTERN = Pattern.compile("^<(?<tag>[a-zA-Z]+)( href=\"[^\"]*\")?>");
private final StringBuilder resultBuilder = new StringBuilder();
private void appendToLineBuilder(String input) {
for (int i = 0; i < input.length(); ) {
int ch = input.codePointAt(i);
if (ch == '%') {
Matcher matcher = FORMAT_PATTERN.matcher(input).region(i, input.length());
if (matcher.find()) {
String formatString = matcher.group();
resultBuilder.insert(0, formatString);
i += formatString.length();
continue;
}
} else if (ch == '<') {
Matcher matcher = XML_TAG_PATTERN.matcher(input).region(i, input.length());
if (matcher.find()) {
String beginTag = matcher.group();
String endTag = "</" + matcher.group(1) + ">";
int endTagOffset = input.indexOf(endTag, i + beginTag.length());
if (endTagOffset > 0) {
resultBuilder.insert(0, endTag);
appendToLineBuilder(input.substring(i + beginTag.length(), endTagOffset));
resultBuilder.insert(0, beginTag);
i = endTagOffset + endTag.length();
continue;
}
}
}
int udCh = MAPPER.getOrDefault(ch, ch);
if (Character.isBmpCodePoint(udCh)) {
resultBuilder.insert(0, (char) udCh);
} else {
resultBuilder.insert(0, Character.toChars(udCh));
}
i += Character.charCount(ch);
}
}
String translate(String input) {
resultBuilder.setLength(0);
appendToLineBuilder(input);
return resultBuilder.toString();
}
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* 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
@@ -15,33 +15,25 @@
* 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.event;
package org.jackhuang.hmcl.gradle.utils;
/**
*
* @author huang
*/
public class FailedEvent<T> extends Event {
import org.jetbrains.annotations.NotNull;
private final int failedTime;
private T newResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public FailedEvent(Object source, int failedTime, T newResult) {
super(source);
this.failedTime = failedTime;
this.newResult = newResult;
/// @author Glavo
public final class PropertiesUtils {
public static @NotNull Properties load(Path path) throws IOException {
Properties properties = new Properties();
try (var reader = Files.newBufferedReader(path)) {
properties.load(reader);
}
return properties;
}
public int getFailedTime() {
return failedTime;
private PropertiesUtils() {
}
public T getNewResult() {
return newResult;
}
public void setNewResult(T newResult) {
this.newResult = newResult;
}
}
@@ -0,0 +1,23 @@
#
# 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/>.
#
# Languages
lzh=Classical
# Scripts
Qabs=Upside Down
@@ -0,0 +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/>.
#
# Languages
en=英吉利文
@@ -0,0 +1,23 @@
#
# 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/>.
#
# Languages
lzh=文言
# Scripts
Qabs=颠倒
@@ -0,0 +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/>.
#
# Languages
lzh=文言
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
_HMCL_PROJECT_ROOT=$(dirname "${BASH_SOURCE[0]}")
sed -i 's,//services.gradle.org/distributions/,//mirrors.cloud.tencent.com/gradle/,g' "$_HMCL_PROJECT_ROOT/gradle/wrapper/gradle-wrapper.properties"
+1 -1
View File
@@ -47,7 +47,7 @@
</module>
<!-- Classes -->
<!-- <module name="FinalClass"/> --> <!-- class with only private constructor must be final: http://checkstyle.sourceforge.net/config_design.html#FinalClass -->
<module name="FinalClass"/> <!-- class with only private constructor must be final: http://checkstyle.sourceforge.net/config_design.html#FinalClass -->
<module name="SimplifyBooleanReturn"/> <!-- directly return boolean does not check and return http://checkstyle.sourceforge.net/config_design.html#SimplifyBooleanReturn -->
<module name="StringLiteralEquality"/> <!-- you can't write myString == "this" http://checkstyle.sourceforge.net/config_design.html#StringLiteralEquality -->
<module name="OneTopLevelClass"/> <!-- only one root class per file http://checkstyle.sourceforge.net/config_design.html#OneTopLevelClass -->
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
sed -i 's,//services.gradle.org/distributions/,//mirrors.cloud.tencent.com/gradle/,g' "$PWD/gradle/wrapper/gradle-wrapper.properties"
+23
View File
@@ -0,0 +1,23 @@
pipeline {
agent any
environment {
HMCL_CI = '1'
VERSION_TYPE = 'dev'
MICROSOFT_AUTH_ID = credentials('microsoft_auth_id')
MICROSOFT_AUTH_SECRET = credentials('microsoft_auth_secret')
CURSEFORGE_API_KEY = credentials('curseforge_api_key')
HMCL_SIGNATURE_KEY = credentials('hmcl_signature_key')
}
stages {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
pipeline {
agent any
environment {
HMCL_CI = '1'
VERSION_TYPE = 'stable'
MICROSOFT_AUTH_ID = credentials('microsoft_auth_id')
MICROSOFT_AUTH_SECRET = credentials('microsoft_auth_secret')
CURSEFORGE_API_KEY = credentials('curseforge_api_key')
HMCL_SIGNATURE_KEY = credentials('hmcl_signature_key')
}
stages {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
#
# 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/>.
#
versionRoot=3.7
+40
View File
@@ -0,0 +1,40 @@
# 调试选项
> [!WARNING]
> 本文介绍的是 HMCL 的内部功能,我们不保证这些功能的稳定性,并且随时可能修改或删除这些功能。
>
> 使用这些功能时请务必小心,错误地使用这些功能可能会导致 HMCL 行为异常甚至崩溃。
HMCL 提供了一系列调试选项,用于控制启动器的行为。
这些选项可以通过环境变量或 JVM 参数指定。如果两者同时存在,那么 JVM 参数会覆盖环境变量的设置。
| 环境变量 | JVM 参数 | 功能 | 默认值 | 额外说明 |
|-----------------------------|----------------------------------------------|--------------------------------|-------------------------------------------------------------------------------------------------------------|--------------|
| `HMCL_JAVA_HOME` | | 指定用于启动 HMCL 的 Java | | 仅对 exe/sh 生效 |
| `HMCL_JAVA_OPTS` | | 指定启动 HMCL 时的默认 JVM 参数 | | 仅对 exe/sh 生效 |
| `HMCL_FORCE_GPU` | | 指定是否强制使用 GPU 加速渲染 | `false` |
| `HMCL_ANIMATION_FRAME_RATE` | | 指定 HMCL 的动画帧率 | `60` | |
| `HMCL_LANGUAGE` | | 指定 HMCL 的默认语言 | 使用系统默认语言 |
| | `-Dhmcl.dir=<path>` | 指定 HMCL 的当前数据文件夹 | `./.hmcl` | |
| | `-Dhmcl.home=<path>` | 指定 HMCL 的用户数据文件夹 | Windows: `%APPDATA\.hmcl`<br>Linux/BSD: `$XDG_DATA_HOME/hmcl`<br>macOS: `~Library/Application Support/hmcl` | |
| | `-Dhmcl.self_integrity_check.disable=true` | 检查更新时不检查本体完整性 | | |
| | `-Dhmcl.bmclapi.override=<url>` | 指定 BMCLAPI 的 API Root | `https://bmclapi2.bangbang93.com` | |
| | `-Dhmcl.discoapi.override=<url>` | 指定 foojay Disco API 的 API Root | https://api.foojay.io/disco/v3.0` |
| `HMCL_FONT` | `-Dhmcl.font.override=<font family>` | 指定 HMCL 默认字体 | 使用系统默认字体 | |
| | `-Dhmcl.update_source.override=<url>` | 指定 HMCL 更新源 | `https://hmcl.huangyuhui.net/api/update_link` | |
| | `-Dhmcl.authlibinjector.location=<path>` | 指定 authlib-injector JAR 文件的位置 | 使用 HMCL 内嵌的 authlib-injector | |
| | `-Dhmcl.openjfx.repo=<maven repository url>` | 添加用于下载 OpenJFX 的自定义 Maven 仓库 | | |
| | `-Dhmcl.native.encoding=<encoding>` | 指定原生编码 | 使用系统的本机编码 | |
| | `-Dhmcl.microsoft.auth.id=<App ID>` | 指定 Microsoft OAuth App ID | 使用 HMCL 内置的 Microsoft OAuth App ID | |
| | `-Dhmcl.microsoft.auth.secret=<App Secret>` | 指定 Microsoft OAuth App 密钥 | 使用 HMCL 内置的 Microsoft OAuth App 密钥 | |
| | `-Dhmcl.curseforge.apikey=<Api Key>` | 指定 CurseForge API 密钥 | 使用 HMCL 内置的 CurseForge API 密钥 | |
| | `-Dhmcl.native.backend=<auto/jna/none>` | 指定HMCL使用的本机后端 | `auto` |
| | `-Dhmcl.hardware.fastfetch=<true/false>` | 指定是否使用 fastfetch 检测硬件信息 | `true` |
+260
View File
@@ -0,0 +1,260 @@
# 本地化
<!-- TODO: 本文档需要进一步完善。为了便于修改,我们暂时不将本文件翻译至其他语言,在完善后再在独立 PR 中提供其他语言的译文。 -->
HMCL 为多种语言提供本地化支持。
本文档介绍了 HMCL 对这些语言的支持状态,并给想要为 HMCL 的本地化工作做出贡献的贡献者提供了一份指南。
## 支持的语言
目前,HMCL 为这些语言提供支持:
| 语言 | 语言标签 | 首选本地化键 | 首选本地化文件后缀 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 |
|---------|-----------|-----------|------------|---------------------------------------------|--------|-------------------------------------------|
| 英语 | `en` | `default` | (空) | `en_us` | **主要** | [Glavo](https://github.com/Glavo) |
| 英语 (颠倒) | `en-Qabs` | `en-Qabs` | `en_Qabs` | `en_ud` | 自动 | |
| 中文 (简体) | `zh-Hans` | `zh` | `_zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) |
| 中文 (繁体) | `zh-Hant` | `zh-Hant` | `_zh_Hant` | `zh_tw` <br/> `zh_hk` | **主要** | [Glavo](https://github.com/Glavo) |
| 中文 (文言) | `lzh` | `lzh` | `_lzh` | `lzh` | 次要 | |
| 日语 | `ja` | `ja` | `_ja` | `ja_jp` | 次要 | |
| 西班牙语 | `es` | `es` | `_es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 俄语 | `ru` | `ru` | `_ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 乌克兰语 | `uk` | `uk` | `_uk` | `uk_ua` | 次要 | |
<details>
<summary>关于语言标签</summary>
HMCL 使用符合 IETF BCP 47 规范的语言标签。
对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称
(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
此外,语言代码中应当尽可能选择地区中立的语言标签。
例如,对于简体中文和繁体中文,我们使用 `zh-Hans`和 `zh-Hant` 作为语言代码,而不是 `zh-CN` 和 `zh-TW`。
</details>
<details>
<summary>关于本地化键和本地化文件后缀</summary>
本地化文件后缀和本地化键用于为[本地化资源](#本地化资源)命名。
通常来说,本地化键就是这份本地化资源对应的语言代码,而本地化文件后缀是将语言代码中的 `-` 替换为 `_`,并加上一个前缀下划线得到的。
作为特例,对于默认的资源,本地化键为 `default`,本地化文件后缀为空。
由于[资源回退机制](#资源回退机制)的存在。
如果没有完全匹配当前语言环境的资源,HMCL 会根据当前环境的语言标签推导出一个搜索列表,根据该列表依次搜索资源。
我们建议在提供本地化资源时,总是提供默认资源 (对应 `default` 本地化键和空的本地化文件后缀),
以确保所有用户都能正常加载资源。
并且我们建议尽可能为本地化资源使用更宽泛的语言标签,使用户更不容易回退到默认资源上。
例如,如果你提供了一份简体中文的本地化资源,那么我们推荐使用 `zh` 作为本地化键,而不是更具体的 `zh-Hans`,
这样它会对于所有使用中文的用户生效,避免对于这些用户回退到默认资源上。
如果你想同时提供简体中文和繁体中文的资源,那么推荐对用户占比更高的资源使用更宽泛的 `zh` 作为本地化键,使其作为默认的中文资源,
而对用户占比更低的资源使用更具体的 `zh-Hans`/`zh-Hant` 作为本地化键。
</details>
HMCL 会要求所有 Pull Request 在更新文档和本地化资源时同步更新所有**主要**支持的语言对应的资源。
如果 PR 作者对相关语言并不了解,那么可以直接在评论中提出翻译请求,
维护者会在合并 PR 前帮助 PR 作者翻译这些文本。
而对于**次要**支持的语言,我们不能保证这些本地化资源总是会同步更新。
我们需要熟练掌握这些语言的协作者帮助我们进行维护。
我们会在文档中记录愿意帮助我们维护这些语言本地化资源的的志愿者。
如果贡献者希望及时将新增的本地化文本翻译至这些语言,
那么可以在 PR 中 @ 这些志愿者寻求帮助。
如果你愿意帮助我们维护一些语言的本地化资源,那么请打开一个 PR,
将自己的 GitHub 用户名加入上方的志愿者列表。
我们非常感谢你的帮助。
## 添加新的语言支持
HMCL 欢迎任何人参与翻译和贡献。但是维护更多语言的翻译需要付出更多维护成本,所以我们对新增加的语言有一些要求。
请在贡献前确认以下要求:
- 我们优先考虑 [Minecraft 官方支持的语言](https://minecraft.wiki/w/Language)。
如果没有特殊理由,我们不会为 Minecraft 官方尚未支持的语言提供支持。
- 我们希望对所有语言都提供长久的维护支持。
由于本项目的维护者们擅长的语言有限,为了避免对新语言的支持很快就因无人维护而过时,
我们希望能够找到擅长该语言者帮助我们长期维护新增的本地化文件。
如果可能缺少长期维护者,我们会更慎重地考虑是否要加入对该语言的支持。
我们建议贡献者在提供新语言翻译之前先通过 [Issue](https://github.com/HMCL-dev/HMCL/issues/new?template=feature.yml) 提出一个功能请求,
与其他贡献者先进行讨论,确定了未来的维护方式后再进行翻译工作。
### 开始翻译
如果你想为 HMCL 添加新的语言支持,请从翻译 [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties) 开始。
HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译整个界面。
这是一个 Java Properties 文件,格式非常简单。
在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。
例如,英语的语言标签为 `en`。
在确定了语言标签后,请在 [`I18N.properties` 文件旁](../HMCL/src/main/resources/assets/lang)创建 `I18N_<语言标签>.properites` (例如 `I18N_en.properties`) 文件。
随后,你就可以开始在这个文件中进行翻译工作了。
`I18N.properties` 文件会遵循[资源回退机制](#资源回退机制)查询缺失的译文。
也就是说,你可以逐条目进行翻译,而你尚未翻译的条目会自动回退到英语上。
在翻译了一部分后,你可以[自行构建 HMCL](./README_zh.md#编译),编译出的 HMCL 中就会包含你的译文。
如果你的电脑默认环境不是该语言,你可以将环境变量 `HMCL_LANGUAGE` 指定为你刚刚从表格中找到的语言标签,
HMCL 会自动切换至这个语言。
到这里,你就可以把文件推送到 GitHub上,并向 HMCL 提交 PR 了。
HMCL 的维护者会替你完成其他步骤。
## 本地化资源
所有文档和本地化资源文件的命名规则为 `<资源名><本地化文件后缀>.<扩展名>`。
例如,对于 `README.md` 来说,不同语言的本地化版本命名如下:
- 英语: `README.md`
- 中文 (简体): `README_zh.md`
- 中文 (繁体): `README_zh_Hant.md`
- 中文 (文言): `README_lzh.md`
除了本地化文件,HMCL 还支持本地化 JSON 中的部分文本字段。JSON 中的本地化文本使用以下格式:
```json5
{
"<本地化键 1>": "<本地化文本 1>",
"<本地化键 2>": "<本地化文本 2>",
// ...
"<本地化键 N>": "<本地化文本 N>"
}
```
例如,对于以下文本字段:
```json
{
"meow": "Meow"
}
```
可以将其改写为本地化文本:
```json
{
"meow": {
"default": "Meow",
"zh": "喵呜",
"zh-Hant": "喵嗚"
}
}
```
## 资源回退机制
对于某个语言下的缺失的资源,HMCL 支持一套资源回退机制,会根据不同的语言标签推导出一个搜索列表,
根据该列表依次搜索资源。
例如,如果当前环境的语言标签为 `en-US`,那么 HMCL 会根据以下列表的顺序搜索对应的本地化资源:
1. `en-US`
2. `en`
3. `und`
对于能够混合的资源 (例如 `.properties` 文件),HMCL 会根据此列表的优先级混合资源;
对于难以混合的资源 (例如字体文件),HMCL 会根据此列表加载找到的最高优先级的资源。
如果当前语言使用 ISO 639 标准的三字符标签,但同时也存在对应的两字符标签,那么 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` 加入所有繁体中文环境的搜索列表中,
从而适应更多场景。
以下是几个常见中文环境对应的本地化资源搜索列表。
- `zh-CN`:
1. `zh-Hans-CN`
2. `zh-Hans`
3. `zh-CN`
4. `zh`
5. `und`
- `zh-SG`:
1. `zh-Hans-SG`
2. `zh-Hans`
3. `zh-SG`
4. `zh-CN`
5. `zh`
6. `und`
- `zh-TW`:
1. `zh-Hant-TW`
2. `zh-Hant`
3. `zh-TW`
4. `zh`
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`
- `lzh`:
1. `lzh-Hant`
2. `lzh`
3. `zh-Hant`
4. `zh`
5. `und`
## 自动同步文档内容
为了简化文档的维护,HMCL 使用了一套宏机制自动维护文档的部分内容。在命令行中执行
```bash
./gradlew updateDocuments
```
即可自动更新所有文档内容。
例如,为了创建在同一文档的不同语言译文之间跳转的链接,请在文档的标题下添加以下内容:
```markdown
<!-- #BEGIN LANGUAGE_SWITCHER -->
<!-- #END LANGUAGE_SWITCHER -->
```
随后执行 `./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)
```
关于宏的更多内容,请见 [MacroProcessor.java](../buildSrc/src/main/java/org/jackhuang/hmcl/gradle/docs/MacroProcessor.java)。
+1 -1
View File
@@ -13,7 +13,7 @@ nanohttpd = "2.3.1"
jsoup = "1.19.1"
chardet = "2.5.0"
twelvemonkeys = "3.12.0"
jna = "5.17.0"
jna = "5.18.0"
pci-ids = "0.4.0"
java-info = "1.0"
authlib-injector = "1.2.5"
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Vendored
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.