Compare commits

...
11 Commits
16 changed files with 576 additions and 108 deletions

No files matched your search

-6
View File
@@ -19,9 +19,3 @@ These rules apply to all Java code written or modified in this repository.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
## Gradle
- When invoking Gradle in this repository, always set `GRADLE_USER_HOME` to the workspace-local `.gradle-user-home` directory.
- Prefer commands such as `./gradlew -g .gradle-user-home ...` or the equivalent environment-variable-based configuration.
- When running Gradle `test` tasks, use a higher timeout of ten minutes.
@@ -46,33 +46,42 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
if (englishSearchFiltersSet.contains(englishWord)) {
continue;
}
englishSearchFiltersSet.add(englishWord);
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
count++;
if (count >= 3) break;
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
SearchResult searchResult = null;
List<RemoteMod> remoteMods = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteMods = searchResult.getUnsortedResults().toList();
if (!remoteMods.isEmpty()) {
break;
}
}
for (RemoteMod remoteMod : remoteMods) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
RemoteMod remoteMod = iterator.next();
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteMod;
@@ -30,7 +30,10 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.*;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -172,6 +175,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@@ -199,6 +203,11 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
}
@Override
public void loginCompletedDeviceCode() {
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
}
@Override
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
@@ -235,6 +244,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
}
public static class LoginCompletedDeviceCodeEvent extends Event {
public LoginCompletedDeviceCodeEvent(Object source) {
super(source);
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
@@ -57,6 +57,7 @@ public final class AccountListPopupMenu extends StackPane {
for (Account account : Accounts.getAccounts()) {
AccountAdvancedListItem item = new AccountAdvancedListItem(account);
if (Accounts.getSelectedAccount() == account) item.setActive(true);
item.setOnAction(e -> {
Accounts.setSelectedAccount(account);
if (getScene().getWindow() instanceof JFXPopup popup)
@@ -124,6 +124,11 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
step.set(new Step.WaitForScanQrCode(event.getUserCode(), event.getVerificationUri()));
}));
holder.registerWeak(Accounts.OAUTH_CALLBACK.onLoginCompletedDeviceCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.WaitForScanQrCode)
step.set(new Step.DeviceLoginCompleted());
}));
this.step.set(Accounts.OAUTH_CALLBACK.getClientId().isEmpty()
? new Step.Init()
: new Step.StartAuthorizationCodeLogin());
@@ -220,6 +225,13 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
codeBox.setMaxWidth(USE_PREF_SIZE);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
} else if (currentStep instanceof Step.DeviceLoginCompleted) {
loginButtonSpinner.setLoading(true);
var hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setSegment(i18n("account.methods.microsoft.methods.device.hint.completed"));
rootContainer.getChildren().addAll(hintPane);
} else if (currentStep instanceof Step.LoginFailed failed) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
@@ -312,6 +324,10 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
}
record DeviceLoginCompleted() implements Step {
}
record LoginFailed(String message) implements Step {
}
}
@@ -164,7 +164,7 @@ public class TerracottaControllerPage extends StackPane {
if (uninitialized.hasLegacy() && I18n.isUseChinese()) {
Object feedback = config().getShownTips().get(FEEDBACK_TIP);
if (!(feedback instanceof Number number) || number.intValue() < 1) {
Controllers.confirm(i18n("terracotta.feedback.desc"), i18n("terracotta.feedback.title"), () -> {
Controllers.confirm(i18n("terracotta.feedback.desc.update"), i18n("terracotta.feedback.title"), () -> {
FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK);
config().getShownTips().put(FEEDBACK_TIP, 1);
}, () -> {
@@ -31,7 +31,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.Version;
@@ -53,7 +52,10 @@ public final class GameListPopupMenu extends StackPane {
double initOffsetX, double initOffsetY,
Profile profile, List<Version> versions) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
menu.getItems().setAll(versions.stream()
.filter(it -> profile.getRepository().hasVersion(it.getId()))
.map(it -> new GameItem(profile, it.getId()))
.toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
}
@@ -66,10 +68,11 @@ public final class GameListPopupMenu extends StackPane {
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(60);
listView.setFixedCellSize(50);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2));
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
@@ -94,35 +97,34 @@ public final class GameListPopupMenu extends StackPane {
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
HBox root = new HBox();
root.setSpacing(8);
root.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
this.imageView = new ImageContainer(32);
imageViewContainer.getChildren().setAll(imageView);
this.imageView.setMouseTransparent(true);
BorderPane.setAlignment(imageView, Pos.CENTER);
this.content = new TwoLineListItem();
this.content.setMouseTransparent(true);
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag)) {
content.addTag(tag);
}
});
BorderPane.setAlignment(content, Pos.CENTER);
root.getChildren().setAll(imageView, content);
StackPane pane = new StackPane();
pane.getChildren().setAll(root);
pane.getStyleClass().add("menu-container");
root.setMouseTransparent(true);
BorderPane container = new BorderPane();
container.getStyleClass().add("container");
container.setPickOnBounds(false);
container.setLeft(imageView);
container.setCenter(content);
RipplerContainer ripplerContainer = new RipplerContainer(pane);
FXUtils.onClicked(ripplerContainer, () -> {
RipplerContainer ripplerContainer = new RipplerContainer(container);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
rootPane.getChildren().setAll(ripplerContainer);
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
FXUtils.onClicked(rootPane, () -> {
GameItem item = getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
@@ -130,8 +132,8 @@ public final class GameListPopupMenu extends StackPane {
popup.hide();
}
});
this.graphic = ripplerContainer;
ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5));
this.graphic = rootPane;
}
@Override
@@ -106,14 +106,14 @@ account.methods.microsoft.birth=How to Change Your Account Birth Date
account.methods.microsoft.code=Code (Copied to Clipboard)
account.methods.microsoft.close_page=Microsoft account authorization is now completed.\n\
\n\
There are some extra works for us, but you can safely close this tab for now.
There are some extra work for us, but you can safely close this tab for now.
account.methods.microsoft.deauthorize=Deauthorize
account.methods.microsoft.error.add_family=Please click <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">here</a> to change your account birth date to be over 18 years old, or add your account to a family.
account.methods.microsoft.error.country_unavailable=Xbox Live is not available in your current country/region.
account.methods.microsoft.error.country_unavailable=XBOX Live is not available in your current country/region.
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft: Java Edition. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
account.methods.microsoft.error.no_license=No Minecraft purchase record found. Please click <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">here</a> to get the game.\nIf you are using Game Pass, please create your game profile <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a>. If you have already purchased the game, please check if you are logged into the correct account.
account.methods.microsoft.error.no_character=You have not created a game profile yet. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create one.
account.methods.microsoft.error.banned=Your account may have been banned by Xbox Live.\n\
account.methods.microsoft.error.banned=Your account may have been banned by XBOX Live.\n\
You can click <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">here</a> to check the ban status of your account.
account.methods.microsoft.error.unknown=Failed to log in, error code: %d.
account.methods.microsoft.error.wrong_verify_method=Failed to log in. Please try logging into your account using PASSWORD instead of other login methods.
@@ -122,6 +122,7 @@ account.methods.microsoft.makegameidsettings=Create Profile / Edit Profile Name
account.methods.microsoft.hint=Click the "Log in" button to start adding your Microsoft account.
account.methods.microsoft.methods.device=Log In with QR Code
account.methods.microsoft.methods.device.hint=Scan QR code or visit <a href="%s">%s</a> to complete login, enter <b>%s</b> in the opened page.
account.methods.microsoft.methods.device.hint.completed=Microsoft account authorization is now completed. There are some extra work for us, just wait for a while.
account.methods.microsoft.methods.browser=Log In via Browser
account.methods.microsoft.methods.browser.hint=Click the "Log in" button or <a href="%s">copy the link</a> and paste it into the browser to log in.
account.methods.microsoft.manual=<b>If your internet connection is bad, it may cause web pages to load slowly or fail to load altogether.\nYou may try again later or switch to a different internet connection.</b>
@@ -1566,7 +1567,8 @@ terracotta.terracotta=Terracotta | Multiplayer
terracotta.status=Lobby
terracotta.back=Exit
terracotta.feedback.title=Fill Out Feedback Form
terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.feedback.desc=We hope you can take 10 seconds to fill out the feedback form.
terracotta.feedback.desc.update=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
terracotta.difficulty.easiest=Excellent network: almost connected!
terracotta.difficulty.simple=Good network: connection may take some time
@@ -107,11 +107,11 @@ account.methods.microsoft.close_page=اكتمل تفويض حساب Microsoft.\n
لا يزال هناك بعض الخطوات من جانبنا، لكن يمكنك إغلاق هذا التبويب بأمان الآن.
account.methods.microsoft.deauthorize=إلغاء التفويض
account.methods.microsoft.error.add_family=يُرجى النقر <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">هنا</a> لتغيير تاريخ ميلاد حسابك ليكون فوق 18 عاماً، أو إضافة حسابك إلى عائلة.
account.methods.microsoft.error.country_unavailable=Xbox Live غير متاح في بلدك/منطقتك الحالية.
account.methods.microsoft.error.missing_xbox_account=لا يوجد حساب Xbox مرتبط بحساب Microsoft الخاص بك بعد. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لربط واحد.
account.methods.microsoft.error.country_unavailable=XBOX Live غير متاح في بلدك/منطقتك الحالية.
account.methods.microsoft.error.missing_xbox_account=لا يوجد حساب XBOX مرتبط بحساب Microsoft الخاص بك بعد. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لربط واحد.
account.methods.microsoft.error.no_character=يُرجى التأكد من أنك اشتريت Minecraft: Java Edition.\n\
إذا اشتريته بالفعل، فقد لا يكون ملف اللعبة قد تم إنشاؤه. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لإنشاء ملف اللعبة.
account.methods.microsoft.error.banned=قد يكون حسابك محظوراً من Xbox Live.\n\
account.methods.microsoft.error.banned=قد يكون حسابك محظوراً من XBOX Live.\n\
يمكنك النقر <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">هنا</a> للتحقق من حالة الحظر لحسابك.
account.methods.microsoft.error.unknown=فشل تسجيل الدخول، رمز الخطأ: %d.
account.methods.microsoft.error.wrong_verify_method=فشل تسجيل الدخول. يُرجى محاولة تسجيل الدخول إلى حسابك باستخدام كلمة المرور بدلاً من طرق الدخول الأخرى.
@@ -1474,7 +1474,7 @@ terracotta.terracotta=Terracotta | متعدد اللاعبين
terracotta.status=الردهة
terracotta.back=خروج
terracotta.feedback.title=ملء استمارة التقييم
terracotta.feedback.desc=مع تحديث HMCL لنواة متعدد اللاعبين، نأمل أن تخصص 10 ثوانٍ لملء استمارة التقييم.
terracotta.feedback.desc.update=مع تحديث HMCL لنواة متعدد اللاعبين، نأمل أن تخصص 10 ثوانٍ لملء استمارة التقييم.
terracotta.sudo_installing=يجب على HMCL التحقق من كلمة مرورك قبل تثبيت نواة متعدد اللاعبين
terracotta.difficulty.easiest=شبكة ممتازة: الاتصال شبه مضمون!
terracotta.difficulty.simple=شبكة جيدة: قد يستغرق الاتصال بعض الوقت
@@ -110,11 +110,11 @@ account.methods.microsoft.code=程式碼 (已自動複製)
account.methods.microsoft.close_page=已完成 Microsoft 帳戶授權。啟動器將自動執行後續步驟。你現在可以關閉本頁面了。
account.methods.microsoft.deauthorize=移除應用存取權
account.methods.microsoft.error.add_family=請點擊 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此處</a> 更改你的帳戶出生日期,使年齡滿 18 歲以上,或將帳戶加入到家庭中。
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 Xbox Live 的支援。
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 XBOX Live 的支援。
account.methods.microsoft.error.missing_xbox_account=請確認你已經購買了 Minecraft: Java 版。若已購買,請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
account.methods.microsoft.error.no_license=未找到 Minecraft 購買記錄。請點擊 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此處</a> 購買遊戲。\n若你使用 Game Pass,請在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。若已購買,請檢查登入的帳戶是否正確。
account.methods.microsoft.error.no_character=你可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
account.methods.microsoft.error.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
account.methods.microsoft.error.banned=你的帳戶可能被 XBOX Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
account.methods.microsoft.error.unknown=登入失敗。錯誤碼:%d。
account.methods.microsoft.error.wrong_verify_method=登入失敗。請在 Microsoft 帳戶登入頁面嘗試使用密碼登入,不要使用其他登入方式。
account.methods.microsoft.logging_in=登入中……
@@ -122,6 +122,7 @@ account.methods.microsoft.makegameidsettings=建立檔案 / 編輯檔案名稱
account.methods.microsoft.hint=點擊「登入」按鈕開始新增 Microsoft 帳戶。
account.methods.microsoft.methods.device=掃描 QR Code 登入
account.methods.microsoft.methods.device.hint=掃描 QR Code 或訪問 <a href="%s">%s</a>,在開啟的頁面中輸入 <b>%s</b> 完成登入。
account.methods.microsoft.methods.device.hint.completed=已完成 Microsoft 帳戶授權。其餘登入步驟將由啟動器自動執行,請稍候。
account.methods.microsoft.methods.browser=在瀏覽器中登入
account.methods.microsoft.methods.browser.hint=點擊「登入」按鈕或者<a href="%s">複製連結</a>並在瀏覽器中貼上以登入。
account.methods.microsoft.manual=<b>若網路環境不佳,可能會導致網頁載入緩慢甚至無法載入,請稍後再試或更換網路環境後再試。</b>
@@ -1357,7 +1358,8 @@ terracotta.terracotta=Terracotta | 陶瓦聯機
terracotta.status=聯機大廳
terracotta.back=退出
terracotta.feedback.title=填寫回饋表
terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.feedback.desc=我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.feedback.desc.update=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
terracotta.difficulty.easiest=目前網路狀況極佳:稍等一下就成功!
terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間…
@@ -111,11 +111,11 @@ account.methods.microsoft.code=代码 (已自动复制)
account.methods.microsoft.close_page=已完成微软账户授权。其余登录步骤将由启动器自动执行。你现在可以关闭本页面了。
account.methods.microsoft.deauthorize=解除账户授权
account.methods.microsoft.error.add_family=请点击 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此处</a> 更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 Xbox Live 的支持。
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 XBOX Live 的支持。
account.methods.microsoft.error.missing_xbox_account=请确认你已经购买了 Minecraft: Java 版。若已购买,请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.no_license=未找到 Minecraft 购买记录。请点击 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此处</a> 购买游戏。\n若你使用 Game Pass,请在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。若已购买,请检查登录的账户是否正确。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.no_character=你尚未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
account.methods.microsoft.error.banned=你的账户可能被 XBOX Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
account.methods.microsoft.error.unknown=未知问题。错误码:%d。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软账户登录页面使用密码登录,不要使用其他登录方式。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.logging_in=登录中……
@@ -123,6 +123,7 @@ account.methods.microsoft.makegameidsettings=创建档案 / 编辑档案名称
account.methods.microsoft.hint=点击“登录”按钮开始添加微软账户。
account.methods.microsoft.methods.device=扫描二维码登录
account.methods.microsoft.methods.device.hint=扫描二维码或访问 <a href="%s">%s</a>,在打开的页面中输入 <b>%s</b> 完成登录。
account.methods.microsoft.methods.device.hint.completed=已完成微软账户授权。其余登录步骤将由启动器自动执行,请稍候。
account.methods.microsoft.methods.browser=在浏览器登录
account.methods.microsoft.methods.browser.hint=点击“登录”按钮或者<a href="%s">复制链接</a>并在浏览器中粘贴以登录。
account.methods.microsoft.manual=<b>若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。</b>\n\
@@ -1362,7 +1363,8 @@ terracotta.terracotta=Terracotta | 陶瓦联机
terracotta.status=联机大厅
terracotta.back=退出
terracotta.feedback.title=填写反馈表
terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.feedback.desc=我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.feedback.desc.update=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功!
terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间……
@@ -160,6 +160,8 @@ public class OAuth {
continue;
}
options.callback.loginCompletedDeviceCode();
return new Result(tokenResponse.accessToken, tokenResponse.refreshToken);
}
}
@@ -268,6 +270,8 @@ public class OAuth {
void grantDeviceCode(String userCode, String verificationURI);
void loginCompletedDeviceCode();
/**
* Open browser
*
@@ -33,7 +33,6 @@ 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.Semaphore;
@@ -331,7 +330,7 @@ public abstract class FetchTask<T> extends Task<T> {
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.timeout(NetworkUtils.TIMEOUT)
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
@@ -739,7 +738,7 @@ public abstract class FetchTask<T> extends Task<T> {
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.connectTimeout(NetworkUtils.TIMEOUT)
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
.build();
}
@@ -22,10 +22,14 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnknownNullability;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -34,115 +38,486 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* @author yushijinhun
*/
/// Utility class providing shared [Gson] instances and convenience methods for
/// JSON serialization and deserialization.
///
/// Two pre-configured [Gson] instances are exposed:
/// - [GSON] — pretty-printing instance with all standard HMCL type adapters registered.
/// - [UGLY_GSON] — compact (non-pretty) instance with the same adapter set minus
/// complex-map-key serialization and the built-in type adapters for [java.time.Instant],
/// [java.util.UUID], and [java.nio.file.Path].
///
/// All `fromJson` / `fromJsonFile` / `fromJsonFully` overloads return `null` when the
/// JSON literal `null` is encountered. The `fromNonNull*` variants throw
/// [JsonParseException] instead of returning `null`. The `fromMaybeMalformed*` variants
/// additionally swallow [com.google.gson.JsonSyntaxException] and return `null` for
/// syntactically invalid input.
///
/// @author yushijinhun
@SuppressWarnings("unchecked")
@NotNullByDefault
public final class JsonUtils {
/// The default shared [Gson] instance.
///
/// Configured with:
/// - Pretty printing enabled.
/// - Complex map key serialization enabled.
/// - Type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path].
/// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapterFactory], and
/// [JsonTypeAdapterFactory].
public static final Gson GSON = defaultGsonBuilder().create();
/// A compact [Gson] instance without pretty printing and without the extra
/// type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path].
///
/// Configured with:
/// - [JsonTypeAdapterFactory]
/// - [ValidationTypeAdapterFactory]
/// - [LowerCaseEnumTypeAdapterFactory]
public static final Gson UGLY_GSON = new GsonBuilder()
.registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE)
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)
.registerTypeAdapterFactory(LowerCaseEnumTypeAdapterFactory.INSTANCE)
.create();
/// Not instantiable.
private JsonUtils() {
}
public static <T> TypeToken<List<T>> listTypeOf(Class<T> elementType) {
/// Returns a [TypeToken] representing `List<T>` parameterized with the given element class.
///
/// @param <T> the element type
/// @param elementType the runtime [Class] of the list element
/// @return a [TypeToken] for `List<T>`
public static <T extends @UnknownNullability Object> TypeToken<List<T>> listTypeOf(Class<T> elementType) {
return (TypeToken<List<T>>) TypeToken.getParameterized(List.class, elementType);
}
public static <T> TypeToken<List<T>> listTypeOf(TypeToken<T> elementType) {
/// Returns a [TypeToken] representing `List<T>` parameterized with the given element token.
///
/// @param <T> the element type
/// @param elementType a [TypeToken] describing the element type (may itself be generic)
/// @return a [TypeToken] for `List<T>`
public static <T extends @UnknownNullability Object> TypeToken<List<T>> listTypeOf(TypeToken<T> elementType) {
return (TypeToken<List<T>>) TypeToken.getParameterized(List.class, elementType.getType());
}
public static <K, V> TypeToken<Map<K, V>> mapTypeOf(Class<K> keyType, Class<V> valueType) {
/// Returns a [TypeToken] representing `Map<K, V>` parameterized with the given key and
/// value classes.
///
/// @param <K> the key type
/// @param <V> the value type
/// @param keyType the runtime [Class] of the map key
/// @param valueType the runtime [Class] of the map value
/// @return a [TypeToken] for `Map<K, V>`
public static <K, V extends @UnknownNullability Object> TypeToken<Map<K, V>> mapTypeOf(Class<K> keyType, Class<V> valueType) {
return (TypeToken<Map<K, V>>) TypeToken.getParameterized(Map.class, keyType, valueType);
}
public static <K, V> TypeToken<Map<K, V>> mapTypeOf(Class<K> keyType, TypeToken<V> valueType) {
/// Returns a [TypeToken] representing `Map<K, V>` parameterized with the given key class
/// and value token.
///
/// @param <K> the key type
/// @param <V> the value type
/// @param keyType the runtime [Class] of the map key
/// @param valueType a [TypeToken] describing the value type (may itself be generic)
/// @return a [TypeToken] for `Map<K, V>`
public static <K, V extends @UnknownNullability Object> TypeToken<Map<K, V>> mapTypeOf(
Class<K> keyType, TypeToken<V> valueType) {
return (TypeToken<Map<K, V>>) TypeToken.getParameterized(Map.class, keyType, valueType.getType());
}
public static <T> T fromJsonFile(Path file, Class<T> classOfT) throws IOException {
return fromJsonFile(file, TypeToken.get(classOfT));
/// Deserializes the JSON string into an object of the given class using the provided [Gson]
/// instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param type the target class
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonSyntaxException if `json` is not valid JSON for `type`
public static <T> @Nullable T fromJson(Gson gson, String json, Class<T> type) {
return gson.<@Nullable T>fromJson(json, type);
}
public static <T> T fromJsonFile(Path file, TypeToken<T> type) throws IOException {
/// Deserializes the JSON string into an object of the given class using [GSON].
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param type the target class
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonSyntaxException if `json` is not valid JSON for `type`
public static <T> @Nullable T fromJson(String json, Class<T> type) {
return fromJson(GSON, json, type);
}
/// Deserializes the JSON string into an object described by the given [TypeToken] using the
/// provided [Gson] instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonSyntaxException if `json` is not valid JSON for `type`
public static <T> @Nullable T fromJson(Gson gson, String json, TypeToken<T> type) {
return gson.<@Nullable T>fromJson(json, type);
}
/// Deserializes the JSON string into an object described by the given [TypeToken] using
/// [GSON].
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonSyntaxException if `json` is not valid JSON for `type`
public static <T> @Nullable T fromJson(String json, TypeToken<T> type) {
return fromJson(GSON, json, type);
}
/// Deserializes JSON from a [Reader] into an object of the given class using the provided
/// [Gson] instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param reader the [Reader] supplying JSON content
/// @param type the target class
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonIOException if there is a problem reading from `reader`
/// @throws com.google.gson.JsonSyntaxException if the JSON is malformed
public static <T> @Nullable T fromJson(Gson gson, Reader reader, Class<T> type) {
return gson.<@Nullable T>fromJson(reader, type);
}
/// Deserializes JSON from a [Reader] into an object of the given class using [GSON].
///
/// @param <T> the target type
/// @param reader the [Reader] supplying JSON content
/// @param type the target class
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonIOException if there is a problem reading from `reader`
/// @throws com.google.gson.JsonSyntaxException if the JSON is malformed
public static <T> @Nullable T fromJson(Reader reader, Class<T> type) {
return fromJson(GSON, reader, type);
}
/// Deserializes JSON from a [Reader] into an object described by the given [TypeToken]
/// using the provided [Gson] instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param reader the [Reader] supplying JSON content
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonIOException if there is a problem reading from `reader`
/// @throws com.google.gson.JsonSyntaxException if the JSON is malformed
public static <T> @Nullable T fromJson(Gson gson, Reader reader, TypeToken<T> type) {
return gson.<@Nullable T>fromJson(reader, type);
}
/// Deserializes JSON from a [Reader] into an object described by the given [TypeToken]
/// using [GSON].
///
/// @param <T> the target type
/// @param reader the [Reader] supplying JSON content
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null`
/// @throws com.google.gson.JsonIOException if there is a problem reading from `reader`
/// @throws com.google.gson.JsonSyntaxException if the JSON is malformed
public static <T> @Nullable T fromJson(Reader reader, TypeToken<T> type) {
return fromJson(GSON, reader, type);
}
/// Reads and deserializes a JSON file at the given [Path] into an object of the given class
/// using the provided [Gson] instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param file the path to the JSON file
/// @param classOfT the target class
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the file
/// @throws com.google.gson.JsonSyntaxException if the file content is not valid JSON for `classOfT`
public static <T> @Nullable T fromJsonFile(Gson gson, Path file, Class<T> classOfT) throws IOException {
return fromJsonFile(gson, file, TypeToken.get(classOfT));
}
/// Reads and deserializes a JSON file at the given [Path] into an object of the given class
/// using [GSON].
///
/// @param <T> the target type
/// @param file the path to the JSON file
/// @param classOfT the target class
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the file
/// @throws com.google.gson.JsonSyntaxException if the file content is not valid JSON for `classOfT`
public static <T> @Nullable T fromJsonFile(Path file, Class<T> classOfT) throws IOException {
return fromJsonFile(GSON, file, classOfT);
}
/// Reads and deserializes a JSON file at the given [Path] into an object described by the
/// given [TypeToken] using the provided [Gson] instance.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param file the path to the JSON file
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the file
/// @throws com.google.gson.JsonSyntaxException if the file content is not valid JSON for `type`
public static <T> @Nullable T fromJsonFile(Gson gson, Path file, TypeToken<T> type) throws IOException {
try (var reader = Files.newBufferedReader(file)) {
return GSON.fromJson(reader, type.getType());
return gson.fromJson(reader, type.getType());
}
}
public static <T> T fromJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
/// Reads and deserializes a JSON file at the given [Path] into an object described by the
/// given [TypeToken] using [GSON].
///
/// @param <T> the target type
/// @param file the path to the JSON file
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the file
/// @throws com.google.gson.JsonSyntaxException if the file content is not valid JSON for `type`
public static <T> @Nullable T fromJsonFile(Path file, TypeToken<T> type) throws IOException {
return fromJsonFile(GSON, file, type);
}
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into an object of the given
/// class using [GSON]. The stream is closed after reading.
///
/// @param <T> the target type
/// @param json the input stream containing UTF-8 JSON
/// @param classOfT the target class
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is malformed or cannot be deserialized
public static <T> @Nullable T fromJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
return GSON.fromJson(reader, classOfT);
return GSON.<@Nullable T>fromJson(reader, classOfT);
}
}
public static <T> T fromJsonFully(InputStream json, TypeToken<T> type) throws IOException, JsonParseException {
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into an object described by
/// the given [TypeToken] using [GSON]. The stream is closed after reading.
///
/// @param <T> the target type
/// @param json the input stream containing UTF-8 JSON
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON root is `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is malformed or cannot be deserialized
public static <T> @Nullable T fromJsonFully(InputStream json, TypeToken<T> type) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
return GSON.fromJson(reader, type);
return GSON.<@Nullable T>fromJson(reader, type);
}
}
/// Deserializes a JSON string into a non-null object of the given class using the provided
/// [Gson] instance. Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param classOfT the target class
/// @return the deserialized object, never `null`
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJson(Gson gson, String json, Class<T> classOfT) throws JsonParseException {
return fromNonNullJson(gson, json, TypeToken.get(classOfT));
}
/// Deserializes a JSON string into a non-null object of the given class using [GSON].
/// Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param classOfT the target class
/// @return the deserialized object, never `null`
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJson(String json, Class<T> classOfT) throws JsonParseException {
T parsed = GSON.fromJson(json, classOfT);
return fromNonNullJson(GSON, json, classOfT);
}
/// Deserializes a JSON string into a non-null object described by the given [TypeToken]
/// using the provided [Gson] instance. Throws [JsonParseException] if the result would be
/// `null`.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, never `null`
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJson(Gson gson, String json, TypeToken<T> type) throws JsonParseException {
T parsed = fromJson(gson, json, type);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
}
/// Deserializes a JSON string into a non-null object described by the given [TypeToken]
/// using [GSON]. Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, never `null`
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJson(String json, TypeToken<T> type) throws JsonParseException {
T parsed = GSON.fromJson(json, type);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
return fromNonNullJson(GSON, json, type);
}
public static <T> T fromNonNullJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
T parsed = GSON.fromJson(reader, classOfT);
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into a non-null object of the
/// given class using the provided [Gson] instance. The stream is closed after reading.
/// Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param inputStream the input stream containing UTF-8 JSON
/// @param classOfT the target class
/// @return the deserialized object, never `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJsonFully(Gson gson, InputStream inputStream, Class<T> classOfT) throws IOException, JsonParseException {
return fromNonNullJsonFully(gson, inputStream, TypeToken.get(classOfT));
}
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into a non-null object of the
/// given class using [GSON]. The stream is closed after reading.
/// Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param inputStream the input stream containing UTF-8 JSON
/// @param classOfT the target class
/// @return the deserialized object, never `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJsonFully(InputStream inputStream, Class<T> classOfT) throws IOException, JsonParseException {
return fromNonNullJsonFully(GSON, inputStream, classOfT);
}
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into a non-null object
/// described by the given [TypeToken] using the provided [Gson] instance. The stream is
/// closed after reading. Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param inputStream the input stream containing UTF-8 JSON
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, never `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJsonFully(Gson gson, InputStream inputStream, TypeToken<T> type) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
T parsed = fromJson(gson, reader, type);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
}
}
public static <T> T fromNonNullJsonFully(InputStream json, TypeToken<T> type) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
T parsed = GSON.fromJson(reader, type);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
}
/// Reads and deserializes JSON from an [InputStream] (UTF-8) into a non-null object
/// described by the given [TypeToken] using [GSON]. The stream is closed after reading.
/// Throws [JsonParseException] if the result would be `null`.
///
/// @param <T> the target type
/// @param inputStream the input stream containing UTF-8 JSON
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, never `null`
/// @throws IOException if an I/O error occurs reading the stream
/// @throws JsonParseException if the JSON is `null` or cannot be deserialized
public static <T> T fromNonNullJsonFully(InputStream inputStream, TypeToken<T> type) throws IOException, JsonParseException {
return fromNonNullJsonFully(GSON, inputStream, type);
}
public static <T> T fromMaybeMalformedJson(String json, Class<T> classOfT) throws JsonParseException {
/// Deserializes a possibly malformed JSON string into an object of the given class using
/// the provided [Gson] instance. Returns `null` if the JSON is syntactically invalid
/// ([com.google.gson.JsonSyntaxException] is swallowed).
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param classOfT the target class
/// @return the deserialized object, or `null` if the JSON is `null` or syntactically invalid
/// @throws JsonParseException if the JSON is well-formed but semantically invalid
public static <T> @Nullable T fromMaybeMalformedJson(Gson gson, String json, Class<T> classOfT) throws JsonParseException {
return fromMaybeMalformedJson(gson, json, TypeToken.get(classOfT));
}
/// Deserializes a possibly malformed JSON string into an object of the given class using
/// [GSON]. Returns `null` if the JSON is syntactically invalid
/// ([com.google.gson.JsonSyntaxException] is swallowed).
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param classOfT the target class
/// @return the deserialized object, or `null` if the JSON is `null` or syntactically invalid
/// @throws JsonParseException if the JSON is well-formed but semantically invalid
public static <T> @Nullable T fromMaybeMalformedJson(String json, Class<T> classOfT) throws JsonParseException {
return fromMaybeMalformedJson(GSON, json, classOfT);
}
/// Deserializes a possibly malformed JSON string into an object described by the given
/// [TypeToken] using the provided [Gson] instance. Returns `null` if the JSON is
/// syntactically invalid ([com.google.gson.JsonSyntaxException] is swallowed).
///
/// @param <T> the target type
/// @param gson the [Gson] instance to use
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null` or syntactically invalid
/// @throws JsonParseException if the JSON is well-formed but semantically invalid
public static <T> @Nullable T fromMaybeMalformedJson(Gson gson, String json, TypeToken<T> type) throws JsonParseException {
try {
return GSON.fromJson(json, classOfT);
return gson.fromJson(json, type);
} catch (JsonSyntaxException e) {
return null;
}
}
public static <T> T fromMaybeMalformedJson(String json, TypeToken<T> type) throws JsonParseException {
try {
return GSON.fromJson(json, type);
} catch (JsonSyntaxException e) {
return null;
}
/// Deserializes a possibly malformed JSON string into an object described by the given
/// [TypeToken] using [GSON]. Returns `null` if the JSON is syntactically invalid
/// ([com.google.gson.JsonSyntaxException] is swallowed).
///
/// @param <T> the target type
/// @param json the JSON string to parse
/// @param type a [TypeToken] describing the target type
/// @return the deserialized object, or `null` if the JSON is `null` or syntactically invalid
/// @throws JsonParseException if the JSON is well-formed but semantically invalid
public static <T> @Nullable T fromMaybeMalformedJson(String json, TypeToken<T> type) throws JsonParseException {
return fromMaybeMalformedJson(GSON, json, type);
}
public static void writeToJsonFile(Path file, Object value) throws IOException {
/// Serializes `value` to JSON and writes it to the file at the given [Path] using [GSON].
///
/// @param file the path to the output file
/// @param value the object to serialize; may be `null`, in which case the JSON `null`
/// literal is written
/// @throws IOException if an I/O error occurs creating or writing the file
public static void writeToJsonFile(Path file, @Nullable Object value) throws IOException {
try (var writer = Files.newBufferedWriter(file)) {
GSON.toJson(value, writer);
}
}
/// Creates and returns a pre-configured [GsonBuilder] used to construct [GSON].
///
/// The builder has the following configuration applied:
/// - Complex map key serialization enabled.
/// - Pretty printing enabled.
/// - [InstantTypeAdapter] registered for [java.time.Instant].
/// - [UUIDTypeAdapter] registered for [java.util.UUID].
/// - [PathTypeAdapter] registered for [java.nio.file.Path].
/// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapterFactory], and
/// [JsonTypeAdapterFactory] registered as type adapter factories.
///
/// Callers may further customize the returned builder before calling
/// [GsonBuilder#create()].
///
/// @return a new [GsonBuilder] with the default HMCL JSON configuration
public static GsonBuilder defaultGsonBuilder() {
return new GsonBuilder()
.enableComplexMapKeySerialization()
@@ -23,28 +23,60 @@ 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.jetbrains.annotations.NotNull;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.*;
/// Represents text that may either be locale-independent or stored as values keyed by language tags.
///
/// The JSON representation accepts either a string or an object. A string is treated as the same text for every
/// locale, while an object maps language keys, such as `en`, `zh-Hans`, or `default`, to localized text values.
///
/// @author Glavo
@NotNullByDefault
@JsonAdapter(LocalizedText.Adapter.class)
@JsonSerializable
public final class LocalizedText {
/// Locale-independent text used when this instance is not backed by localized values.
private final @Nullable String value;
/// Text values keyed by language keys generated by [LocaleUtils#toLanguageKey(Locale)].
private final @Nullable Map<String, String> localizedValues;
public LocalizedText(String value) {
/// Creates a locale-independent text value.
///
/// When `value` is `null`, [#getText(List)] returns `null` for every candidate locale.
///
/// @param value the text value, or `null` if the text is absent
public LocalizedText(@Nullable String value) {
this.value = value;
this.localizedValues = null;
}
public LocalizedText(@NotNull Map<String, String> localizedValues) {
/// Creates a localized text value backed by language-key mappings.
///
/// The map is stored directly and is not copied. Later changes to `localizedValues` are visible through this
/// instance.
///
/// @param localizedValues text values keyed by language keys generated by [LocaleUtils#toLanguageKey(Locale)]
/// @throws NullPointerException if `localizedValues` is `null`
public LocalizedText(Map<String, String> localizedValues) {
this.value = null;
this.localizedValues = Objects.requireNonNull(localizedValues);
}
public String getText(@NotNull List<Locale> candidates) {
/// Returns the best matching text for the given candidate locales.
///
/// If this instance stores localized values, the candidates are checked in iteration order after conversion with
/// [LocaleUtils#toLanguageKey(Locale)]. The first matching value is returned, or `null` if no candidate matches.
/// If this instance stores locale-independent text, the stored value is returned without checking `candidates`.
///
/// @param candidates candidate locales ordered from most preferred to least preferred
/// @return the matched text, the locale-independent text, or `null` if no text is available
public @Nullable String getText(List<Locale> candidates) {
if (localizedValues != null) {
for (Locale locale : candidates) {
String value = localizedValues.get(LocaleUtils.toLanguageKey(locale));
@@ -56,10 +88,17 @@ public final class LocalizedText {
return value;
}
/// Gson adapter for the compact localized text JSON representation.
static final class Adapter extends TypeAdapter<LocalizedText> {
/// Reads a localized text value from `null`, a JSON string, or a JSON object.
///
/// @param jsonReader the reader positioned at the next localized text value
/// @return the parsed localized text, or `null` if the next JSON token is `null`
/// @throws IOException if reading from `jsonReader` fails
/// @throws JsonSyntaxException if the next token is neither `null`, a string, nor an object
@Override
public LocalizedText read(JsonReader jsonReader) throws IOException {
public @Nullable LocalizedText read(JsonReader jsonReader) throws IOException {
JsonToken nextToken = jsonReader.peek();
if (nextToken == JsonToken.NULL) {
return null;
@@ -83,8 +122,13 @@ public final class LocalizedText {
}
}
/// Writes a localized text value as `null`, a JSON string, or a JSON object.
///
/// @param jsonWriter the writer that receives the JSON representation
/// @param localizedText the localized text to write, or `null` to write a JSON `null`
/// @throws IOException if writing to `jsonWriter` fails
@Override
public void write(JsonWriter jsonWriter, LocalizedText localizedText) throws IOException {
public void write(JsonWriter jsonWriter, @Nullable LocalizedText localizedText) throws IOException {
if (localizedText == null) {
jsonWriter.nullValue();
} else if (localizedText.localizedValues != null) {
@@ -26,6 +26,7 @@ import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.*;
import java.nio.charset.Charset;
import java.time.Duration;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.Matcher;
@@ -45,7 +46,9 @@ public final class NetworkUtils {
public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "=";
public static final int TIME_OUT = 8000;
public static final Duration TIMEOUT = Duration.ofSeconds(30);
public static final int TIMEOUT_MILLIS = (int) TIMEOUT.toMillis();
private NetworkUtils() {
}
@@ -167,8 +170,8 @@ public final class NetworkUtils {
} catch (IllegalArgumentException | MalformedURLException e) {
throw new IOException(e);
}
connection.setConnectTimeout(TIME_OUT);
connection.setReadTimeout(TIME_OUT);
connection.setConnectTimeout(TIMEOUT_MILLIS);
connection.setReadTimeout(TIMEOUT_MILLIS);
if (connection instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setRequestProperty("User-Agent", USER_AGENT);
@@ -276,8 +279,8 @@ public final class NetworkUtils {
int redirect = 0;
while (true) {
conn.setUseCaches(useCache);
conn.setConnectTimeout(TIME_OUT);
conn.setReadTimeout(TIME_OUT);
conn.setConnectTimeout(TIMEOUT_MILLIS);
conn.setReadTimeout(TIMEOUT_MILLIS);
conn.setInstanceFollowRedirects(false);
Map<String, List<String>> properties = conn.getRequestProperties();
String method = conn.getRequestMethod();