Compare commits

...
Author SHA1 Message Date
GlavoandToobLac 2b0de19e85 [release/3.15] 从 CurseForge 下载文件时传递 API Key (#6364)
https://github.com/HMCL-dev/HMCL/pull/6169
https://github.com/HMCL-dev/HMCL/pull/6297

---------

Co-authored-by: ToobLac <tooblac39@gmail.com>
2026-07-10 21:28:22 +08:00
Glavoand3gf8jv4dv 82bc0a5d75 [release/3.15] chore: update versions.txt to 26.2 (#6218)
https://github.com/HMCL-dev/HMCL/pull/6168

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-06-25 20:15:39 +08:00
Glavo 90296c3376 [release/3.15] 使用 HttpURLConnection 替换 HttpClient 实现 (#6215) 2026-06-25 18:48:14 +08:00
Glavo 40d21bbe24 修复无法为 MC 1.21.11 安装 Forge 的问题 (#6160) 2026-06-02 21:11:37 +08:00
Glavo d0be7efca3 将下载超时时间放宽至 30 秒 (#6151) 2026-05-30 11:50:55 +08:00
CiiLuand3gf8jv4dv 3bed03c535 设备代码流在完成授权后隐藏二维码 (#6087)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-05-29 21:08:05 +08:00
Glavo d295e606a9 修复无法用中文搜索游戏内容的问题 (#6129) 2026-05-28 20:47:41 +08:00
CiiLu 36afe32146 账户选择 popup 为已选择的账户添加底色 (#6111) 2026-05-28 20:38:36 +08:00
CiiLu 87dbbdd96f 优化陶瓦联机反馈提示 (#6120) 2026-05-28 20:35:44 +08:00
CiiLu 9b8e67b1b3 优化游戏列表 Popup 列表项大小 (#6119) 2026-05-28 20:27:12 +08:00
Glavo f5a5e6a6da Enhance nullability annotations and documentation in LocalizedText (#6149) 2026-05-28 20:19:16 +08:00
Glavo f0466c1d8e docs: remove outdated Gradle usage instructions from AGENTS.md (#6148) 2026-05-28 20:02:03 +08:00
3gf8jv4dv 903ecbb127 chore(l10n): rename Xbox to XBOX (#6130) 2026-05-27 21:03:16 +08:00
ENC_Euphony 4c6f3aeb99 fix: avoid stale versions in switch-instance popup (#6133)
Fix #6099
2026-05-27 20:05:04 +08:00
Glavo d6a4b3480f 增强 JsonUtils (#6145) 2026-05-25 20:55:37 +08:00
27 changed files with 813 additions and 228 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;
@@ -27,15 +27,16 @@ import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.tree.TarFileTree;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
@@ -66,7 +67,7 @@ public final class TerracottaBundle {
.thenComposeAsync(Schedulers.javafx(), pkg -> {
FileDownloadTask download = new FileDownloadTask(links, pkg, hash) {
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash);
return new Context() {
@Override
@@ -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
*
@@ -35,7 +35,6 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -245,7 +244,7 @@ public class Skin {
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
return new Context() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -266,7 +265,7 @@ public class Skin {
setResult(new ByteArrayInputStream(baos.toByteArray()));
if (checkETag) {
repository.cacheBytes(UrlResponseInfo.of(response), baos.toByteArray());
repository.cacheBytes(response, baos.toByteArray());
}
}
};
@@ -47,20 +47,21 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class CurseForgeRemoteModRepository implements RemoteModRepository {
private static final String PREFIX = "https://api.curseforge.com";
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final Semaphore SEMAPHORE = new Semaphore(16);
public static final String API_KEY = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
private static <R extends HttpRequest> R withApiKey(R request) {
if (request.getUrl().startsWith(PREFIX) && !apiKey.isEmpty()) {
request.header("X-API-KEY", apiKey);
if (request.getUrl().startsWith(PREFIX) && !API_KEY.isEmpty()) {
request.header("X-API-KEY", API_KEY);
}
return request;
}
public static boolean isAvailable() {
return !apiKey.isEmpty();
return !API_KEY.isEmpty();
}
private final Type type;
@@ -25,7 +25,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
@@ -84,7 +83,7 @@ public final class CacheFileTask extends FetchTask<Path> {
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
assert checkETag;
assert response != null;
@@ -125,7 +124,7 @@ public final class CacheFileTask extends FetchTask<Path> {
}
try {
setResult(repository.cacheRemoteFile(UrlResponseInfo.of(response), temp));
setResult(repository.cacheRemoteFile(response, temp));
} finally {
deleteTempFile();
}
@@ -29,11 +29,7 @@ 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.Semaphore;
@@ -48,7 +44,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public abstract class FetchTask<T> extends Task<T> {
protected static final int DEFAULT_RETRY = 3;
protected static final int DEFAULT_RETRY = 5;
protected final List<URI> uris;
protected int retry = DEFAULT_RETRY;
@@ -87,7 +83,7 @@ public abstract class FetchTask<T> extends Task<T> {
return getContext(null, false, null);
}
protected abstract Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException;
protected abstract Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException;
@Override
public void execute() throws Exception {
@@ -137,8 +133,8 @@ public abstract class FetchTask<T> extends Task<T> {
private static final class HttpResumeContext {
private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes ([0-9]+)-([0-9]+)/([0-9]+)");
static @Nullable FetchTask.HttpResumeContext of(HttpResponse<?> response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_OK)
static @Nullable FetchTask.HttpResumeContext of(int statusCode, UrlResponseInfo response) throws IOException {
if (statusCode != HttpURLConnection.HTTP_OK)
return null;
boolean acceptRanges = response.headers().firstValue("accept-ranges").orElse("").equalsIgnoreCase("bytes");
@@ -184,8 +180,8 @@ public abstract class FetchTask<T> extends Task<T> {
return strongETag != null ? strongETag : Objects.requireNonNull(lastModified);
}
boolean canResume(HttpResponse<?> response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_PARTIAL)
boolean canResume(int statusCode, UrlResponseInfo response) throws IOException {
if (statusCode != HttpURLConnection.HTTP_PARTIAL)
return false;
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
@@ -313,8 +309,10 @@ public abstract class FetchTask<T> extends Task<T> {
beforeDownload(uri);
updateProgress(0);
HttpResponse<InputStream> response;
HttpURLConnection connection = null;
UrlResponseInfo responseInfo;
String bmclapiHash;
int responseCode;
URI currentURI = uri;
@@ -330,51 +328,56 @@ public abstract class FetchTask<T> extends Task<T> {
}
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
connection = NetworkUtils.createHttpConnection(currentURI);
boolean keepConnection = false;
try {
headers.forEach(connection::setRequestProperty);
responseCode = connection.getResponseCode();
responseInfo = UrlResponseInfo.of(connection);
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()) {
closeResponseBody(response);
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");
bmclapiHash = responseInfo.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;
}
}
String location = response.headers().firstValue("Location").orElse(null);
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
if (responseCode >= 300 && responseCode <= 308 && responseCode != 306 && responseCode != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
String location = connection.getHeaderField("Location");
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
closeResponseBody(response);
currentURI = target;
} else {
break;
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
currentURI = target;
} else {
keepConnection = true;
break;
}
} finally {
if (!keepConnection && connection != null) {
closeHttpConnection(connection);
connection = null;
}
}
} while (true);
boolean closeResponseBody = true;
InputStream inputStream = null;
boolean responseBodyConsumed = false;
try {
int responseCode = response.statusCode();
if (resumeRequested && responseCode == 416) {
resumeContext = null;
discardContext(context);
@@ -386,7 +389,7 @@ public abstract class FetchTask<T> extends Task<T> {
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(currentURI, false);
Path cache = repository.getCachedRemoteFile(responseInfo.uri(), false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
@@ -407,14 +410,14 @@ public abstract class FetchTask<T> extends Task<T> {
throw new ResponseCodeException(uri, responseCode);
}
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
long contentLength = responseInfo.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromHeaders(responseInfo.headers());
if (context == null) {
context = getContext(response, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response);
context = getContext(responseInfo, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(responseCode, responseInfo);
} else if (resumeRequested) {
if (resumeContext.canResume(response)) {
if (resumeContext.canResume(responseCode, responseInfo)) {
// Resume download
LOG.info("Resuming " + resumeContext.uri + " from " + resumeContext.countUncompressed);
} else {
@@ -427,16 +430,18 @@ public abstract class FetchTask<T> extends Task<T> {
}
} else {
discardContext(context);
context = getContext(response, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response);
context = getContext(responseInfo, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(responseCode, responseInfo);
}
try {
closeResponseBody = false;
inputStream = connection.getInputStream();
download(context,
resumeContext, response.body(),
resumeContext, inputStream,
contentLength,
contentEncoding);
inputStream = null;
responseBodyConsumed = true;
} catch (IOException | InterruptedException | RuntimeException | Error e) {
if (context.broken) {
IOUtils.closeQuietly(context, e);
@@ -457,9 +462,9 @@ public abstract class FetchTask<T> extends Task<T> {
context = null;
return;
} finally {
if (closeResponseBody) {
closeResponseBody(response);
}
IOUtils.closeQuietly(inputStream);
if (connection != null && !responseBodyConsumed)
closeHttpConnection(connection);
}
} catch (InterruptedException e) {
throw e;
@@ -493,10 +498,10 @@ public abstract class FetchTask<T> extends Task<T> {
throw toDownloadException(uri, null, exceptions);
}
private static void closeResponseBody(HttpResponse<InputStream> response) {
InputStream body = response.body();
if (body != null)
IOUtils.closeQuietly(body);
/// Disconnects an HTTP URL connection whose response body will not be consumed.
private static void closeHttpConnection(HttpURLConnection connection) {
IOUtils.closeQuietly(connection.getErrorStream());
connection.disconnect();
}
private static void discardContext(@Nullable Context context) {
@@ -650,12 +655,6 @@ 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;
@@ -727,24 +726,4 @@ public abstract class FetchTask<T> extends Task<T> {
initialized = true;
}
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
private static final class Holder {
private static final HttpClient HTTP_CLIENT;
static {
if (!initialized) {
throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization.");
}
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
.build();
}
private Holder() {
}
}
}
@@ -28,7 +28,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.FileSystem;
@@ -179,7 +178,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException {
Path temp = Files.createTempFile(null, null);
String algorithm;
@@ -271,7 +270,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
if (checkETag) {
repository.cacheRemoteFile(UrlResponseInfo.of(response), file);
repository.cacheRemoteFile(response, file);
}
} finally {
if (!moved) {
@@ -26,7 +26,6 @@ import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -63,7 +62,7 @@ public final class GetTask extends FetchTask<String> {
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) {
long length = -1;
if (response != null)
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
@@ -92,7 +91,7 @@ public final class GetTask extends FetchTask<String> {
setResult(result);
if (checkETag) {
repository.cacheText(UrlResponseInfo.of(response), result);
repository.cacheText(response, result);
}
}
};
@@ -21,50 +21,81 @@ import com.google.gson.*;
import java.lang.reflect.Type;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
import java.time.format.*;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;
import static java.time.temporal.ChronoField.*;
/// Serializes and deserializes [Instant] values for Gson.
///
/// Serialized values are written as ISO offset date-time strings in the system default time zone,
/// truncated to whole seconds. Deserialization accepts the historical US localized date-time
/// format, ISO local date-time values with optional offsets, and ISO local date-time values
/// interpreted in the system default time zone.
///
/// @author Glavo
public final class InstantTypeAdapter implements JsonSerializer<Instant>, JsonDeserializer<Instant> {
/// Shared adapter instance for registering with Gson.
public static final InstantTypeAdapter INSTANCE = new InstantTypeAdapter();
/// Creates the singleton adapter instance.
private InstantTypeAdapter() {
}
/// Serializes an [Instant] to a JSON string in the system default time zone.
///
/// @param t the instant to serialize
/// @param type the requested source type
/// @param jsc the Gson serialization context
/// @return a JSON primitive containing the serialized instant
@Override
public JsonElement serialize(Instant t, Type type, JsonSerializationContext jsc) {
return new JsonPrimitive(serializeToString(t, ZoneId.systemDefault()));
}
/// Deserializes a JSON string into an [Instant].
///
/// @param json the JSON element to deserialize
/// @param type the requested target type
/// @param context the Gson deserialization context
/// @return the parsed instant
/// @throws JsonParseException if `json` is not a string or cannot be parsed as an instant
/// @throws IllegalArgumentException if `type` is not [Instant]
@Override
public Instant deserialize(JsonElement json, Type type, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonPrimitive))
throw new JsonParseException("The instant should be a string value");
else {
if (json instanceof JsonPrimitive) {
Instant time = deserializeToInstant(json.getAsString());
if (type == Instant.class)
return time;
else
throw new IllegalArgumentException(this.getClass() + " cannot be deserialized to " + type);
} else {
throw new JsonParseException("The instant should be a string value");
}
}
/// Formatter for the legacy US localized date-time representation.
private static final DateTimeFormatter EN_US_FORMAT = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM)
.withLocale(Locale.US)
.withZone(ZoneId.systemDefault());
/// Formatter for ISO local date-time text followed by one of the supported offset forms.
private static final DateTimeFormatter ISO_DATE_TIME = new DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.optionalStart().appendOffset("+HH:MM", "+00:00").optionalEnd()
.optionalStart().appendOffset("+H:MM", "+0:00").optionalEnd()
.optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
.optionalStart().appendOffset("+HH", "Z").optionalEnd()
.optionalStart().appendOffsetId().optionalEnd()
.toFormatter();
/// Parses an instant from one of the supported textual representations.
///
/// @param string the text to parse
/// @return the parsed instant
/// @throws JsonParseException if `string` is not in a supported instant format
public static Instant deserializeToInstant(String string) {
try {
return ZonedDateTime.parse(string, EN_US_FORMAT).toInstant();
@@ -83,6 +114,13 @@ public final class InstantTypeAdapter implements JsonSerializer<Instant>, JsonDe
}
}
/// Formats an instant as an ISO offset date-time string in the given time zone.
///
/// The output is truncated to whole seconds.
///
/// @param instant the instant to format
/// @param zone the time zone used to render the instant
/// @return the formatted instant
public static String serializeToString(Instant instant, ZoneId zone) {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.ofInstant(instant, zone).truncatedTo(ChronoUnit.SECONDS));
}
@@ -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) {
@@ -41,7 +41,7 @@ public final class IOUtils {
private IOUtils() {
}
public static final int DEFAULT_BUFFER_SIZE = 8 * 1024;
public static final int DEFAULT_BUFFER_SIZE = 32 * 1024;
public static BufferedReader newBufferedReaderMaybeNativeEncoding(Path file) throws IOException {
if (NATIVE_CHARSET == UTF_8)
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
@@ -26,6 +27,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 +47,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(10);
public static final int TIMEOUT_MILLIS = (int) TIMEOUT.toMillis();
private NetworkUtils() {
}
@@ -160,6 +164,45 @@ public final class NetworkUtils {
}
}
private static final List<Pair<String, String>> API_KEYS;
static {
if (CurseForgeRemoteModRepository.API_KEY.isEmpty()) {
API_KEYS = List.of();
} else {
API_KEYS = List.of(
pair("api.curseforge.com", CurseForgeRemoteModRepository.API_KEY),
pair("forgecdn.net", CurseForgeRemoteModRepository.API_KEY)
);
}
}
private static boolean matchDomainSuffix(String domain, String suffix) {
return domain.endsWith(suffix)
&& (domain.length() == suffix.length() || domain.charAt(domain.length() - suffix.length() - 1) == '.');
}
public static void injectApiKey(WebURL url, URLConnection connection) {
if (!(connection instanceof HttpURLConnection))
return;
if (connection.getRequestProperty("x-api-key") != null) {
return;
}
String host = url.getHost();
if (host == null || host.isEmpty())
return;
for (Pair<String, String> pair : API_KEYS) {
String hostSuffix = pair.getKey();
if (matchDomainSuffix(host, hostSuffix)) {
connection.addRequestProperty("x-api-key", pair.getValue());
return;
}
}
}
public static URLConnection createConnection(WebURL url) throws IOException {
URLConnection connection;
try {
@@ -167,12 +210,13 @@ 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);
httpConnection.setInstanceFollowRedirects(false);
injectApiKey(url, connection);
}
return connection;
}
@@ -276,8 +320,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();
@@ -290,10 +334,10 @@ public final class NetworkUtils {
throw new IOException("Too much redirects");
}
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL))
.openConnection();
properties
.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
WebURL redirectedUrl = WebURL.of(conn.getURL()).resolve(newURL);
HttpURLConnection redirected = (HttpURLConnection) redirectedUrl.toURL().openConnection();
properties.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
injectApiKey(redirectedUrl, redirected);
redirected.setRequestMethod(method);
conn = redirected;
++redirect;
@@ -17,17 +17,60 @@
*/
package org.jackhuang.hmcl.util.io;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/// Response URI and headers captured from a URL response.
///
/// @author Glavo
public record UrlResponseInfo(URI uri, HttpHeaders headers) {
/// Creates response metadata from a URL connection.
public static UrlResponseInfo of(URLConnection connection) throws IOException {
return new UrlResponseInfo(toURI(connection.getURL()), HttpHeaders.of(headers(connection), (name, value) -> true));
}
/// Creates response metadata from an HTTP client response.
public static UrlResponseInfo of(HttpResponse<?> httpResponse) {
return new UrlResponseInfo(httpResponse.uri(), httpResponse.headers());
}
/// Creates response metadata from an HTTP client response-info object.
public static UrlResponseInfo of(URI uri, HttpResponse.ResponseInfo info) {
return new UrlResponseInfo(uri, info.headers());
}
/// Converts a response URL into a URI.
private static URI toURI(URL url) throws IOException {
try {
return url.toURI();
} catch (URISyntaxException e) {
throw new IOException("Invalid response URL: " + url, e);
}
}
/// Copies named header fields from a URL connection.
private static Map<String, List<String>> headers(URLConnection connection) {
Map<String, List<String>> headerFields = connection.getHeaderFields();
if (headerFields == null || headerFields.isEmpty()) {
return Map.of();
}
LinkedHashMap<String, List<String>> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : headerFields.entrySet()) {
String name = entry.getKey();
List<String> values = entry.getValue();
if (name != null && values != null) {
headers.put(name, List.copyOf(values));
}
}
return headers;
}
}
@@ -914,6 +914,23 @@
26.1
26.1.1-rc-1
26.1.1
26w14a
26.1.2-rc-1
26.1.2
26w14a
26.2-snapshot-1
26.2-snapshot-2
26.2-snapshot-3
26.2-snapshot-4
26.2-snapshot-5
26.2-snapshot-6
26.2-snapshot-7
26.2-snapshot-8
26.2-pre-1
26.2-pre-2
26.2-pre-3
26.2-pre-4
26.2-pre-5
26.2-pre-6
26.2-rc-1
26.2-rc-2
26.2
@@ -21,6 +21,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
@@ -30,7 +31,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -45,7 +45,7 @@ import static org.junit.jupiter.api.Assertions.*;
/// Tests HTTP download retry and resume behavior.
@NotNullByDefault
public final class FetchTaskTest {
/// Marks HTTP client dependencies as initialized for isolated unit tests.
/// Marks HTTP connection dependencies as initialized for isolated unit tests.
@org.junit.jupiter.api.BeforeAll
public static void notifyFetchTaskInitialized() {
FetchTask.notifyInitialized();
@@ -257,7 +257,7 @@ public final class FetchTaskTest {
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) {
Charset charset = NetworkUtils.getCharsetFromContentType(response == null
? null
: response.headers().firstValue("content-type").orElse(null));