Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a89baee09 | ||
|
|
7f4c5fc4a0 | ||
|
|
75789c774d | ||
|
|
f0a16b3d14 | ||
|
|
710e69751f | ||
|
|
938d8647ce | ||
|
|
4b4dd592c3 | ||
|
|
225dba7448 | ||
|
|
5b1414428e | ||
|
|
cb467ae240 | ||
|
|
9416c0cfb3 | ||
|
|
16ea98c95a | ||
|
|
a8552a92d1 | ||
|
|
36d71bd14e | ||
|
|
cbe6554390 | ||
|
|
1b65e0b6d7 | ||
|
|
bd9ae189f8 | ||
|
|
867a04dfba | ||
|
|
dd7703c7a9 | ||
|
|
756b95ca8c | ||
|
|
ac0a0c7604 | ||
|
|
9ee611c0f6 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
.gradle
|
||||
|
||||
|
||||
+36
-1
@@ -1,4 +1,7 @@
|
||||
import org.jackhuang.hmcl.gradle.CheckTranslations
|
||||
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 java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
@@ -206,11 +209,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 +356,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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -208,20 +208,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 +426,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getCurrentGame() {
|
||||
return currentGame.get();
|
||||
}
|
||||
@@ -447,6 +442,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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -71,6 +70,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,68 @@
|
||||
/*
|
||||
* 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.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/// @author Glavo
|
||||
public final class UpsideDownUtils {
|
||||
private static final Map<Integer, Integer> MAPPER = new LinkedHashMap<>();
|
||||
|
||||
private static void putChars(char baseChar, String upsideDownChars) {
|
||||
for (int i = 0; i < upsideDownChars.length(); i++) {
|
||||
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static void putChars(String baseChars, String upsideDownChars) {
|
||||
if (baseChars.length() != upsideDownChars.length()) {
|
||||
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
|
||||
}
|
||||
|
||||
for (int i = 0; i < baseChars.length(); i++) {
|
||||
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
|
||||
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
|
||||
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
|
||||
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,42 @@ 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.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 +69,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 +90,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,163 +110,247 @@ 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);
|
||||
@@ -341,48 +438,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,48 +18,81 @@
|
||||
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();
|
||||
}
|
||||
@@ -70,9 +103,18 @@ public final class Schedulers {
|
||||
// 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) {
|
||||
|
||||
+8
-61
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
@@ -0,0 +1 @@
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
|
@@ -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"));
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -1,3 +1,4 @@
|
||||
import org.jackhuang.hmcl.gradle.ci.CheckUpdate
|
||||
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
|
||||
|
||||
plugins {
|
||||
@@ -66,4 +67,14 @@ defaultTasks("clean", "build")
|
||||
|
||||
tasks.register<UpdateDocuments>("updateDocuments") {
|
||||
documentsDir.set(layout.projectDirectory.dir("docs"))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<CheckUpdate>("checkUpdateDev") {
|
||||
tagPrefix.set("v")
|
||||
api.set("https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json")
|
||||
}
|
||||
|
||||
tasks.register<CheckUpdate>("checkUpdateStable") {
|
||||
tagPrefix.set("release-")
|
||||
api.set("https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json")
|
||||
}
|
||||
@@ -10,4 +10,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,135 @@
|
||||
/*
|
||||
* 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 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.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);
|
||||
|
||||
@Input
|
||||
public abstract Property<String> getApi();
|
||||
|
||||
@Input
|
||||
public abstract Property<String> getTagPrefix();
|
||||
|
||||
public CheckUpdate() {
|
||||
getOutputs().upToDateWhen(task -> false);
|
||||
}
|
||||
|
||||
private static <T> T fetch(URI uri, Class<T> type) throws IOException, InterruptedException {
|
||||
// // HttpClient implements Closeable since Java 21
|
||||
//noinspection resource
|
||||
var client = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.ALWAYS)
|
||||
.build();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
return new Gson().fromJson(response.body(), type);
|
||||
}
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException, InterruptedException {
|
||||
String githubEnv = Objects.requireNonNullElse(System.getenv("GITHUB_ENV"), "");
|
||||
if (githubEnv.isBlank())
|
||||
LOGGER.warn("GITHUB_ENV is not set");
|
||||
|
||||
String uri = getApi().get();
|
||||
LOGGER.info("Fetching metadata from {}", uri);
|
||||
BuildInfo buildInfo = Objects.requireNonNull(fetch(URI.create(uri), BuildInfo.class),
|
||||
"Could not fetch build info");
|
||||
|
||||
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.info(item);
|
||||
if (writer != null)
|
||||
writer.println(item);
|
||||
};
|
||||
|
||||
String revision = Objects.requireNonNullElse(buildInfo.actions(), List.<BuildInfo.ActionInfo>of())
|
||||
.stream()
|
||||
.filter(action -> "hudson.plugins.git.util.BuildData".equals(action._class))
|
||||
.map(BuildInfo.ActionInfo::lastBuiltRevision)
|
||||
.map(BuildInfo.ActionInfo.BuiltRevision::SHA1)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new GradleException("Could not find revision"));
|
||||
if (revision.matches("[0-9a-z]{40}"))
|
||||
addEnv.accept("HMCL_COMMIT_SHA", revision);
|
||||
else
|
||||
throw new GradleException("Invalid revision: " + revision);
|
||||
|
||||
Pattern fileNamePattern = Pattern.compile("HMCL-(?<version>\\d+(?:\\.\\d+)+)\\.jar");
|
||||
String version = Objects.requireNonNullElse(buildInfo.artifacts(), List.<BuildInfo.ArtifactInfo>of())
|
||||
.stream()
|
||||
.map(BuildInfo.ArtifactInfo::fileName)
|
||||
.map(fileNamePattern::matcher)
|
||||
.filter(Matcher::matches)
|
||||
.map(matcher -> matcher.group("version"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new GradleException("Could not find .jar artifact"));
|
||||
addEnv.accept("HMCL_VERSION", version);
|
||||
addEnv.accept("HMCL_TAG_NAME", getTagPrefix().get() + version);
|
||||
}
|
||||
}
|
||||
|
||||
private record BuildInfo(long number,
|
||||
List<ArtifactInfo> artifacts,
|
||||
List<ActionInfo> actions
|
||||
) {
|
||||
|
||||
record ArtifactInfo(String fileName) {
|
||||
}
|
||||
|
||||
record ActionInfo(String _class, BuiltRevision lastBuiltRevision) {
|
||||
record BuiltRevision(String SHA1) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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(", ", "[", "]")));
|
||||
}
|
||||
|
||||
}
|
||||
+298
@@ -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,155 @@
|
||||
/*
|
||||
* 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.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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
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 = new LinkedHashMap<>();
|
||||
|
||||
private static void putChars(char baseChar, String upsideDownChars) {
|
||||
for (int i = 0; i < upsideDownChars.length(); i++) {
|
||||
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static void putChars(String baseChars, String upsideDownChars) {
|
||||
if (baseChars.length() != upsideDownChars.length()) {
|
||||
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
|
||||
}
|
||||
|
||||
for (int i = 0; i < baseChars.length(); i++) {
|
||||
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
|
||||
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
|
||||
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
|
||||
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
+20
@@ -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=英吉利文
|
||||
+23
@@ -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=颠倒
|
||||
+20
@@ -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=文言
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)。
|
||||
@@ -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"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user