Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0de19e85 | ||
|
|
82bc0a5d75 | ||
|
|
90296c3376 | ||
|
|
40d21bbe24 | ||
|
|
d0be7efca3 | ||
|
|
3bed03c535 | ||
|
|
d295e606a9 | ||
|
|
36afe32146 | ||
|
|
87dbbdd96f | ||
|
|
9b8e67b1b3 | ||
|
|
f5a5e6a6da | ||
|
|
f0466c1d8e | ||
|
|
903ecbb127 | ||
|
|
4c6f3aeb99 | ||
|
|
d6a4b3480f |
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+5
-4
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user