Compare commits

...
29 Commits
Author SHA1 Message Date
Glavoand辞庐 e57a41c2f3 [release/3.11] 迁移微软授权代码流 client_secret 到 PKCE (#5649)
https://github.com/HMCL-dev/HMCL/pull/5575

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-02-25 21:14:39 +08:00
Glavo d63b0bb088 [release/3.11] 优化设备代码流登录流程 (#5650)
https://github.com/HMCL-dev/HMCL/pull/5610
2026-02-25 21:11:29 +08:00
Glavo 8e9ddfb248 [release/3.11] 修复 MultipleSourceVersionList 不会使用备用下载源的问题 (#5648)
https://github.com/HMCL-dev/HMCL/pull/5585
https://github.com/HMCL-dev/HMCL/pull/5602
2026-02-25 21:10:29 +08:00
Glavoand辞庐 1edd36819c [release/3.11] fix: 打开微软对话框有一个加载圈一闪而过 (#5647)
https://github.com/HMCL-dev/HMCL/pull/5584

Co-authored-by: 辞庐 <109708109+CiiLu@users.noreply.github.com>
2026-02-25 21:08:47 +08:00
a5f6028b3a [release/3.11] 修复微软登录报错的超链接显示 (#5646)
https://github.com/HMCL-dev/HMCL/pull/5549

Co-authored-by: Xirren <michaelqiancn@gmail.com>
Co-authored-by: xirren <xirren@outlook.com>
2026-02-25 21:02:56 +08:00
GlavoandCiiLu 52f57c783e [release/3.11] fix: 安装整合包时任务弹窗可能空白 (#5645)
https://github.com/HMCL-dev/HMCL/pull/5550

Co-authored-by: CiiLu <109708109+CiiLu@users.noreply.github.com>
2026-02-25 20:59:30 +08:00
3gf8jv4dv 77a86ef963 docs: fix typos [skip ci] (#5542) 2026-02-15 20:28:09 +08:00
Glavo 32a741e2de Fix #5525: 修复 Task 进度异常的问题 (#5543) 2026-02-15 12:30:23 +08:00
CiiLu d1ead74177 fix: 微软登录完成页 svg 图标不会跟随深色模式 (#5541) 2026-02-15 02:55:57 +08:00
NoClassDefFoundErrorandGlavo 80ba1ec1a7 [Feature] 让Java下载页面可以同时下载多个Java (#5394)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-14 22:10:04 +08:00
Glavo 25fef6b5c2 删除 Lang.timer (#5537) 2026-02-14 22:04:04 +08:00
CiiLu 64ce1854f5 feat: 微软登录二维码生成添加 otc 参数 (#5538) 2026-02-14 22:03:23 +08:00
Glavo fb4f0be899 优化 SpinnerPane (#5533) 2026-02-14 20:45:21 +08:00
Glavo eea072cea5 更新微软账户登录对话框 (#5531) 2026-02-14 20:38:07 +08:00
3gf8jv4dv b0cd184e22 fix(l10n): replace mcedit with minecraft wiki's tutorial page (#5524)
resolves #5519
2026-02-13 22:33:48 +08:00
CiiLu 8b3e801a44 增加弹窗图标内容间距 (#5527) 2026-02-13 22:32:46 +08:00
CiiLu 04bb089f3a fix: 修复新建账号界面从微软账号切换到其他方式后下面按钮排版奇怪的问题 (#5523) 2026-02-12 20:41:17 +08:00
CiiLu 4bd0cbc88f fix: 下载游戏版本列表点击左侧图标无法触发水波纹效果 & 光标没有变成手型 (#5520) 2026-02-12 20:39:01 +08:00
CiiLu 9c4c7bd786 fix: 修复安装卡片部分区域点击无法触发水波纹效果的问题 (#5522) 2026-02-12 20:38:23 +08:00
Damon LuandGlavo f79d560605 阻止在 JFXCustomColorPickerDialog 输入非法颜色信息 (#5368)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-11 21:28:30 +08:00
Glavo 90e6782193 创建 SVGContainer 控件 (#5464) 2026-02-11 21:21:03 +08:00
Glavo ca071fd924 Add weak listener registration methods to WeakListenerHolder (#5509) 2026-02-10 22:42:57 +08:00
Glavo 2e26b14322 修复部分请求头中 Accept-Language 字段包含 Java 版本的问题 (#5508) 2026-02-10 22:39:22 +08:00
CiiLu b7fbebae43 feat: 使用授权代码流登录微软帐户 (#5330) 2026-02-10 02:52:52 +08:00
Glavo 33dc39e59e 修复陶瓦联机页面“导出联机日志”按钮填充异常的问题 (#5507) 2026-02-09 23:28:13 +08:00
Glavo a70b8d7baa 使用自定义 EasyTier 服务器节点 (#5504) 2026-02-09 22:54:18 +08:00
NoClassDefFoundErrorandGlavo 64821600c5 Fix #5326 修改在构建文件树时遇到重名文件夹导致读取失败的问题 (#5369)
Co-authored-by: Glavo <zjx001202@gmail.com>
2026-02-09 20:42:10 +08:00
dc993b871f feat: 无版本开始游戏不选择支持状态为“不支持”版本 (#5462)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 20:37:02 +08:00
3gf8jv4dv a0bd64ad1f docs: add HMCL_UI_SCALE to contribution guide [skip ci] (#5496) 2026-02-09 20:36:42 +08:00
57 changed files with 1561 additions and 651 deletions

No files matched your search

+1 -2
View File
@@ -28,7 +28,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
@@ -60,6 +59,7 @@ dependencies {
implementation(libs.twelvemonkeys.imageio.webp)
implementation(libs.java.info)
implementation(libs.monet.fx)
implementation(libs.nayuki.qrcodegen)
if (launcherExe.isBlank()) {
implementation(libs.hmclauncher)
@@ -152,7 +152,6 @@ val hmclProperties = buildList {
}
add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
}
@@ -34,14 +34,17 @@ import javafx.geometry.Insets;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.control.Tab;
import javafx.scene.control.TextFormatter;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
/**
* @author Shadi Shaheen
@@ -95,14 +98,17 @@ public class JFXCustomColorPickerDialog extends StackPane {
rgbField.getStyleClass().add("custom-color-field");
rgbField.setPromptText("RGB Color");
rgbField.setTextFormatter(colorCharFormatter());
rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hsbField.getStyleClass().add("custom-color-field");
hsbField.setPromptText("HSB Color");
hsbField.setTextFormatter(colorCharFormatter());
hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
hexField.getStyleClass().add("custom-color-field");
hexField.setPromptText("#HEX Color");
hexField.setTextFormatter(colorCharFormatter());
hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal));
StackPane tabContent = new StackPane();
@@ -404,4 +410,20 @@ public class JFXCustomColorPickerDialog extends StackPane {
dialog.setMinWidth(minWidth);
dialog.setMinHeight(minHeight);
}
private static final Pattern COLOR_CHAR_PATTERN = Pattern.compile("[0-9a-zA-Z#(),%.\\s]*");
private static TextFormatter<String> colorCharFormatter() {
return new TextFormatter<>(change -> {
if (!change.isContentChange()) return change;
String ins = StringUtils.toHalfWidth(change.getText());
if (!COLOR_CHAR_PATTERN.matcher(ins).matches()) return null;
String full = StringUtils.toHalfWidth(change.getControlNewText());
long h = full.chars().filter(c -> c == '#').count();
if (h > 1 || (h == 1 && full.indexOf('#') != 0)) return null;
change.setText(ins);
return change;
});
}
}
@@ -22,27 +22,28 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
private final String codeVerifier;
private final String state;
public static String lastlyOpenedURL;
@@ -52,6 +53,34 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
super(port);
this.port = port;
var encoder = Base64.getUrlEncoder().withoutPadding();
var random = new SecureRandom();
{
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
// https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
byte[] bytes = new byte[32];
random.nextBytes(bytes);
this.state = encoder.encodeToString(bytes);
}
{
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
byte[] bytes = new byte[64];
random.nextBytes(bytes);
this.codeVerifier = encoder.encodeToString(bytes);
}
}
@Override
public String getCodeVerifier() {
return codeVerifier;
}
@Override
public String getState() {
return state;
}
@Override
@@ -93,19 +122,32 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String parameters = session.getQueryParameterString();
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
if (query.containsKey("code")) {
idToken = query.get("id_token");
future.complete(query.get("code"));
String code = query.get("code");
if (code != null) {
if (this.state.equals(query.get("state"))) {
idToken = query.get("id_token");
future.complete(code);
} else if (query.containsKey("state")) {
LOG.warning("Failed to authenticate: invalid state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state"));
} else {
LOG.warning("Failed to authenticate: missing state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state"));
}
} else {
LOG.warning("Error: " + parameters);
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
LOG.warning("Failed to authenticate: missing authorization code in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
}
String html;
try {
html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"))
.replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet"))
.replace("%lang%", Locale.getDefault().toLanguageTag())
.replace("%close-page%", i18n("account.methods.microsoft.close_page"));
.replace("%success%", i18n("message.success"))
.replace("%ok%", i18n("button.ok"))
.replace("%close_page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) {
LOG.error("Failed to load html", e);
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "");
@@ -123,7 +165,8 @@ 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<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@Override
public OAuth.Session startServer() throws IOException, AuthenticationException {
@@ -150,17 +193,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
@Override
public void openBrowser(String url) throws IOException {
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
switch (grantFlow) {
case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url));
case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url));
}
FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
}
@Override
@@ -168,17 +207,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
public boolean isPublicClient() {
return true; // We have turned on the device auth flow.
}
}
public static class GrantDeviceCodeEvent extends Event {
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
@@ -44,13 +45,15 @@ import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -310,11 +313,18 @@ public final class TerracottaManager {
public static TerracottaState.HostScanning setScanning() {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/scanning", portSpecific.port), Map.of(
"player", getPlayerName()
));
Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
List<Pair<String, String>> query = new ArrayList<>(nodes.size() + 1);
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery(
"http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query
)).setSignificance(Task.TaskSignificance.MINOR);
}).start();
new GetTask(uri).setSignificance(Task.TaskSignificance.MINOR).start();
return new TerracottaState.HostScanning(-1, -1, null);
}
return null;
@@ -323,15 +333,19 @@ public final class TerracottaManager {
public static Task<TerracottaState.GuestConnecting> setGuesting(String room) {
TerracottaState state = STATE_V.get();
if (state instanceof TerracottaState.PortSpecific portSpecific) {
String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/guesting", portSpecific.port), Map.of(
"room", room,
"player", getPlayerName()
));
return new GetTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch)
.thenComposeAsync(nodes -> {
ArrayList<Pair<String, String>> query = new ArrayList<>(nodes.size() + 2);
query.add(pair("room", room));
query.add(pair("player", getPlayerName()));
for (URI node : nodes) {
query.add(pair("public_nodes", node.toString()));
}
return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query))
.setSignificance(Task.TaskSignificance.MINOR)
.thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null))
.setSignificance(Task.TaskSignificance.MINOR);
});
} else {
return null;
}
@@ -0,0 +1,103 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.terracotta;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// @author Glavo
public final class TerracottaNodeList {
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
@JsonSerializable
private record TerracottaNode(String url, @Nullable String region) implements Validation {
@Override
public void validate() throws JsonParseException, TolerableValidationException {
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
try {
new URI(url);
} catch (URISyntaxException e) {
throw new JsonParseException("Invalid URL: " + url, e);
}
}
}
private static volatile List<URI> list;
public static List<URI> fetch() {
List<URI> list = TerracottaNodeList.list;
if (list != null)
return list;
synchronized (TerracottaNodeList.class) {
list = TerracottaNodeList.list;
if (list != null)
return list;
try {
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
if (nodes == null) {
list = List.of();
LOG.info("No available Terracotta nodes found");
} else {
list = nodes.stream()
.filter(node -> {
if (node == null)
return false;
try {
node.validate();
} catch (Exception e) {
LOG.warning("Invalid terracotta node: " + node, e);
return false;
}
return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region);
})
.map(it -> URI.create(it.url()))
.toList();
LOG.info("Terracotta node list: " + list);
}
} catch (Exception e) {
LOG.warning("Failed to fetch terracotta node list", e);
list = List.of();
}
TerracottaNodeList.list = list;
return list;
}
}
private TerracottaNodeList() {
}
}
@@ -19,7 +19,7 @@ package org.jackhuang.hmcl.ui;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.ui.account.ClassicAccountLoginDialog;
import org.jackhuang.hmcl.ui.account.OAuthAccountLoginDialog;
import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginPane;
import java.util.Optional;
import java.util.concurrent.CancellationException;
@@ -49,10 +49,10 @@ public final class DialogController {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<AuthInfo> res = new AtomicReference<>(null);
runInFX(() -> {
OAuthAccountLoginDialog pane = new OAuthAccountLoginDialog((OAuthAccount) account, it -> {
MicrosoftAccountLoginPane pane = new MicrosoftAccountLoginPane(account, it -> {
res.set(it);
latch.countDown();
}, latch::countDown);
}, latch::countDown, false);
Controllers.dialog(pane);
});
latch.await();
@@ -343,6 +343,7 @@ public class InstallerItem extends Control {
BorderPane.setAlignment(statusLabel, Pos.CENTER_LEFT);
HBox buttonsContainer = new HBox();
buttonsContainer.setPickOnBounds(false);
buttonsContainer.setSpacing(8);
buttonsContainer.setAlignment(Pos.CENTER);
pane.getChildren().add(buttonsContainer);
@@ -18,8 +18,6 @@
package org.jackhuang.hmcl.ui;
import javafx.beans.value.ObservableValue;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.paint.Paint;
import javafx.scene.shape.SVGPath;
@@ -30,6 +28,7 @@ import javafx.scene.shape.SVGPath;
/// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px.
/// The view boxes of all icons are normalized to `0 0 24 24`.
public enum SVG {
NONE(""), // Empty Icon
ADD("M11 13H5V11H11V5H13V11H19V13H13V19H11V13Z"),
ADD_CIRCLE("M11 17H13V13H17V11H13V7H11V11H7V13H11V17ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
ALPHA_CIRCLE("M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z"), // Not Material
@@ -130,6 +129,12 @@ public enum SVG {
public static final double DEFAULT_SIZE = 24;
static void setSize(SVGPath path, double size) {
double scale = size / DEFAULT_SIZE;
path.setScaleX(scale);
path.setScaleY(scale);
}
private final String rawPath;
private String path;
@@ -144,35 +149,20 @@ public enum SVG {
return path;
}
public SVGPath createSVGPath() {
var p = new SVGPath();
p.setContent(getPath());
p.getStyleClass().add("svg");
return p;
public SVGPath createIcon() {
var path = new SVGPath();
path.getStyleClass().add("svg");
path.setContent(getPath());
return path;
}
private static Node createIcon(SVGPath path, double size) {
if (size == DEFAULT_SIZE)
return path;
else {
double scale = size / DEFAULT_SIZE;
path.setScaleX(scale);
path.setScaleY(scale);
return new Group(path);
}
public SVGContainer createIcon(double size) {
return new SVGContainer(this, size);
}
public Node createIcon() {
return createIcon(DEFAULT_SIZE);
}
public Node createIcon(double size) {
return createIcon(createSVGPath(), size);
}
public Node createIcon(ObservableValue<? extends Paint> color) {
SVGPath p = createSVGPath();
p.fillProperty().bind(color);
return createIcon(p, DEFAULT_SIZE);
public SVGPath createIcon(ObservableValue<? extends Paint> color) {
SVGPath path = createIcon();
path.fillProperty().bind(color);
return path;
}
}
@@ -0,0 +1,175 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.scene.Parent;
import javafx.scene.shape.SVGPath;
import javafx.util.Duration;
import org.jackhuang.hmcl.ui.animation.Motion;
/// A lightweight wrapper for displaying [SVG] icons.
///
/// @author Glavo
public final class SVGContainer extends Parent {
private static final String DEFAULT_STYLE_CLASS = "svg-container";
private final SVGPath path = new SVGPath();
private SVG icon = SVG.NONE;
private double iconSize = SVG.DEFAULT_SIZE;
private SVGPath tempPath;
private Timeline timeline;
{
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
this.path.getStyleClass().add("svg");
}
/// Creates an SVGContainer with the default icon and the default icon size.
public SVGContainer() {
this(SVG.NONE, SVG.DEFAULT_SIZE);
}
/// Creates an SVGContainer showing the given icon using the default icon size.
///
/// @param icon the [SVG] icon to display
public SVGContainer(SVG icon) {
this(icon, SVG.DEFAULT_SIZE);
}
/// Creates an SVGContainer with a custom icon size. The initial icon is
/// [SVG#NONE].
///
/// @param iconSize the icon size
public SVGContainer(double iconSize) {
this(SVG.NONE, iconSize);
}
/// Creates an SVGContainer with the specified icon and size.
///
/// @param icon the [SVG] icon to display
/// @param iconSize the icon size
public SVGContainer(SVG icon, double iconSize) {
setIconSizeImpl(iconSize);
setIcon(icon);
}
public double getIconSize() {
return iconSize;
}
private void setIconSizeImpl(double newSize) {
this.iconSize = newSize;
SVG.setSize(path, newSize);
if (tempPath != null)
SVG.setSize(tempPath, newSize);
}
public void setIconSize(double newSize) {
setIconSizeImpl(newSize);
requestLayout();
}
/// Gets the currently displayed icon.
public SVG getIcon() {
return icon;
}
/// Sets the icon to display without animation.
public void setIcon(SVG newIcon) {
setIcon(newIcon, Duration.ZERO);
}
/// Sets the icon to display with a cross-fade animation.
public void setIcon(SVG newIcon, Duration animationDuration) {
if (timeline != null) {
timeline.stop();
timeline = null;
}
SVG oldIcon = this.icon;
this.icon = newIcon;
if (animationDuration.equals(Duration.ZERO)) {
path.setContent(newIcon.getPath());
path.setOpacity(1);
if (getChildren().size() != 1)
getChildren().setAll(path);
} else {
if (tempPath == null) {
tempPath = new SVGPath();
tempPath.getStyleClass().add("svg");
SVG.setSize(tempPath, iconSize);
} else
tempPath.setOpacity(1);
tempPath.setContent(oldIcon.getPath());
getChildren().setAll(path, tempPath);
path.setOpacity(0);
path.setContent(newIcon.getPath());
timeline = new Timeline(
new KeyFrame(Duration.ZERO,
new KeyValue(path.opacityProperty(), 0, Motion.LINEAR),
new KeyValue(tempPath.opacityProperty(), 1, Motion.LINEAR)
),
new KeyFrame(animationDuration,
new KeyValue(path.opacityProperty(), 1, Motion.LINEAR),
new KeyValue(tempPath.opacityProperty(), 0, Motion.LINEAR)
)
);
timeline.setOnFinished(e -> {
getChildren().setAll(path);
timeline = null;
});
timeline.play();
}
}
// Parent
@Override
public double prefWidth(double height) {
return iconSize;
}
@Override
public double prefHeight(double width) {
return iconSize;
}
@Override
public double minHeight(double width) {
return iconSize;
}
@Override
public double minWidth(double height) {
return iconSize;
}
@Override
protected void layoutChildren() {
// Do nothing
}
}
@@ -20,14 +20,19 @@ package org.jackhuang.hmcl.ui;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.collections.WeakListChangeListener;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.event.EventPriority;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class WeakListenerHolder {
public final class WeakListenerHolder {
private final List<Object> refs = new ArrayList<>(0);
public WeakListenerHolder() {
@@ -48,6 +53,22 @@ public class WeakListenerHolder {
return new WeakListChangeListener<>(listener);
}
public <T extends Event> void registerWeak(EventManager<T> manager, Consumer<T> consumer) {
refs.add(manager.registerWeak(consumer));
}
public <T extends Event> void registerWeak(EventManager<T> manager, Consumer<T> consumer, EventPriority priority) {
refs.add(manager.registerWeak(consumer, priority));
}
public <T> void onWeakChange(ObservableValue<T> value, Consumer<T> consumer) {
refs.add(FXUtils.onWeakChange(value, consumer));
}
public <T> void onWeakChangeAndOperate(ObservableValue<T> value, Consumer<T> consumer) {
refs.add(FXUtils.onWeakChangeAndOperate(value, consumer));
}
public void add(Object obj) {
refs.add(obj);
}
@@ -130,7 +130,7 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco
microsoftItem.getStyleClass().add("navigation-drawer-item");
microsoftItem.setTitle(i18n("account.methods.microsoft"));
microsoftItem.setLeftIcon(SVG.MICROSOFT);
microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT)));
microsoftItem.setOnAction(e -> Controllers.dialog(new MicrosoftAccountLoginPane()));
AdvancedListItem offlineItem = new AdvancedListItem();
offlineItem.getStyleClass().add("navigation-drawer-item");
@@ -23,9 +23,7 @@ import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -45,7 +43,6 @@ import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
@@ -54,7 +51,6 @@ import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.StringUtils;
@@ -85,13 +81,11 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
private final JFXButton btnAccept;
private final SpinnerPane spinner;
private final Node body;
private final HBox actions;
private Node detailsPane; // AccountDetailsInputPane for Offline / Mojang / authlib-injector, Label for Microsoft
private final Pane detailsContainer;
private final BooleanProperty logging = new SimpleBooleanProperty();
private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
private TaskExecutor loginTask;
@@ -146,10 +140,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnCancel.setOnAction(e -> onCancel());
onEscPressed(this, btnCancel::fire);
HBox hbox = new HBox(spinner, btnCancel);
hbox.setAlignment(Pos.CENTER_RIGHT);
actions = new HBox(spinner, btnCancel);
actions.setAlignment(Pos.CENTER_RIGHT);
setActions(lblErrorMessage, hbox);
setActions(lblErrorMessage, actions);
}
if (showMethodSwitcher) {
@@ -226,7 +220,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
Runnable doCreate = () -> {
logging.set(true);
deviceCode.set(null);
loginTask = Task.supplyAsync(() -> factory.create(new DialogCharacterSelector(), username, password, null, additionalData))
.whenComplete(Schedulers.javafx(), account -> {
@@ -280,93 +273,16 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
btnAccept.disableProperty().unbind();
detailsContainer.getChildren().remove(detailsPane);
lblErrorMessage.setText("");
lblErrorMessage.setVisible(true);
setActions(lblErrorMessage, actions);
}
if (factory == Accounts.FACTORY_MICROSOFT) {
VBox vbox = new VBox(8);
detailsPane = vbox;
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("account.methods.microsoft.snapshot"));
vbox.getChildren().add(hintPane);
return;
}
if (!IntegrityChecker.isOfficial()) {
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
hintPane.setSegment(i18n("unofficial.hint"));
vbox.getChildren().add(hintPane);
}
VBox codeBox = new VBox(8);
Label hint = new Label(i18n("account.methods.microsoft.code"));
Label code = new Label();
code.setMouseTransparent(true);
code.setStyle("-fx-font-size: 24");
codeBox.getChildren().addAll(hint, code);
codeBox.setAlignment(Pos.CENTER);
vbox.getChildren().add(codeBox);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setVisible(false);
errHintPane.setManaged(false);
codeBox.setVisible(false);
codeBox.setManaged(false);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
code.setText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getVerificationUri()));
codeBox.setVisible(true);
codeBox.setManaged(true);
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
codeBox.setVisible(false);
codeBox.setManaged(false);
}
});
lblErrorMessage.setVisible(false);
lblErrorMessage.textProperty().addListener((obs, oldVal, newVal) -> {
boolean hasError = !newVal.isEmpty();
errHintPane.setSegment(newVal);
errHintPane.setVisible(hasError);
errHintPane.setManaged(hasError);
hintPane.setVisible(!hasError);
hintPane.setManaged(!hasError);
codeBox.setVisible(!hasError && deviceCode.get() != null);
codeBox.setManaged(!hasError && deviceCode.get() != null);
});
FXUtils.onClicked(codeBox, () -> {
if (deviceCode.get() != null) FXUtils.copyText(deviceCode.get().getUserCode());
});
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value ->
runInFX(() -> deviceCode.set(value))
));
HBox linkBox = new HBox();
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A");
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx");
linkBox.getChildren().setAll(profileLink, purchaseLink, deauthorizeLink, forgotpasswordLink);
vbox.getChildren().addAll(hintPane, errHintPane, linkBox);
btnAccept.setDisable(false);
detailsPane = new MicrosoftAccountLoginPane(true);
setActions();
} else {
detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire);
btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not());
setActions(lblErrorMessage, actions);
}
detailsContainer.getChildren().add(detailsPane);
@@ -0,0 +1,303 @@
package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXSpinner;
import io.nayuki.qrcodegen.QrCode;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.SVGPath;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.upgrade.IntegrityChecker;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.QrCodeUtils;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.concurrent.CancellationException;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MicrosoftAccountLoginPane extends JFXDialogLayout implements DialogAware {
private final Account accountToRelogin;
private final Consumer<AuthInfo> loginCallback;
private final Runnable cancelCallback;
@SuppressWarnings("FieldCanBeLocal")
private final WeakListenerHolder holder = new WeakListenerHolder();
private final ObjectProperty<Step> step = new SimpleObjectProperty<>();
private TaskExecutor browserTaskExecutor;
private TaskExecutor deviceTaskExecutor;
private final JFXButton btnLogin;
private final SpinnerPane loginButtonSpinner;
public MicrosoftAccountLoginPane() {
this(false);
}
public MicrosoftAccountLoginPane(boolean bodyonly) {
this(null, null, null, bodyonly);
}
public MicrosoftAccountLoginPane(Account account, Consumer<AuthInfo> callback, Runnable onCancel, boolean bodyonly) {
this.accountToRelogin = account;
this.loginCallback = callback;
this.cancelCallback = onCancel;
getStyleClass().add("microsoft-login-dialog");
if (bodyonly) {
this.pseudoClassStateChanged(PseudoClass.getPseudoClass("bodyonly"), true);
} else {
Label heading = new Label(accountToRelogin != null ? i18n("account.login.refresh") : i18n("account.create.microsoft"));
heading.getStyleClass().add("header-label");
setHeading(heading);
}
this.setMaxWidth(650);
onEscPressed(this, this::onCancel);
btnLogin = new JFXButton(i18n("account.login"));
btnLogin.getStyleClass().add("dialog-accept");
loginButtonSpinner = new SpinnerPane();
loginButtonSpinner.getStyleClass().add("small-spinner-pane");
loginButtonSpinner.setContent(btnLogin);
JFXButton btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
btnCancel.setOnAction(e -> onCancel());
setActions(loginButtonSpinner, btnCancel);
holder.registerWeak(Accounts.OAUTH_CALLBACK.onOpenBrowserAuthorizationCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.StartAuthorizationCodeLogin)
step.set(new Step.WaitForOpenBrowser(event.getUrl()));
}));
holder.registerWeak(Accounts.OAUTH_CALLBACK.onGrantDeviceCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.StartDeviceCodeLogin)
step.set(new Step.WaitForScanQrCode(event.getUserCode(), event.getVerificationUri()));
}));
this.step.set(Accounts.OAUTH_CALLBACK.getClientId().isEmpty()
? new Step.Init()
: new Step.StartAuthorizationCodeLogin());
FXUtils.onChangeAndOperate(step, this::onStep);
}
private void onStep(Step currentStep) {
VBox rootContainer = new VBox(10);
setBody(rootContainer);
rootContainer.setAlignment(Pos.TOP_CENTER);
if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) {
var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING);
snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot"));
rootContainer.getChildren().add(snapshotHint);
btnLogin.setDisable(true);
loginButtonSpinner.setLoading(false);
return;
}
if (!IntegrityChecker.isOfficial()) {
var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING);
unofficialHintPane.setSegment(i18n("unofficial.hint"));
rootContainer.getChildren().add(unofficialHintPane);
}
if (currentStep instanceof Step.Init) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
var hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("account.methods.microsoft.hint"));
rootContainer.getChildren().add(hintPane);
} else if (currentStep instanceof Step.StartAuthorizationCodeLogin) {
loginButtonSpinner.setLoading(false);
cancelAllTasks();
rootContainer.getChildren().add(new JFXSpinner());
browserTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.AUTHORIZATION_CODE))
.whenComplete(Schedulers.javafx(), this::onLoginCompleted)
.executor(true);
} else if (currentStep instanceof Step.StartDeviceCodeLogin) {
loginButtonSpinner.setLoading(true);
cancelAllTasks();
rootContainer.getChildren().add(new JFXSpinner());
deviceTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.DEVICE))
.whenComplete(Schedulers.javafx(), this::onLoginCompleted)
.executor(true);
} else if (currentStep instanceof Step.WaitForOpenBrowser wait) {
btnLogin.setOnAction(e -> {
FXUtils.openLink(wait.url());
loginButtonSpinner.setLoading(true);
});
loginButtonSpinner.setLoading(false);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setSegment(
i18n("account.methods.microsoft.methods.browser.hint", StringUtils.escapeXmlAttribute(wait.url()), wait.url()),
FXUtils::copyText
);
rootContainer.getChildren().add(hintPane);
} else if (currentStep instanceof Step.WaitForScanQrCode wait) {
loginButtonSpinner.setLoading(true);
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
? "https://www.microsoft.com/link?otc=" + wait.userCode()
: wait.verificationUri();
var deviceHint = new HintPane(MessageDialogPane.MessageType.INFO);
deviceHint.setSegment(i18n("account.methods.microsoft.methods.device.hint",
StringUtils.escapeXmlAttribute(scanUri),
wait.verificationUri(),
wait.userCode()
));
var qrCode = new SVGPath();
qrCode.fillProperty().bind(Themes.colorSchemeProperty().getPrimary());
qrCode.setContent(QrCodeUtils.toSVGPath(QrCode.encodeText(scanUri, QrCode.Ecc.MEDIUM)));
qrCode.setScaleX(3);
qrCode.setScaleY(3);
var lblCode = new Label(wait.userCode());
lblCode.getStyleClass().add("code-label");
lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";");
var codeBox = new StackPane(lblCode);
codeBox.getStyleClass().add("code-box");
codeBox.setCursor(Cursor.HAND);
FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode()));
codeBox.setMaxWidth(USE_PREF_SIZE);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
} else if (currentStep instanceof Step.LoginFailed failed) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
cancelAllTasks();
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setSegment(failed.message());
rootContainer.getChildren().add(errHintPane);
}
var linkBox = new FlowPane(8, 8);
linkBox.setAlignment(Pos.CENTER_LEFT);
linkBox.setPrefWrapLength(500);
if (currentStep instanceof Step.Init || currentStep instanceof Step.StartAuthorizationCodeLogin || currentStep instanceof Step.WaitForOpenBrowser) {
JFXHyperlink useQrCode = new JFXHyperlink(i18n("account.methods.microsoft.methods.device"));
useQrCode.setOnAction(e -> this.step.set(new Step.StartDeviceCodeLogin()));
linkBox.getChildren().add(useQrCode);
} else if (currentStep instanceof Step.StartDeviceCodeLogin || currentStep instanceof Step.WaitForScanQrCode) {
JFXHyperlink userBrowser = new JFXHyperlink(i18n("account.methods.microsoft.methods.browser"));
userBrowser.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
linkBox.getChildren().add(userBrowser);
}
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
linkBox.getChildren().addAll(profileLink, purchaseLink);
rootContainer.getChildren().add(linkBox);
setBody(rootContainer);
}
private void cancelAllTasks() {
if (browserTaskExecutor != null) browserTaskExecutor.cancel();
if (deviceTaskExecutor != null) deviceTaskExecutor.cancel();
}
private void onCancel() {
cancelAllTasks();
if (cancelCallback != null) cancelCallback.run();
fireEvent(new DialogCloseEvent());
}
private void onLoginCompleted(MicrosoftAccount account, Exception exception) {
if (exception == null) {
if (accountToRelogin != null) Accounts.getAccounts().remove(accountToRelogin);
int oldIndex = Accounts.getAccounts().indexOf(account);
if (oldIndex == -1) {
Accounts.getAccounts().add(account);
} else {
Accounts.getAccounts().remove(oldIndex);
Accounts.getAccounts().add(oldIndex, account);
}
Accounts.setSelectedAccount(account);
if (loginCallback != null) {
try {
loginCallback.accept(account.logIn());
} catch (AuthenticationException e) {
this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(e)));
return;
}
}
fireEvent(new DialogCloseEvent());
} else if (!(exception instanceof CancellationException)) {
this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(exception)));
}
}
private sealed interface Step {
final class Init implements Step {
}
final class StartAuthorizationCodeLogin implements Step {
}
record WaitForOpenBrowser(String url) implements Step {
}
final class StartDeviceCodeLogin implements Step {
}
record WaitForScanQrCode(String userCode, String verificationUri) implements Step {
}
record LoginFailed(String message) implements Step {
}
}
}
@@ -1,109 +0,0 @@
package org.jackhuang.hmcl.ui.account;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.OAuthAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.HintPane;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import java.util.function.Consumer;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class OAuthAccountLoginDialog extends DialogPane {
private final OAuthAccount account;
private final Consumer<AuthInfo> success;
private final Runnable failed;
private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
public OAuthAccountLoginDialog(OAuthAccount account, Consumer<AuthInfo> success, Runnable failed) {
this.account = account;
this.success = success;
this.failed = failed;
setTitle(i18n("account.login.refresh"));
VBox vbox = new VBox(8);
Label usernameLabel = new Label(account.getUsername());
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(
"<b>" + i18n("account.login.refresh.microsoft.hint") + "</b>\n"
+ i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())
);
} else {
hintPane.setSegment(
"<b>" + i18n("account.login.refresh.microsoft.hint") + "</b>\n"
+ i18n("account.methods.microsoft.hint")
);
}
});
FXUtils.onClicked(hintPane, () -> {
if (deviceCode.get() != null) {
FXUtils.copyText(deviceCode.get().getUserCode());
}
});
HBox box = new HBox(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setExternalLink("https://support.microsoft.com/account-billing/how-to-change-a-birth-date-on-a-microsoft-account-837badbc-999e-54d2-2617-d19206b9540a");
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
profileLink.setExternalLink("https://account.live.com/editprof.aspx");
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase"));
purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL);
box.getChildren().setAll(profileLink, birthLink, purchaseLink);
GridPane.setColumnSpan(box, 2);
vbox.getChildren().setAll(usernameLabel, hintPane, box);
setBody(vbox);
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode));
}
private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) {
FXUtils.runInFX(() -> {
deviceCode.set(event);
});
}
@Override
protected void onAccept() {
setLoading();
Task.supplyAsync(account::logInWhenCredentialsExpired)
.whenComplete(Schedulers.javafx(), (authInfo, exception) -> {
if (exception == null) {
success.accept(authInfo);
onSuccess();
} else {
LOG.info("Failed to login when credentials expired: " + account, exception);
onFailure(Accounts.localizeErrorMessage(exception));
}
}).start();
}
@Override
protected void onCancel() {
failed.run();
super.onCancel();
}
}
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
@@ -28,8 +27,8 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.SVGContainer;
import org.jackhuang.hmcl.ui.animation.Motion;
import java.util.function.Consumer;
@@ -100,27 +99,17 @@ public class AdvancedListBox extends ScrollPane {
return add(item);
}
@SuppressWarnings("SuspiciousNameCombination")
public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab<?> tab, String title,
SVG unselectedGraphic, SVG selectedGraphic) {
AdvancedListItem item = createNavigationDrawerItem(title, null);
item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab));
item.setOnAction(e -> tabHeader.select(tab));
Node unselectedIcon = unselectedGraphic.createIcon(AdvancedListItem.LEFT_ICON_SIZE);
Node selectedIcon = selectedGraphic.createIcon(AdvancedListItem.LEFT_ICON_SIZE);
TransitionPane leftGraphic = new TransitionPane();
AdvancedListItem.setAlignment(leftGraphic, Pos.CENTER);
var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE);
leftGraphic.setMouseTransparent(true);
leftGraphic.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(leftGraphic, AdvancedListItem.LEFT_GRAPHIC_SIZE);
FXUtils.setLimitHeight(leftGraphic, AdvancedListItem.LEFT_ICON_SIZE);
leftGraphic.setPadding(Insets.EMPTY);
leftGraphic.setContent(item.isActive() ? selectedIcon : unselectedIcon, ContainerAnimations.NONE);
FXUtils.onChange(item.activeProperty(), active ->
leftGraphic.setContent(active ? selectedIcon : unselectedIcon, ContainerAnimations.FADE));
AdvancedListItem.setAlignment(leftGraphic, Pos.CENTER);
AdvancedListItem.setMargin(leftGraphic, AdvancedListItem.LEFT_ICON_MARGIN);
FXUtils.onChange(item.activeProperty(), active -> leftGraphic.setIcon(active ? selectedGraphic : unselectedGraphic, Motion.SHORT4));
item.setLeftGraphic(leftGraphic);
return add(item);
}
@@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.Locale;
import java.util.function.Consumer;
public class HintPane extends VBox {
private final Text label = new Text();
@@ -66,7 +67,11 @@ public class HintPane extends VBox {
}
public void setSegment(String segment) {
flow.getChildren().setAll(FXUtils.parseSegment(segment, Controllers::onHyperlinkAction));
this.setSegment(segment, Controllers::onHyperlinkAction);
}
public void setSegment(String segment, Consumer<String> hyperlinkAction) {
flow.getChildren().setAll(FXUtils.parseSegment(segment, hyperlinkAction));
}
public void setChildren(Node... children) {
@@ -31,16 +31,12 @@ import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.Lang;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Locale;
import java.util.Timer;
import java.util.TimerTask;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MessageDialogPane extends HBox {
@@ -72,7 +68,7 @@ public final class MessageDialogPane extends HBox {
private @Nullable ButtonBase cancelButton;
public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) {
this.setSpacing(8);
this.setSpacing(16);
this.getStyleClass().add("jfx-dialog-layout");
Label graphic = new Label();
@@ -219,36 +215,6 @@ public final class MessageDialogPane extends HBox {
return this;
}
public Builder cancelOnTimeout(long timeoutMs) {
if (dialog.getCancelButton() == null) {
throw new IllegalStateException("Call ok/yesOrNo/actionOrCancel before calling cancelOnTimeout");
}
ButtonBase cancelButton = dialog.getCancelButton();
String originalText = cancelButton.getText();
Timer timer = Lang.getTimer();
timer.scheduleAtFixedRate(new TimerTask() {
long timeout = timeoutMs;
@Override
public void run() {
if (timeout <= 0) {
cancel();
runInFX(() -> {
cancelButton.fire();
});
return;
}
timeout -= 1000;
long currentTimeout = timeout;
runInFX(() -> cancelButton.setText(originalText + " (" + (currentTimeout / 1000) + ")"));
}
}, 1000, 1000);
return this;
}
public MessageDialogPane build() {
return dialog;
}
@@ -18,8 +18,6 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXSpinner;
import javafx.beans.DefaultProperty;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*;
import javafx.event.Event;
import javafx.event.EventHandler;
@@ -33,14 +31,12 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
@DefaultProperty("content")
/// A spinner pane that can show spinner, failed reason, or content.
public class SpinnerPane extends Control {
private final ObjectProperty<Node> content = new SimpleObjectProperty<>(this, "content");
private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading");
private final StringProperty failedReason = new SimpleStringProperty(this, "failedReason");
private static final String DEFAULT_STYLE_CLASS = "spinner-pane";
public SpinnerPane() {
getStyleClass().add("spinner-pane");
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
public void showSpinner() {
@@ -52,108 +48,195 @@ public class SpinnerPane extends Control {
setLoading(false);
}
public Node getContent() {
return content.get();
private void updateContent() {
if (getSkin() instanceof Skin skin) {
skin.updateContent();
}
}
private ObjectProperty<Node> content;
public ObjectProperty<Node> contentProperty() {
if (content == null)
content = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "content";
}
@Override
protected void invalidated() {
updateContent();
}
};
return content;
}
public void setContent(Node content) {
this.content.set(content);
public Node getContent() {
return contentProperty().get();
}
public boolean isLoading() {
return loading.get();
public void setContent(Node content) {
contentProperty().set(content);
}
private BooleanProperty loading;
public BooleanProperty loadingProperty() {
if (loading == null)
loading = new BooleanPropertyBase() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "loading";
}
@Override
protected void invalidated() {
updateContent();
}
};
return loading;
}
public void setLoading(boolean loading) {
this.loading.set(loading);
public boolean isLoading() {
return loading != null && loading.get();
}
public String getFailedReason() {
return failedReason.get();
public void setLoading(boolean loading) {
loadingProperty().set(loading);
}
private StringProperty failedReason;
public StringProperty failedReasonProperty() {
if (failedReason == null)
failedReason = new StringPropertyBase() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "failedReason";
}
@Override
protected void invalidated() {
updateContent();
}
};
return failedReason;
}
public void setFailedReason(String failedReason) {
this.failedReason.set(failedReason);
public String getFailedReason() {
return failedReason != null ? failedReason.get() : null;
}
public void setFailedReason(String failedReason) {
failedReasonProperty().set(failedReason);
}
private ObjectProperty<EventHandler<Event>> onFailedAction;
public final ObjectProperty<EventHandler<Event>> onFailedActionProperty() {
if (onFailedAction == null) {
onFailedAction = new ObjectPropertyBase<>() {
@Override
public Object getBean() {
return SpinnerPane.this;
}
@Override
public String getName() {
return "onFailedAction";
}
@Override
protected void invalidated() {
setEventHandler(FAILED_ACTION, get());
}
};
}
return onFailedAction;
}
public final EventHandler<Event> getOnFailedAction() {
return onFailedAction != null ? onFailedAction.get() : null;
}
public final void setOnFailedAction(EventHandler<Event> value) {
onFailedActionProperty().set(value);
}
public final EventHandler<Event> getOnFailedAction() {
return onFailedActionProperty().get();
}
private final ObjectProperty<EventHandler<Event>> onFailedAction = new SimpleObjectProperty<EventHandler<Event>>(this, "onFailedAction") {
@Override
protected void invalidated() {
setEventHandler(FAILED_ACTION, get());
}
};
@Override
protected SkinBase<SpinnerPane> createDefaultSkin() {
return new Skin(this);
}
private static final class Skin extends SkinBase<SpinnerPane> {
private final JFXSpinner spinner = new JFXSpinner();
private final StackPane contentPane = new StackPane();
private final StackPane topPane = new StackPane();
private final TransitionPane root = new TransitionPane();
private final StackPane failedPane = new StackPane();
private final Label failedReasonLabel = new Label();
@SuppressWarnings("FieldCanBeLocal") // prevent from gc.
private final InvalidationListener observer;
Skin(SpinnerPane control) {
super(control);
topPane.getChildren().setAll(spinner);
topPane.getStyleClass().add("notice-pane");
failedPane.getStyleClass().add("notice-pane");
failedPane.getChildren().setAll(failedReasonLabel);
FXUtils.onClicked(failedPane, () -> {
EventHandler<Event> action = control.getOnFailedAction();
if (action != null)
action.handle(new Event(FAILED_ACTION));
});
updateContent();
this.getChildren().setAll(root);
}
FXUtils.onChangeAndOperate(getSkinnable().content, newValue -> {
if (newValue == null) {
private StackPane contentPane;
private StackPane spinnerPane;
private StackPane failedPane;
private Label failedReasonLabel;
void updateContent() {
SpinnerPane control = getSkinnable();
Node nextContent;
if (control.isLoading()) {
if (spinnerPane == null) {
spinnerPane = new StackPane(new JFXSpinner());
spinnerPane.getStyleClass().add("notice-pane");
}
nextContent = spinnerPane;
} else if (control.getFailedReason() != null) {
if (failedPane == null) {
failedReasonLabel = new Label();
failedPane = new StackPane(failedReasonLabel);
failedPane.getStyleClass().add("notice-pane");
FXUtils.onClicked(failedPane, () -> control.fireEvent(new Event(SpinnerPane.FAILED_ACTION)));
}
failedReasonLabel.setText(control.getFailedReason());
nextContent = failedPane;
} else {
if (contentPane == null) {
contentPane = new StackPane();
}
Node content = control.getContent();
if (content != null)
contentPane.getChildren().setAll(content);
else
contentPane.getChildren().clear();
} else {
contentPane.getChildren().setAll(newValue);
}
});
getChildren().setAll(root);
observer = FXUtils.observeWeak(() -> {
if (getSkinnable().getFailedReason() != null) {
root.setContent(failedPane, ContainerAnimations.FADE);
failedReasonLabel.setText(getSkinnable().getFailedReason());
} else if (getSkinnable().isLoading()) {
root.setContent(topPane, ContainerAnimations.FADE);
} else {
root.setContent(contentPane, ContainerAnimations.FADE);
}
}, getSkinnable().loadingProperty(), getSkinnable().failedReasonProperty());
nextContent = contentPane;
}
if (nextContent != failedPane && failedReasonLabel != null) {
failedReasonLabel.setText(null);
}
root.setContent(nextContent, ContainerAnimations.FADE);
}
}
@@ -139,7 +139,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else {
handler.reject(e.getMessage());
}
}).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL);
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
} else {
// otherwise we still consider the file as modpack zip file
// since casually the url may not ends with ".zip"
@@ -156,7 +156,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else {
handler.reject(e.getMessage());
}
}).executor(true),
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL
);
@@ -162,6 +162,8 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
RemoteVersionListCell(VersionsPage control) {
this.control = control;
imageView.setMouseTransparent(true);
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
@@ -18,13 +18,13 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
@@ -48,6 +48,8 @@ import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
import org.jackhuang.hmcl.ui.construct.DialogPane;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.wizard.SinglePageWizardProvider;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -108,23 +110,39 @@ public final class JavaDownloadDialog extends StackPane {
}
private final class DownloadMojangJava extends DialogPane {
private final ToggleGroup toggleGroup = new ToggleGroup();
private final List<JFXCheckBox> checkboxes = new ArrayList<>();
DownloadMojangJava() {
setTitle(i18n("java.download.title"));
validProperty().bind(toggleGroup.selectedToggleProperty().isNotNull());
VBox vbox = new VBox(16);
Label prompt = new Label(i18n("java.download.prompt"));
vbox.getChildren().add(prompt);
setValid(false);
InvalidationListener updateValidStatus = e -> {
for (JFXCheckBox box : checkboxes) {
if (!box.isDisabled() && box.isSelected()) {
setValid(true);
return;
}
}
setValid(false);
};
for (GameJavaVersion version : supportedGameJavaVersions) {
JFXRadioButton button = new JFXRadioButton("Java " + version.majorVersion());
JFXCheckBox button = new JFXCheckBox("Java " + version.majorVersion());
button.setUserData(version);
vbox.getChildren().add(button);
toggleGroup.getToggles().add(button);
if (JavaManager.REPOSITORY.isInstalled(platform, version))
if (JavaManager.REPOSITORY.isInstalled(platform, version)) {
button.setDisable(true);
button.setSelected(true);
} else {
button.selectedProperty().addListener(updateValidStatus);
}
vbox.getChildren().add(button);
checkboxes.add(button);
}
setBody(vbox);
@@ -137,38 +155,52 @@ public final class JavaDownloadDialog extends StackPane {
setActions(warningLabel, acceptPane, cancelButton);
}
private Task<Void> downloadTask(GameJavaVersion javaVersion) {
return JavaManager.getDownloadJavaTask(downloadProvider, platform, javaVersion).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
Throwable resolvedException = resolveException(exception);
LOG.warning("Failed to download java", exception);
if (!(resolvedException instanceof CancellationException)) {
Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed"));
}
}
});
}
@Override
protected void onAccept() {
fireEvent(new DialogCloseEvent());
GameJavaVersion javaVersion = (GameJavaVersion) toggleGroup.getSelectedToggle().getUserData();
if (javaVersion == null)
List<GameJavaVersion> selectedVersions = new ArrayList<>();
for (JFXCheckBox box : checkboxes) {
if (!box.isDisabled() && box.isSelected()) {
selectedVersions.add((GameJavaVersion) box.getUserData());
}
}
if (selectedVersions.isEmpty())
return;
if (JavaManager.REPOSITORY.isInstalled(platform, javaVersion))
Controllers.confirm(i18n("download.java.override"), null, () -> {
Controllers.taskDialog(Task.supplyAsync(() -> JavaManager.REPOSITORY.getJavaExecutable(platform, javaVersion))
.thenComposeAsync(Schedulers.javafx(), realPath -> {
if (realPath != null) {
JavaManager.removeJava(realPath);
Task<Void> task = Task.allOf(selectedVersions.stream().map(javaVersion -> JavaManager.getDownloadJavaTask(downloadProvider, platform, javaVersion).wrapResult()).toList())
.whenComplete(Schedulers.javafx(), (results, exception) -> {
Throwable exceptionToDisplay;
if (exception == null) {
List<Throwable> exceptions = results.stream()
.filter(Result::isFailure)
.map(Result::getException)
.map(Lang::resolveException)
.filter(it -> !(it instanceof CancellationException))
.toList();
if (!exceptions.isEmpty()) {
if (exceptions.size() == 1) {
exceptionToDisplay = exceptions.get(0);
} else {
exceptionToDisplay = new IOException("Failed to download Java");
for (Throwable e : exceptions)
exceptionToDisplay.addSuppressed(e);
}
return downloadTask(javaVersion);
}), i18n("download.java"), TaskCancellationAction.NORMAL);
}, null);
else
Controllers.taskDialog(downloadTask(javaVersion), i18n("download.java.process"), TaskCancellationAction.NORMAL);
} else {
exceptionToDisplay = null;
}
} else {
exceptionToDisplay = exception;
}
if (exceptionToDisplay != null) {
LOG.warning("Failed to download java", exceptionToDisplay);
Controllers.dialog(DownloadProviders.localizeErrorMessage(exceptionToDisplay), i18n("install.failed"));
}
});
Controllers.taskDialog(task, i18n("download.java.process"), TaskCancellationAction.NORMAL);
}
}
@@ -64,12 +64,12 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.RemoteVersion;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import java.io.IOException;
import java.util.List;
@@ -320,6 +320,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
Task<?> task = versionList.refreshAsync("")
.thenSupplyAsync(() -> versionList.getVersions("").stream()
.filter(it -> it.getVersionType() == RELEASE)
.filter(it -> NativePatcher.checkSupportedStatus(GameVersionNumber.asGameVersion(it.getGameVersion()), Platform.SYSTEM_PLATFORM, OperatingSystem.SYSTEM_VERSION) != NativePatcher.SupportStatus.UNSUPPORTED)
.sorted()
.findFirst()
.orElseThrow(() -> new IOException("No versions found")))
@@ -551,6 +551,7 @@ public class TerracottaControllerPage extends StackPane {
ComponentSublist locals = new ComponentSublist();
var header = new LinePane();
header.getStyleClass().add("no-padding");
header.setLargeTitle(true);
header.setMinHeight(LinePane.USE_COMPUTED_SIZE);
header.setMouseTransparent(true);
@@ -22,7 +22,6 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
@@ -32,7 +31,6 @@ import javafx.scene.image.WritableImage;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.SVGPath;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.schematic.LitematicFile;
import org.jackhuang.hmcl.setting.Profile;
@@ -551,8 +549,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
private final HBox right;
private final ImageView iconImageView;
private final SVGPath iconSVG;
private final StackPane iconSVGWrapper;
private final SVGContainer iconSVGView;
private final Tooltip tooltip = new Tooltip();
@@ -568,15 +565,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
this.iconImageView = new ImageView();
FXUtils.limitSize(iconImageView, 32, 32);
this.iconSVG = new SVGPath();
iconSVG.getStyleClass().add("svg");
iconSVG.setScaleX(32.0 / SVG.DEFAULT_SIZE);
iconSVG.setScaleY(32.0 / SVG.DEFAULT_SIZE);
this.iconSVGWrapper = new StackPane(new Group(iconSVG));
iconSVGWrapper.setAlignment(Pos.CENTER);
FXUtils.setLimitWidth(iconSVGWrapper, 32);
FXUtils.setLimitHeight(iconSVGWrapper, 32);
this.iconSVGView = new SVGContainer(32);
BorderPane.setAlignment(left, Pos.CENTER);
root.setLeft(left);
@@ -638,8 +627,8 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
iconSVG.setContent(item.getIcon().getPath());
left.getChildren().setAll(iconSVGWrapper);
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
center.setTitle(item.getName());
@@ -103,7 +103,7 @@ public final class Versions {
i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
}
}).executor(true),
}),
i18n("message.downloading"),
TaskCancellationAction.NORMAL
);
@@ -0,0 +1,48 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import io.nayuki.qrcodegen.QrCode;
/// @author Glavo
public final class QrCodeUtils {
public static String toSVGPath(QrCode qr) {
return toSVGPath(qr, 0);
}
public static String toSVGPath(QrCode qr, int border) {
int actualSize = qr.size + border * 2;
var builder = new StringBuilder(qr.size * qr.size * 12);
builder.append('M').append(actualSize).append(' ').append(actualSize).append("ZM0 0Z");
for (int y = 0; y < qr.size; y++) {
for (int x = 0; x < qr.size; x++) {
if (qr.getModule(x, y)) {
if (x != 0 || y != 0)
builder.append(' ');
builder.append("M").append(x + border).append(',').append(y + border).append("h1v1h-1z");
}
}
}
return builder.toString();
}
private QrCodeUtils() {
}
}
+43 -4
View File
@@ -572,6 +572,41 @@
-fx-text-fill: -monet-on-surface;
}
/*******************************************************************************
* *
* Microsoft Login Dialog *
* *
******************************************************************************/
.microsoft-login-dialog:bodyonly {
-fx-padding: 0px 0px 0px 0px;
}
.method-title {
-fx-text-fill: -monet-on-surface;
-fx-font-weight: bold;
}
.method-desc {
-fx-text-fill: -monet-outline;
-fx-font-size: 0.9em;
-fx-line-spacing: 2;
-fx-wrap-text: true;
-fx-text-alignment: center;
}
.code-box {
-fx-background-color: -monet-surface-variant;
-fx-background-radius: 6;
-fx-padding: 0 10 0 10;
-fx-alignment: center;
}
.code-box .code-label {
-fx-font-size: 22px;
-fx-font-weight: bold;
-fx-text-fill: -monet-primary;
}
/*******************************************************************************
* *
@@ -1054,10 +1089,6 @@
-fx-font-size: 12;
}
.options-list-item > .line-component .line-component-container {
-fx-padding: 10 16 10 16;
}
.options-list-item .svg {
-fx-fill: -monet-on-surface;
-fx-border-color: -monet-outline-variant;
@@ -1887,6 +1918,14 @@
* *
******************************************************************************/
.line-component .line-component-container {
-fx-padding: 10 16 10 16;
}
.line-component.no-padding .line-component-container {
-fx-padding: 0;
}
.line-component:large-title .title-label {
-fx-font-size: 15px;
}
@@ -118,11 +118,11 @@ account.methods.microsoft.error.wrong_verify_method=Failed to log in. Please try
account.methods.microsoft.logging_in=Logging in...
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.manual=Please enter the code shown above on the pop-up webpage to complete the login.\n\
\n\
If the website fails to load, please open %s manually in your browser.\n\
\n\
<b>If your internet connection is bad, it may cause web pages to load slowly or fail to load altogether. You may try again later or switch to a different internet connection.</b>
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.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>
account.methods.microsoft.profile=Account Profile
account.methods.microsoft.purchase=Buy Minecraft
account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the <a href="https://hmcl.huangyuhui.net/download">official build</a> to log in.
@@ -141,7 +141,6 @@ account.methods.offline.uuid.hint=UUID is a unique identifier for Minecraft play
\n\
This option is for advanced users only. We do not recommend changing this option unless you know what you are doing.
account.methods.offline.uuid.malformed=Invalid format.
account.methods.forgot_password=Forgot Password
account.methods.ban_query=Ban Query
account.missing=No Accounts
account.missing.add=Click here to add one.
@@ -368,7 +367,6 @@ download.provider.balanced.desc=Balanced, but may not be the latest
download.provider.mirror=From Mirror
download.provider.mirror.desc=Fast, but may not be the latest
download.java=Downloading Java
download.java.override=This Java version already exists. Do you want to uninstall and reinstall it?
download.java.process=Java Download Process
download.javafx=Downloading dependencies for the launcher...
download.javafx.notes=We are currently downloading dependencies for HMCL from the Internet.\n\
@@ -478,7 +476,7 @@ game.crash.reason.analyzing=Analyzing...
game.crash.reason.multiple=Several reasons detected:\n\n
game.crash.reason.block=The game crashed because of a block in the world.\n\
\n\
You can try removing this block using MCEdit or deleting the mod that added it.\n\
You can try removing this block using the <a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">map editors</a> or deleting the mod that added it.\n\
\n\
Block Type: %1$s\n\
Location: %2$s
@@ -494,7 +492,7 @@ game.crash.reason.duplicated_mod=The game cannot continue to run because of dupl
Each mod can only be installed once. Please delete the duplicate mod and try again.
game.crash.reason.entity=The game crashed because of an entity in the world.\n\
\n\
You can try removing the entity using MCEdit or deleting the mod that added it.\n\
You can try removing the entity using the <a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">map editors</a> or deleting the mod that added it.\n\
\n\
Entity Type: %1$s\n\
Location: %2$s
@@ -120,7 +120,6 @@ account.methods.offline.uuid.hint=UUID هو معرف فريد للاعبي Minec
\n\
هذا الخيار للمستخدمين المتقدمين فقط. لا نوصي بتغيير هذا الخيار ما لم تعرف ما تفعله.
account.methods.offline.uuid.malformed=تنسيق غير صالح.
account.methods.forgot_password=نسيت كلمة المرور
account.methods.ban_query=استعلام الحظر
account.missing=لا توجد حسابات
account.missing.add=انقر هنا لإضافة واحد.
@@ -341,7 +340,6 @@ download.provider.official=من المصادر الرسمية
download.provider.balanced=من الأسرع المتاح
download.provider.mirror=من المرآة
download.java=تنزيل Java
download.java.override=هذا الإصدار من Java موجود بالفعل. هل تريد إلغاء التثبيت وإعادة التثبيت؟
download.java.process=عملية تنزيل Java
download.javafx=تنزيل التبعيات للمشغل...
download.javafx.notes=نحن نقوم حالياً بتنزيل تبعيات HMCL من الإنترنت.\n\
@@ -444,12 +442,6 @@ game.crash.info=معلومات التعطل
game.crash.reason=سبب التعطل
game.crash.reason.analyzing=جاري التحليل...
game.crash.reason.multiple=تم اكتشاف عدة أسباب:\n\n
game.crash.reason.block=تعطلت اللعبة بسبب كتلة في العالم.\n\
\n\
يمكنك محاولة إزالة هذه الكتلة باستخدام MCEdit أو حذف التعديل الذي أضافها.\n\
\n\
نوع الكتلة: %1$s\n\
الموقع: %2$s
game.crash.reason.bootstrap_failed=تعطلت اللعبة بسبب التعديل "%1$s".\n\
\n\
يمكنك محاولة حذفه أو تحديثه.
@@ -460,12 +452,6 @@ game.crash.reason.duplicated_mod=لا يمكن للعبة الاستمرار ف
%2$s\n\
\n\
يمكن تثبيت كل تعديل مرة واحدة فقط. يرجى حذف التعديل المكرر والمحاولة مرة أخرى.
game.crash.reason.entity=تعطلت اللعبة بسبب كيان في العالم.\n\
\n\
يمكنك محاولة إزالة الكيان باستخدام MCEdit أو حذف التعديل الذي أضافه.\n\
\n\
نوع الكيان: %1$s\n\
الموقع: %2$s
game.crash.reason.modmixin_failure=تعطلت اللعبة لأن بعض التعديلات فشلت في الحقن.\n\
\n\
هذا يعني عموماً أن التعديل به خطأ أو غير متوافق مع البيئة الحالية.\n\
@@ -123,7 +123,6 @@ account.methods.offline.uuid.hint=UUID es un identificador único para los jugad
\n\
Esta opción es sólo para usuarios avanzados. No recomendamos editar esta opción a menos que sepas lo que estás haciendo.
account.methods.offline.uuid.malformed=Formato no válido.
account.methods.forgot_password=Olvidé mi contraseña
account.methods.ban_query=Consulta de bloqueo de cuentas
account.missing=No hay cuentas
account.missing.add=Haga clic aquí para añadir una.
@@ -346,7 +345,6 @@ download.provider.official=De fuentes oficiales
download.provider.balanced=De la fuente más rápida disponible
download.provider.mirror=Desde espejo
download.java=Descargando Java
download.java.override=Esta versión de Java ya existe. ¿Desea desinstalarla y volver a instalarla?
download.java.process=Proceso de descarga Java
download.javafx=Descargando dependencias para el launcher...
download.javafx.notes=Estamos descargando dependencias para HMCL desde Internet.\n\
@@ -450,12 +448,6 @@ game.crash.info=Información de colgado
game.crash.reason=Motivo del colgado
game.crash.reason.analyzing=Analizando...
game.crash.reason.multiple=Se han detectado varias razones:\n\n
game.crash.reason.block=El juego se ha colgado debido a un bloqueo en el mundo.\n\
\n\
Puedes intentar eliminar este bloque usando MCEdit o borrando el mod que lo añadió.\n\
\n\
Tipo de bloque: %1$s\n\
Ubicación: %2$s
game.crash.reason.bootstrap_failed=El juego se ha colgado debido al mod «%1$s».\n\
\n\
Puedes intentar borrarlo o actualizarlo.
@@ -469,12 +461,6 @@ game.crash.reason.duplicated_mod=El juego no puede iniciarse debido a la existen
%2$s\n\
\n\
Cada mod sólo puede instalarse una vez, por favor, elimine el mod duplicado e inténtelo de nuevo.
game.crash.reason.entity=El juego se ha colgado debido a una entidad en el mundo.\n\
\n\
Puedes intentar eliminar la entidad usando MCEdit o borrando el mod que la añadió.\n\
\n\
Tipo de entidad: %1$s\n\
Ubicación: %2$s
game.crash.reason.fabric_version_0_12=La versión de Fabric Loader 0.12 o posterior es incompatible con los mods instalados actualmente. Necesitas bajarlo a la versión 0.11.7.
game.crash.reason.fabric_warnings=El cargador de Fabric Loader ha advertido:\n\
\n\
@@ -91,7 +91,6 @@ account.methods.microsoft.birth=誕生日の設定を編集する方法...
account.methods.microsoft.deauthorize=アカウントのバインドを解除
account.methods.microsoft.close_page=Microsoftアカウントの認証が終了しました。後で終了するログイン手順がいくつか残っています。このページを今すぐ閉じることができます。
account.methods.microsoft.logging_in=ログイン...
account.methods.forgot_password=パスワードをお忘れの方
account.methods.microsoft.makegameidsettings=プロファイルを作成/プロフィール名を編集する
account.methods.microsoft.profile=アカウントプロファイル..
account.methods.microsoft.purchase=Minecraftを購入する
@@ -349,13 +348,11 @@ game.crash.info=ゲームステータス
game.crash.reason=クラッシュアナライザー
game.crash.reason.analyzing=分析中..
game.crash.reason.multiple=複数の理由が検出されました:\n\n
game.crash.reason.block=ブロックが原因でゲームを実行できません。\nMCEditでこのブロックを削除するか、関連するModを直接削除できます。\nブロックタイプ:%1$s\nブロックの場所:%2$s
game.crash.reason.bootstrap_failed=mod %1$s がクラッシュしたため、ゲームを実行できません。\n削除または更新を試みることができます。
game.crash.reason.config=modが構成ファイルを解析できないため、ゲームを実行できません。\nMod %1$s は構成ファイル %2$s を解析できません。
game.crash.reason.debug_crash=クラッシュが手動でトリガーされるため、ゲームを実行できません。\n実際、ゲームは無実であり、すべてあなたの責任です。
game.crash.reason.duplicated_mod=Mod %1$s が重複してインストールされているため、現在のゲームを継続して実行できません。\n%2$s\n各Modは1つしかインストールできませんので、余分なModを削除して再度お試しください。
game.crash.reason.mixin_apply_mod_failed=Mixin が %1$s モジュールを適用できないため、現在のゲームを続行できません。\n問題を解決するために、このモジュールを削除または更新してみてください。
game.crash.reason.entity=エンティティが原因でゲームを実行できません。\nMCEditでこのエンティティを削除するか、関連するModを直接削除できます。\nエンティティタイプ:%1$s\nエンティティの場所:%2$s
game.crash.reason.fabric_version_0_12=Fabric 0.12以降は、現在インストールされているmodと互換性がありません。ファブリックを0.11.7にダウングレードする必要があります。
game.crash.reason.fabric_warnings=Fabricはいくつかの警告を出します:\n%1$s
game.crash.reason.modmixin_failure=mod インジェクションの失敗により、現在のゲームを続行できません。\nこれは通常、MOD にバグがあるか、現在の環境と互換性がないことを意味します。\n間違った mod のログを確認できます。
@@ -110,7 +110,6 @@ account.methods.microsoft.logging_in=方登入……
account.methods.microsoft.makegameidsettings=添檔 / 書檔之名
account.methods.microsoft.profile=纂戶簿訊息
account.methods.microsoft.purchase=買礦藝
account.methods.forgot_password=亡符節
account.methods.ban_query=檢戶簿羈否
account.methods.microsoft.snapshot.website=官網
account.methods.offline=離綫之式
@@ -345,7 +344,6 @@ download.provider.balanced.desc=均,容有舊
download.provider.mirror=先別源
download.provider.mirror.desc=至快,容有舊
download.java=引爪哇
download.java.override=爪哇是版既現,除而復裝諸?
download.java.process=引爪哇
download.javafx=甫引行需諸案……
download.javafx.notes=方引 HMCL 所需諸案。\n擊「迭引源」以閲詳,并迭之。擊「罷」以止而退。\n誡:引之允滯,宜迭其源。
@@ -410,14 +408,14 @@ game.crash.feedback=<b>無錄是頁以示人!</b>宜擊<b>「導崩記」</b>
game.crash.info=戲訊
game.crash.reason=潰退之由
game.crash.reason.analyzing=方求之焉……
game.crash.reason.block=塊謬而崩。\n或操 <a href="https://www.mcedit.net">MCEdit</a> 修檔以去之,抑徑去改囊之有干者。\n塊類:%1$s\n塊位:%2$s
game.crash.reason.block=塊謬而崩。\n或操輿圖纂具修檔以去之,抑徑去改囊之有干者。\n塊類:%1$s\n塊位:%2$s\n\n輿圖纂具薦:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英吉利語大典</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-tw#地图编辑工具">漢語大典</a>
game.crash.reason.bootstrap_failed=改囊「%1$s」謬而崩。\n或抑廢之。
game.crash.reason.mixin_apply_mod_failed=「%1$s」不能旁嵌而崩。\n或廢之,或新之。
game.crash.reason.config=置設之案謬而崩。\n改囊「%1$s」之置設「%2$s」有謬。
game.crash.reason.multiple=其所由也衆:\n\n
game.crash.reason.debug_crash=手崩也。\n戲事無誤,乃君致之!
game.crash.reason.duplicated_mod=改囊「%1$s」復而崩。\n%2$s\n改囊不能再載。宜廢冗者。
game.crash.reason.entity=實體謬而崩。\n或操 <a href="https://www.mcedit.net">MCEdit</a> 修檔以去之,抑徑去改囊之有干者。\n實體類:%1$s\n實體位:%2$s
game.crash.reason.entity=實體謬而崩。\n或操輿圖纂具修檔以去之,抑徑去改囊之有干者。\n實體類:%1$s\n實體位:%2$s\n\n輿圖纂具薦:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英吉利語大典</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-tw#地图编辑工具">漢語大典</a>
game.crash.reason.fabric_version_0_12=改囊或弗見容於緞載之〇點一二與愈晉者。宜退乎〇點一一點七。
game.crash.reason.fabric_warnings=緞報曰:\n%1$s
game.crash.reason.file_already_exists=案「%1$s」既存而謬。\n誠以爲可去,宜另備而去之,然後復行。
@@ -122,7 +122,6 @@ account.methods.offline.name.invalid=Для имени пользователя
account.methods.offline.uuid=UUID
account.methods.offline.uuid.hint=UUID - это уникальный идентификатор игрового персонажа в Minecraft. Способ генерации UUID различается в разных лаунчерах игр. Изменение UUID на тот, который генерируется другим лаунчером гарантирует, что игровые блоки/предметы в рюкзаке вашего офлайн аккаунта останутся. Этот параметр предназначен для экспертов. Если вы не знаете что делаете, мы не советуем вам изменять этот параметр. Эта опция не требуется для присоединения к серверам.
account.methods.offline.uuid.malformed=Недопустимый формат.
account.methods.forgot_password=Забыли пароль
account.methods.ban_query=Просмотреть аккаунт заблокирован
account.missing=Нет аккаунтов
account.missing.add=Нажмите здесь, чтобы добавить.
@@ -343,7 +342,6 @@ download.provider.official=Из официальных источников
download.provider.balanced=Из самых быстрых доступных
download.provider.mirror=Из зеркала
download.java=Скачивание Java
download.java.override=Эта версия Java уже существует. Вы хотите удалить и установить ее заново?
download.java.process=Процесс скачивания Java
download.javafx=Скачивание зависимостей лаунчера...
download.javafx.notes=Скачивание зависимостей для лаунчер из Интернета.\n\
@@ -444,12 +442,6 @@ game.crash.info=Сведения об отказе
game.crash.reason=Анализатор сбоев
game.crash.reason.analyzing=Анализирование...
game.crash.reason.multiple=Обнаружено несколько причин:\n\n
game.crash.reason.block=Игра отказала из-за блока в мире.\n\
\n\
Удалите этот блок с помощью MCEdit или удалите мод связанный с ним напрямую.\n\
\n\
Тип блока: %1$s\n\
Расположение блока: %2$s
game.crash.reason.bootstrap_failed=Игра отказала из-за мода «%1$s».\n\
\n\
Попробуйте удалить или обновить его.
@@ -460,12 +452,6 @@ game.crash.reason.duplicated_mod=Не удалось продолжить зап
%2$s\n\
\n\
Каждый мод должен быть установлен только один, удалите дублирующий мод и попробуйте снова.
game.crash.reason.entity=Игра отказала из-за сущность в мире.\n\
\n\
Удалите эту сущность с помощью MCEdit или удалите мод связанный с ним напрямую.\n\
\n\
Тип сущность: %1$s\n\
Расположение сущность: %2$s
game.crash.reason.fabric_version_0_12=Fabric 0.12 (и выше) несовместим с установленными модами. Следует понизить версию fabric до 0.11.7.
game.crash.reason.mixin_apply_mod_failed=Игра отказала из-за невозможности применить mixin к моду «%1$s».\n\
\n\
@@ -120,7 +120,6 @@ account.methods.offline.uuid.hint=UUID - це унікальний іденти
\n\
Ця опція призначена лише для досвідчених користувачів. Ми не рекомендуємо змінювати цю опцію, якщо ви не знаєте, що робите.
account.methods.offline.uuid.malformed=Недійсний формат.
account.methods.forgot_password=Забули пароль
account.methods.ban_query=Запит блокування
account.missing=Немає облікових записів
account.missing.add=Натисніть тут, щоб додати.
@@ -342,7 +341,6 @@ download.provider.official=З офіційних джерел
download.provider.balanced=З найшвидшого доступного
download.provider.mirror=З дзеркала
download.java=Завантаження Java
download.java.override=Ця версія Java вже існує. Бажаєте видалити та перевстановити її?
download.java.process=Процес завантаження Java
download.javafx=Завантаження залежностей для лаунчера...
download.javafx.notes=Ми зараз завантажуємо залежності для HMCL з Інтернету. \n\
@@ -429,12 +427,6 @@ game.crash.info=Інформація про збій
game.crash.reason=Причина збою
game.crash.reason.analyzing=Аналіз...
game.crash.reason.multiple=Виявлено кілька причин:\n\n
game.crash.reason.block=Гра зазнала збою через блок у світі. \n\
\n\
Ви можете спробувати видалити цей блок за допомогою MCEdit або видалити мод, який його додав. \n\
\n\
Тип блоку: %1$s \n\
Місцезнаходження: %2$s
game.crash.reason.bootstrap_failed=Гра зазнала збою через мод "%1$s". \n\
\n\
Ви можете спробувати видалити або оновити його.
@@ -443,10 +435,6 @@ game.crash.reason.debug_crash=Гра зазнала збою, оскільки
game.crash.reason.duplicated_mod=Гра не може продовжувати роботу через дублікати модів "%1$s". \n\
%2$s \n\
Кожен мод можна встановити лише один раз. Видаліть дублікат мода та спробуйте знову.
game.crash.reason.entity=Гра зазнала збою через сутність у світі. \n\
Ви можете спробувати видалити сутність за допомогою MCEdit або видалити мод, який її додав. \n\
Тип сутності: %1$s \n\
Місцезнаходження: %2$s
game.crash.reason.modmixin_failure=Гра зазнала збою, оскільки деякі моди не вдалося ввести. \n\
Це, як правило, означає, що мод має помилку або несумісний з поточним середовищем. \n\
Ви можете перевірити журнал, щоб знайти помилковий мод.
@@ -117,12 +117,13 @@ account.methods.microsoft.error.wrong_verify_method=登入失敗。請在 Micros
account.methods.microsoft.logging_in=登入中……
account.methods.microsoft.makegameidsettings=建立檔案 / 編輯檔案名稱
account.methods.microsoft.hint=點擊「登入」按鈕開始新增 Microsoft 帳戶。
account.methods.microsoft.manual=請在彈出的網頁中輸入上方顯示的代碼以完成登入。\n\
若網站未能顯示,請手動在瀏覽器中開啟:%s\n\
<b>若網路環境不佳,可能會導致網頁載入緩慢甚至無法載入,請稍後再試或更換網路環境後再試。</b>\n
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.browser=在瀏覽器中登入
account.methods.microsoft.methods.browser.hint=點擊「登入」按鈕或者<a href="%s">複製連結</a>並在瀏覽器中貼上以登入。
account.methods.microsoft.manual=<b>若網路環境不佳,可能會導致網頁載入緩慢甚至無法載入,請稍後再試或更換網路環境後再試。</b>
account.methods.microsoft.profile=編輯帳戶個人資訊
account.methods.microsoft.purchase=購買 Minecraft
account.methods.forgot_password=忘記密碼
account.methods.ban_query=查詢帳戶是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。請下載 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 進行登入。
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。請下載官方版本來重新整理帳戶。
@@ -362,7 +363,6 @@ download.provider.balanced.desc=平衡,但可能不是最新
download.provider.mirror=盡量使用鏡像源
download.provider.mirror.desc=載入快,但可能不是最新
download.java=下載 Java
download.java.override=此 Java 版本已經存在,是否移除並重新安裝?
download.java.process=下載 Java
download.javafx=正在下載必要的執行時元件
download.javafx.notes=正在透過網路下載 HMCL 必要的執行時元件。\n點擊「切換下載源」按鈕查看詳情以及選取下載源。點擊「取消」按鈕停止並退出。\n注意:如果下載速度過慢,請嘗試切換下載源。
@@ -435,13 +435,13 @@ game.crash.info=遊戲訊息
game.crash.reason=崩潰原因
game.crash.reason.analyzing=分析中……
game.crash.reason.multiple=檢測到多個原因:\n\n
game.crash.reason.block=目前遊戲由於某個方塊不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該方塊,或者直接刪除對應的模組。\n方塊類型:%1$s\n方塊坐標:%2$s
game.crash.reason.block=目前遊戲由於某個方塊不能正常工作,無法繼續執行。\n你可以嘗試使用地圖編輯工具編輯世界刪除該方塊,或者直接刪除對應的模組。\n方塊類型:%1$s\n方塊坐標:%2$s\n\n相關工具:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英文 Wiki</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-tw#地图编辑工具">中文 Wiki</a>
game.crash.reason.bootstrap_failed=目前遊戲由於模組「%1$s」出現問題,無法繼續執行。\n你可以嘗試刪除或更新該模組以解決問題。
game.crash.reason.mixin_apply_mod_failed=目前遊戲由於 Mixin 無法應用於「%1$s」模組,無法繼續執行。\n你可以嘗試刪除或更新該模組以解決問題。
game.crash.reason.config=目前遊戲由於無法解析模組配置檔案,無法繼續執行\n模組「%1$s」的配置檔案「%2$s」無法被解析。
game.crash.reason.debug_crash=目前遊戲由於手動觸發崩潰,無法繼續執行。\n事實上遊戲並沒有問題,問題都是你造成的!
game.crash.reason.duplicated_mod=目前遊戲由於模組重複安裝,無法繼續執行。\n%s\n每種模組只能安裝一個,請你刪除多餘的模組再試。
game.crash.reason.entity=目前遊戲由於某個實體不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該實體,或者直接刪除對應的模組。\n實體類型:%1$s\n實體坐標:%2$s
game.crash.reason.entity=目前遊戲由於某個實體不能正常工作,無法繼續執行。\n你可以嘗試使用地圖編輯工具編輯世界刪除該實體,或者直接刪除對應的模組。\n實體類型:%1$s\n實體坐標:%2$s\n\n相關工具:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英文 Wiki</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-tw#地图编辑工具">中文 Wiki</a>
game.crash.reason.fabric_version_0_12=Fabric Loader 0.12 及更高版本與目前已經安裝的模組可能不相容,你需要將 Fabric Loader 降級至 0.11.7。
game.crash.reason.fabric_warnings=Fabric 提供了一些警告訊息:\n%1$s
game.crash.reason.modmixin_failure=目前遊戲由於某些模組注入失敗,無法繼續執行。\n這一般代表著該模組存在問題,或與目前環境不相容。\n你可以查看日誌尋找出錯模組。
@@ -118,13 +118,14 @@ account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软
account.methods.microsoft.logging_in=登录中……
account.methods.microsoft.makegameidsettings=创建档案 / 编辑档案名称
account.methods.microsoft.hint=点击“登录”按钮开始添加微软账户。
account.methods.microsoft.manual=请在弹出的网页中输入上方显示的代码以完成登录。\n\
若网站未能显示,请手动在浏览器中打开:%s\n\
<b>若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。</b>\n\
account.methods.microsoft.methods.device=扫描二维码登录
account.methods.microsoft.methods.device.hint=扫描二维码或访问 <a href="%s">%s</a>,在打开的页面中输入 <b>%s</b> 完成登录。
account.methods.microsoft.methods.browser=在浏览器登录
account.methods.microsoft.methods.browser.hint=点击“登录”按钮或者<a href="%s">复制链接</a>并在浏览器中粘贴以登录。
account.methods.microsoft.manual=<b>若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。</b>\n\
如遇到问题,你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.profile=编辑账户个人信息
account.methods.microsoft.purchase=购买 Minecraft
account.methods.forgot_password=忘记密码
account.methods.ban_query=检测账户是否被封禁
account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL。请下载 <a href="https://hmcl.huangyuhui.net/download">官方版本</a> 来登录微软账户。
account.methods.microsoft.snapshot.tooltip=你正在使用第三方提供的 HMCL。请下载官方版本来刷新账户。
@@ -364,7 +365,6 @@ download.provider.balanced.desc=平衡,但可能不是最新
download.provider.mirror=尽量使用镜像源
download.provider.mirror.desc=加载快,但可能不是最新
download.java=下载 Java
download.java.override=此 Java 版本已经存在,是否卸载并重新安装?
download.java.process=下载 Java
download.javafx=正在下载必要的运行时组件……
download.javafx.notes=正在通过网络下载 HMCL 必要的运行时组件。\n点击“切换下载源”按钮查看详情以及选择下载源。点击“取消”按钮停止并退出。\n注意:若下载速度过慢,请尝试切换下载源。
@@ -438,14 +438,14 @@ game.crash.feedback=<b>请不要将本界面截图或拍照给他人!</b>如
game.crash.info=游戏信息
game.crash.reason=崩溃原因
game.crash.reason.analyzing=分析中……
game.crash.reason.block=当前游戏由于某个方块不能正常工作,无法继续运行。\n你可以尝试使用 <a href="https://www.mcedit.net">MCEdit</a> 工具编辑世界删除该方块,或者直接删除对应的模组。\n方块类型:%1$s\n方块坐标:%2$s
game.crash.reason.block=当前游戏由于某个方块不能正常工作,无法继续运行。\n你可以尝试使用地图编辑工具编辑世界删除该方块,或者直接删除对应的模组。\n方块类型:%1$s\n方块坐标:%2$s\n\n相关工具:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英文 Wiki</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-cn#地图编辑工具">中文 Wiki</a>
game.crash.reason.bootstrap_failed=当前游戏由于模组“%1$s”出现问题,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
game.crash.reason.mixin_apply_mod_failed=当前游戏由于 Mixin 无法应用于“%1$s”模组,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
game.crash.reason.config=当前游戏由于无法解析模组配置文件,无法继续运行。\n无法解析模组“%1$s”的配置文件“%2$s”。
game.crash.reason.multiple=检测到多个原因:\n\n
game.crash.reason.debug_crash=当前游戏由于手动触发崩溃,无法继续运行。\n事实上游戏并没有问题,问题都是你造成的!
game.crash.reason.duplicated_mod=当前游戏由于模组“%1$s”重复安装,无法继续运行。\n%2$s\n每种模组只能安装一个,请你删除多余的模组再试。
game.crash.reason.entity=当前游戏由于某个实体不能正常工作,无法继续运行。\n你可以尝试使用 <a href="https://www.mcedit.net">MCEdit</a> 工具编辑世界删除该实体,或者直接删除对应的模组。\n实体类型:%1$s\n实体坐标:%2$s
game.crash.reason.entity=当前游戏由于某个实体不能正常工作,无法继续运行。\n你可以尝试使用地图编辑工具编辑世界删除该实体,或者直接删除对应的模组。\n实体类型:%1$s\n实体坐标:%2$s\n\n相关工具:<a href="https://minecraft.wiki/w/Tutorial:Programs_and_editors/Mapping#Map_editors">英文 Wiki</a> | <a href="https://zh.minecraft.wiki/w/辅助程序与编辑器/地图工具?variant=zh-cn#地图编辑工具">中文 Wiki</a>
game.crash.reason.fabric_version_0_12=Fabric Loader 0.12 及更高版本与当前已经安装的模组可能不兼容,你需要将 Fabric Loader 降级至 0.11.7。
game.crash.reason.fabric_warnings=Fabric 提供了一些警告信息:\n%1$s
game.crash.reason.file_already_exists=当前游戏由于文件“%1$s”已经存在,无法继续运行。\n如果你认为这个文件可以删除,你可以在备份这个文件后尝试删除它,并重新启动游戏。
@@ -19,20 +19,135 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<html lang="%lang%">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello Minecraft! Launcher</title>
<style>
%style%
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--monet-surface);
color: var(--monet-on-surface);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
user-select: none;
cursor: default;
}
.dialog-card {
background-color: var(--monet-surface-container-high);
padding: 24px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.02);
max-width: 560px;
width: 90%;
display: flex;
flex-direction: row;
gap: 16px;
}
.icon-column {
flex-shrink: 0;
padding-top: 4px;
}
.icon-column svg {
width: 40px;
height: 40px;
display: block;
fill: var(--monet-on-surface);
}
.content-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-size: 20px;
line-height: 1.4;
font-weight: 500;
color: var(--monet-on-surface);
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
line-height: 1.5;
color: var(--monet-on-surface-variant);
word-wrap: break-word;
margin-bottom: 24px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn-confirm {
background-color: transparent;
color: var(--monet-primary);
border: none;
padding: 0 16px;
height: 40px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 0.2s;
outline: none;
min-width: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-confirm:hover {
background-color: rgba(128, 128, 128, 0.08);
}
.btn-confirm:active {
background-color: rgba(128, 128, 128, 0.12);
}
</style>
</head>
<body>
<div>
%close-page%
<div class="dialog-card">
<div class="icon-column">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8"/>
</svg>
</div>
<script>
setTimeout(function() {
open("about:blank","_self").close();
}, 5000);
</script>
</body>
<div class="content-column">
<div class="title">%success%</div>
<div class="subtitle">%close_page%</div>
<div class="actions">
<button class="btn-confirm" onclick="closeWindow()">%ok%</button>
</div>
</div>
</div>
<script>
function closeWindow() {
window.close();
window.open("about:blank", "_self").close();
}
setTimeout(closeWindow, 3000);
</script>
</body>
</html>
+21 -21
View File
@@ -1,63 +1,63 @@
{
"__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!",
"version_latest": "0.4.1",
"version_latest": "0.4.2",
"packages": {
"windows-x86_64": {
"hash": "4693fec29bb54a0bb1a1a8a263a935f1673b8f76e84d125974cf259f1912a195beab4dfd04c23cae592cf71b116e82ecd48828b1445ab75c980c8cd79c777d21",
"hash": "6a98f524d4f00373696517306af8aa50d01d55ce4eadb27e9e4bc2f882707a0b5f20d5d4c33371d1459dcf5bf144ffed9beb414202d9ccf32b11dbbfcf19d650",
"files": {
"VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf",
"terracotta-0.4.1-windows-x86_64.exe": "3d29f7deb61b8b87e14032baecad71042340f6259d7ff2822336129714470e1a951cd96ee5d6a8c9312e7cf1bb9bb9d1cbf757cfa44d2b2f214362c32ea03b5b"
"terracotta-0.4.2-windows-x86_64.exe": "6e98d1f2380ed22fb5a2dd4aafce6c773e9cf69100c8bb8e49e7d6983756bdb9a31f80e06bcfbe5a2742144fe806d3d687dec54d8f09d87c659341f99dd9fd80"
}
},
"windows-arm64": {
"hash": "6c68da53142cc92598a9d3b803f110c77927ee7b481e2f623dfab17cd97fee4a58378e796030529713d3e394355135cc01d5f5d86cef7dbd31bbf8e913993d4c",
"hash": "fc1077247014ac0c712469498bde2ef7f6d881d5fcb7bdd5e11ebe20218fed365be19afdb8d453a79d77b729f866058522b910741767f4df947faa891434b463",
"files": {
"VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8",
"terracotta-0.4.1-windows-arm64.exe": "d2cf0f29aac752d322e594da6144bbe2874aabde52b5890a6f04a33b77a377464cbf3367e40de6b046c7226517f48e23fd40e611198fcaa1f30503c36d99b20c"
"terracotta-0.4.2-windows-arm64.exe": "30a15c5c53e5817c5a3634532172559327474741d3b2c7ef4e8a30acc6f59cdcf3570bf5f583e3cbe9e2abc8253e977c1abda1e9f36c88c4e99240da257347d0"
}
},
"macos-x86_64": {
"hash": "c247ab9023cf47231f3a056ddf70fe823e504c19ce241bf264c0a3cf2981c044b236dc239f430afb2541445d1b61d18898f8af5e1c063f31fb952bdfbea4aff5",
"hash": "a762e4b2d6f84e899292b9e3856d009411a516d3c47f54575f843ce082f63dff2baa68ba0faa844b8b64fb12e91017386f15f5e7f975f8ee605bf8d4217cb091",
"files": {
"terracotta-0.4.1-macos-x86_64": "dd2cde70a950849498425b8b00c17fb80edb8dd9bc0312da4885d97a28a4959d87782bd2202ef7f71bdbf2a92609089585c9e3faf24c10df6150e221e111a764",
"terracotta-0.4.1-macos-x86_64.pkg": "6057d5b4ea93da45796277a20ddaea7850e8c66326ded769f20fff74e132b453d6d615d937fc8f1d62b0b36b26961df5afb76535aecf85f25d2160889225ac6d"
"terracotta-0.4.2-macos-x86_64": "24efb85390eff88a538ed7e503fb1488e5e622730ca30c741a0e8b4c8f4e8d4868a2f9f38da8de540aeb535af2fd1e41c7081dae9c700e8a1a03b6c540218164",
"terracotta-0.4.2-macos-x86_64.pkg": "0f80437061231018ec0f860f875aba02cae9e0b36d21f2db8e99d25948806e1119f7844a4d4acca01d6124d7079718762cdebe3756b005a55632de7029fe0d24"
}
},
"macos-arm64": {
"hash": "0ddf48a44ea2563c37707caea8381ad3c69da73dd443d07dd98fe630f4e24ccda6f1fcdc9207a66bf61758b887a7b47186883ccd12bcb6b68387d8d756873f44",
"hash": "09e444fea2d9fd19f3e5cb62e29055228345be163924cbd408d947646fafed1012cf48508ee6a155ede3d571e2ffaa72d09ceeb1493c8a60feb05e0699f19ba3",
"files": {
"terracotta-0.4.1-macos-arm64": "ddc335ee082b4c0323314d482933810fc2d377cfc131a9aa978b54f245f1bed8973139edf7cf94f7ae6c04660dfe18d275b1a81638781e482b4ff585f351eed9",
"terracotta-0.4.1-macos-arm64.pkg": "5cbb41a450f080234b66fa264351bd594e3f6ef1599c96f277baa308e34dd26caefa3a34b3d65e972bc20868f2d4a661e8b3910d4b0311a374c6ac680bdccf8f"
"terracotta-0.4.2-macos-arm64": "8e59a9d78acd57702dc044d6f2799c6af586b075a262f7d4dbbf0876e1af8d8271e04783c24ff820b801e3b14cd0190ab8403d097f3f2d98b6d911f95ed1e972",
"terracotta-0.4.2-macos-arm64.pkg": "b0b72f8883767c359f0700e958bd859dd5932c4674fbdf2a32f7de189fb9f54822d4825a754dc856416c8760338eb8ffca80b40c0a57c608fbe6ae9928888993"
}
},
"linux-x86_64": {
"hash": "c00b0622203c1610a8e72bde5230fca6fe56cf1f45dc9bc7e857350f3a050d442c814084758090799b9a5b875730fa666539ee75bec706c05d9338ea208301eb",
"hash": "d326ad95815d04568d485b5038e40ffc47ca54292fa0925eee6f5cea014024f901d661708aac2a743037b990882ad82b4d0b7bb03dc3b2fe720dbf0f3efe1c98",
"files": {
"terracotta-0.4.1-linux-x86_64": "e53a9d8ec085ef7a7816b3da374e5c88fced2cf8319d447be3226422d01f9d7ee2019e403eafe21082135050652b2788b7d9520cc432c8d08931930b99595ed7"
"terracotta-0.4.2-linux-x86_64": "fac328ba8957a711b03557bb913940f22d61b76608cd203fdf51024b6f94b19f5bc91c9b8a9fa80baf6968e1e6873c1880fd4cf54a2f8e3c6cf1e6ac161f8d0c"
}
},
"linux-arm64": {
"hash": "4d661e1123ca050980960fe2d32b8d93a2c3ba807748569a45f16050fb1b52089bfc64c2dd64aba652dfed0e4ac3fba1ff9518cc3f95598c99fc71d93552b589",
"hash": "57c08f48d9535e93ad547d2dfc852d267992cc164a7208b42a2da0a6cbc2f21862f610e02a746b4b67150f4dec26b86a4f96eb9bd2f58d124d5b40ba50c6d55e",
"files": {
"terracotta-0.4.1-linux-arm64": "d4ccf6ff8c2fac060fecaa3c8368677f9342e283f2435493b3e259e922ee0bb0b08f15a292bf91898800a462336c64d0dee7b73f03c1752e5b0988a05adb3b52"
"terracotta-0.4.2-linux-arm64": "d807744c2041c98686e4b505324713badea7a0f31e8810be49ae053a63fb6dfc474ac58d678fb93eea0dd5cccff7372d9ec6135a1046f4b306cad35cd90ecacd"
}
},
"linux-loongarch64": {
"hash": "8297bb37617e9e7ce282fc553c5b14c84a900bcff4d025be31fd4a4da8b3943d040afc6143aa17de9a88e5fa29af7254d38db8ae6418ee539c2301632448da09",
"hash": "a19f4ad4fa1fd2f3c1c867f8743178ab0524d1634d63e122ffc080623e4f14207ecab58830fa0d888696ef28ce4cfa189e6ffbe92c6e1aa1836652617a96a801",
"files": {
"terracotta-0.4.1-linux-loongarch64": "ffdf7582d095307b91ddfc3e5d0daa78d434e4e34916d0cdf1520ae74b188fe5a48307047bf2da9a526eb725fe80cf230a93001bc8199d236b9cf28a1beaa6e9"
"terracotta-0.4.2-linux-loongarch64": "7d4896b275207def0861e544cf2afc9501c1946f1bc8bb2bad9ef4510dd915eabb63eeedff725e69565cdf5c27c146b52dd75d24115d5141909c0865f89cd7b9"
}
},
"linux-riscv64": {
"hash": "092f863885205107525e7ccb0e18977f6fd3018910ca5819772ec741dd8cffee52cc352a44b928bd2ba99ab881adaadff9e3bf4bf283f7384a35fea14becb0b4",
"hash": "ef3dfcf3c64dfcaf94adc518f0184d017a32a924f67ef33ffb8128f20669cf177cd345df3be8c00225a275bd44f70657b94fb43c60afeb21238000ac7a7f1721",
"files": {
"terracotta-0.4.1-linux-riscv64": "a6e5d70ddc433bf804764b69e8c204a6a428ece22b9d8ab713ed339fb81bfa1d29daeb6bdfd62c85ff193396315f96172f4a28925e5a4efc45f7d6fa868782a9"
"terracotta-0.4.2-linux-riscv64": "afddc55f16f5cf3103548d13f7a3238964c8899dba8eb0e1fdc9e354da65e91fa4486de4b8680a7a3d9326eacf18c1ddb6b5b1f9459908f35f77b36f769172ea"
}
},
"freebsd-x86_64": {
"hash": "288bd7a97b2e2c5fb3c7d8107ed1311bc881fb74cf92da40b6c84156239d0832d2306b74b1e93a683188a5db896a6506326c6a7a4ac0ab2e798fa4f1f00787f0",
"hash": "a4d02ebbb1419f4e9265f5b24060432799151a27e0d1871690d2f3a51f40bcfd6d92c176001a15a62caa9bf31ea1b67a7b2da10e3351bfcdd36bfac1377d08d9",
"files": {
"terracotta-0.4.1-freebsd-x86_64": "c90e7db3c5e5cc8d53d8d10a8bf88899e2c418758c14c3d14bc24efc32a711f76882bb5b5a49db5b0256ead4f22b666139bd2a212bf09036d7e7e64ddec4ec3c"
"terracotta-0.4.2-freebsd-x86_64": "a50da02752cef73d21804b26eb02261568ed3053c8fef1074014d50226843810f71e6eda39a53af3bad8e173cec40826631a4c8877af460d14a307f1b0a62519"
}
}
},
@@ -24,12 +24,16 @@ import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class OAuth {
public static final OAuth MICROSOFT = new OAuth(
@@ -77,17 +81,31 @@ public class OAuth {
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
Session session = options.callback.startServer();
options.callback.openBrowser(NetworkUtils.withQuery(authorizationURL,
mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope),
pair("prompt", "select_account"))));
String codeVerifier = session.getCodeVerifier();
String state = session.getState();
String codeChallenge = generateCodeChallenge(codeVerifier);
options.callback.openBrowser(GrantFlow.AUTHORIZATION_CODE, NetworkUtils.withQuery(authorizationURL,
mapOf(pair("client_id", options.callback.getClientId()),
pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()),
pair("scope", options.scope),
pair("prompt", "select_account"),
pair("code_challenge", codeChallenge),
pair("state", state),
pair("code_challenge_method", "S256")
)));
String code = session.waitFor();
// Authorization Code -> Token
AuthorizationResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
.form(pair("client_id", options.callback.getClientId()),
pair("code", code),
pair("grant_type", "authorization_code"),
pair("code_verifier", codeVerifier),
pair("redirect_uri", session.getRedirectURI()),
pair("scope", options.scope))
.ignoreHttpCode()
.retry(5)
.getJson(AuthorizationResponse.class);
@@ -106,7 +124,7 @@ public class OAuth {
options.callback.grantDeviceCode(deviceTokenResponse.userCode, deviceTokenResponse.verificationURI);
// Microsoft OAuth Flow
options.callback.openBrowser(deviceTokenResponse.verificationURI);
options.callback.openBrowser(GrantFlow.DEVICE, deviceTokenResponse.verificationURI);
long startTime = System.nanoTime();
long interval = TimeUnit.MILLISECONDS.convert(deviceTokenResponse.interval, TimeUnit.SECONDS);
@@ -153,10 +171,6 @@ public class OAuth {
pair("grant_type", "refresh_token")
);
if (!options.callback.isPublicClient()) {
query.put("client_secret", options.callback.getClientSecret());
}
RefreshResponse response = HttpRequest.POST(tokenURL)
.form(query)
.accept("application/json")
@@ -174,6 +188,20 @@ public class OAuth {
}
}
private static String generateCodeChallenge(String codeVerifier) {
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
try {
byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII);
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update(bytes, 0, bytes.length);
byte[] digest = messageDigest.digest();
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception e) {
LOG.warning("Failed to generate code challenge", e);
throw new RuntimeException(e);
}
}
private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
@@ -207,6 +235,9 @@ public class OAuth {
}
public interface Session {
String getState();
String getCodeVerifier();
String getRedirectURI();
@@ -237,15 +268,12 @@ public class OAuth {
/**
* Open browser
*
* @param url OAuth url.
* @param grantFlow the grant flow.
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
void openBrowser(GrantFlow grantFlow, String url) throws IOException;
String getClientId();
String getClientSecret();
boolean isPublicClient();
}
public enum GrantFlow {
@@ -47,10 +47,10 @@ public final class MicrosoftAccount extends OAuthAccount {
this.characterUUID = requireNonNull(session.getProfile().getId());
}
protected MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException {
protected MicrosoftAccount(MicrosoftService service, OAuth.GrantFlow flow) throws AuthenticationException {
this.service = requireNonNull(service);
MicrosoftSession acquiredSession = service.authenticate();
MicrosoftSession acquiredSession = service.authenticate(flow);
if (acquiredSession.getProfile() == null) {
session = service.refresh(acquiredSession);
} else {
@@ -105,7 +105,7 @@ public final class MicrosoftAccount extends OAuthAccount {
@Override
public AuthInfo logInWhenCredentialsExpired() throws AuthenticationException {
MicrosoftSession acquiredSession = service.authenticate();
MicrosoftSession acquiredSession = service.authenticate(OAuth.GrantFlow.DEVICE);
if (!Objects.equals(characterUUID, acquiredSession.getProfile().getId())) {
throw new WrongAccountException(characterUUID, acquiredSession.getProfile().getId());
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.auth.microsoft;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.OAuth;
import java.util.Map;
import java.util.Objects;
@@ -39,9 +40,7 @@ public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
@Override
public MicrosoftAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException {
Objects.requireNonNull(selector);
return new MicrosoftAccount(service, selector);
return new MicrosoftAccount(service, (OAuth.GrantFlow) additionalData);
}
@Override
@@ -69,9 +69,9 @@ public class MicrosoftService {
return profileRepository;
}
public MicrosoftSession authenticate() throws AuthenticationException {
public MicrosoftSession authenticate(OAuth.GrantFlow flow) throws AuthenticationException {
try {
OAuth.Result result = OAuth.MICROSOFT.authenticate(OAuth.GrantFlow.DEVICE, new OAuth.Options(SCOPE, callback));
OAuth.Result result = OAuth.MICROSOFT.authenticate(flow, new OAuth.Options(SCOPE, callback));
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
} catch (IOException e) {
throw new ServerDisconnectException(e);
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.task.Task;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -47,25 +49,60 @@ public class MultipleSourceVersionList extends VersionList<RemoteVersion> {
private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
VersionList<?> versionList = backends[sourceIndex];
return versionList.refreshAsync(gameVersion)
.thenComposeAsync(() -> {
Task<?> refreshTask = versionList.refreshAsync(gameVersion);
return new Task<>() {
private Task<?> nextTask = null;
{
setSignificance(TaskSignificance.MODERATE);
setName("MultipleSourceVersionList.refreshAsync(task=%s, index=%d, all=%d)".formatted(
refreshTask.getName(), sourceIndex, backends.length)
);
}
@Override
public Collection<Task<?>> getDependents() {
return List.of(refreshTask);
}
@Override
public Collection<? extends Task<?>> getDependencies() {
return nextTask != null ? List.of(nextTask) : List.of();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
lock.writeLock().lock();
try {
versions.putAll(gameVersion, versionList.getVersions(gameVersion));
} catch (Exception e) {
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", e);
throw e;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", e);
return refreshAsync(gameVersion, sourceIndex + 1);
}
} finally {
lock.writeLock().unlock();
}
return null;
});
setResult(refreshTask.getResult());
} else {
Exception exception = refreshTask.getException();
assert exception != null;
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", exception);
setSignificance(TaskSignificance.MINOR);
throw exception;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", exception);
nextTask = refreshAsync(gameVersion, sourceIndex + 1);
nextTask.storeTo(this::setResult);
}
}
}
};
}
@Override
@@ -212,7 +212,7 @@ public abstract class FetchTask<T> extends Task<T> {
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.header("User-Agent", Holder.USER_AGENT);
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
@@ -518,7 +518,6 @@ public abstract class FetchTask<T> extends Task<T> {
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
private static final class Holder {
private static final HttpClient HTTP_CLIENT;
private static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
static {
if (!initialized) {
@@ -22,6 +22,7 @@ import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
@@ -345,12 +346,7 @@ public abstract class Task<T> {
if (count < 0 || total < 0)
throw new IllegalArgumentException("Invalid count or total: count=" + count + ", total=" + total);
double progress;
if (total >= count)
progress = 1.0;
else
progress = (double) count / total;
updateProgress(progress);
updateProgress(count < total ? (double) count / total : 1.0);
}
protected void updateProgress(double progress) {
@@ -784,6 +780,37 @@ public abstract class Task<T> {
return whenComplete(executor, (exception -> action.execute(getResult(), exception)));
}
public Task<Result<T>> wrapResult() {
return new Task<Result<T>>() {
{
setSignificance(TaskSignificance.MODERATE);
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded() != (Task.this.getException() == null))
throw new AssertionError("When whenComplete succeeded, Task.exception must be null.", Task.this.getException());
if (isDependentsSucceeded()) {
setResult(Result.success(Task.this.getResult()));
} else {
setSignificance(TaskSignificance.MINOR);
setResult(Result.failure(Task.this.getException()));
}
}
@Override
public Collection<Task<?>> getDependents() {
return Collections.singleton(Task.this);
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
}.setExecutor(executor).setName(getCaller()).setSignificance(TaskSignificance.MODERATE);
}
/**
* Returns a new Task with the same exception as this task, that executes
* the given actions when this task completes.
@@ -419,26 +419,6 @@ public final class Lang {
action.accept(it1.next(), it2.next());
}
private static Timer timer;
public static synchronized Timer getTimer() {
if (timer == null) {
timer = new Timer(true);
}
return timer;
}
public static synchronized TimerTask setTimeout(Runnable runnable, long delayMs) {
TimerTask task = new TimerTask() {
@Override
public void run() {
runnable.run();
}
};
getTimer().schedule(task, delayMs);
return task;
}
public static Throwable resolveException(Throwable e) {
if (e instanceof ExecutionException || e instanceof CompletionException)
return resolveException(e.getCause());
@@ -0,0 +1,78 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/// @author Glavo
public final class Result<T> {
public static <T> Result<T> success(T value) {
return new Result<>(value, null);
}
public static <T> Result<T> failure(@NotNull Throwable exception) {
Objects.requireNonNull(exception);
return new Result<>(null, exception);
}
private final T value;
private final Throwable exception;
private Result(T value, Throwable exception) {
this.value = value;
this.exception = exception;
}
public boolean isSuccess() {
return exception == null;
}
public boolean isFailure() {
return exception != null;
}
public T get() throws Throwable {
if (exception != null)
throw exception;
else
return value;
}
public @Nullable T getOrNull() {
return exception == null ? value : null;
}
public @Nullable Throwable getException() {
return exception;
}
@Override
public int hashCode() {
return Objects.hash(value, exception);
}
@Override
public boolean equals(Object obj) {
return this == obj || obj instanceof Result<?> other
&& Objects.equals(value, other.value)
&& Objects.equals(exception, other.exception);
}
}
@@ -276,6 +276,58 @@ public final class StringUtils {
return false;
}
/// Check if the code point is a full-width character.
public static boolean isFullWidth(int codePoint) {
return codePoint >= '\uff10' && codePoint <= '\uff19' // full-width digits
|| codePoint >= '\uff21' && codePoint <= '\uff3a' // full-width uppercase letters
|| codePoint >= '\uff41' && codePoint <= '\uff5a' // full-width lowercase letters
|| codePoint == '\uff08' // full-width left parenthesis
|| codePoint == '\uff09' // full-width right parenthesis
|| codePoint == '\uff0c' // full-width comma
|| codePoint == '\uff05' // full-width percent sign
|| codePoint == '\uff0e' // full-width period
|| codePoint == '\u3000' // full-width ideographic space
|| codePoint == '\uff03'; // full-width number sign
}
/// Convert full-width characters to half-width characters.
public static String toHalfWidth(String str) {
int i = 0;
while (i < str.length()) {
int cp = str.codePointAt(i);
if (isFullWidth(cp)) {
break;
}
i += Character.charCount(cp);
}
if (i == str.length())
return str;
var builder = new StringBuilder(str.length());
builder.append(str, 0, i);
while (i < str.length()) {
int c = str.codePointAt(i);
if (c >= '\uff10' && c <= '\uff19') builder.append((char) (c - 0xfee0));
else if (c >= '\uff21' && c <= '\uff3a') builder.append((char) (c - 0xfee0));
else if (c >= '\uff41' && c <= '\uff5a') builder.append((char) (c - 0xfee0));
else if (c == '\uff08') builder.append('(');
else if (c == '\uff09') builder.append(')');
else if (c == '\uff0c') builder.append(',');
else if (c == '\uff05') builder.append('%');
else if (c == '\uff0e') builder.append('.');
else if (c == '\u3000') builder.append(' ');
else if (c == '\uff03') builder.append('#');
else builder.appendCodePoint(c);
i += Character.charCount(c);
}
return builder.toString();
}
private static boolean isVarNameStart(char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_';
}
@@ -493,6 +545,15 @@ public final class StringUtils {
return builder.toString();
}
public static String escapeXmlAttribute(String str) {
return str
.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("'", "&apos;");
}
public static String repeats(char ch, int repeat) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < repeat; i++) {
@@ -40,6 +40,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
* @author huangyuhui
*/
public final class NetworkUtils {
public static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "=";
public static final int TIME_OUT = 8000;
@@ -95,6 +97,27 @@ public final class NetworkUtils {
return sb.toString();
}
public static String withQuery(String baseUrl, List<Pair<String, String>> params) {
StringBuilder sb = new StringBuilder(baseUrl);
boolean first = true;
for (Pair<String, String> param : params) {
if (param.getValue() == null)
continue;
if (first) {
if (!baseUrl.isEmpty()) {
sb.append('?');
}
first = false;
} else {
sb.append(PARAMETER_SEPARATOR);
}
sb.append(encodeURL(param.getKey()));
sb.append(NAME_VALUE_SEPARATOR);
sb.append(encodeURL(param.getValue()));
}
return sb.toString();
}
public static List<URI> withQuery(List<URI> list, Map<String, String> params) {
return list.stream().map(uri -> URI.create(withQuery(uri.toString(), params))).collect(Collectors.toList());
}
@@ -147,6 +170,7 @@ public final class NetworkUtils {
connection.setReadTimeout(TIME_OUT);
if (connection instanceof HttpURLConnection httpConnection) {
httpConnection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag());
httpConnection.setRequestProperty("User-Agent", USER_AGENT);
httpConnection.setInstanceFollowRedirects(false);
}
return connection;
@@ -129,10 +129,10 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
}
if (entry.isDirectory()) {
if (dir.entry != null) {
throw new IOException("Duplicate entry: " + entry.getName());
}
dir.entry = entry;
if (dir.entry == null)
dir.entry = entry;
else if (!dir.entry.isDirectory())
throw new IOException("A file and a directory have the same name: " + entry.getName());
} else {
String fileName = path[path.length - 1];
+1
View File
@@ -88,6 +88,7 @@ These options can be specified via environment variables or JVM parameters. If b
| `HMCL_FORCE_GPU` | | Specifies whether to force GPU-accelerated rendering | `false` | |
| `HMCL_ANIMATION_FRAME_RATE` | | Specifies the animation frame rate of HMCL | `60` | |
| `HMCL_LANGUAGE` | | Specifies the default language of HMCL | Uses the system default language | |
| `HMCL_UI_SCALE` | | Specifies the UI scaling for HMCL | Uses the system's current scaling | Supports scale factor (1.5), percentage (150%), or DPI (144dpi). |
| | `-Dhmcl.dir=<path>` | Specifies the current data folder of HMCL | `./.hmcl` | |
| | `-Dhmcl.home=<path>` | Specifies the user data folder of HMCL | Windows: `%APPDATA%\.hmcl`<br>Linux/BSD: `$XDG_DATA_HOME/hmcl`<br>macOS: `~Library/Application Support/hmcl` | |
| | `-Dhmcl.self_integrity_check.disable=true` | Disables self-integrity checks during updates | | |
+1
View File
@@ -87,6 +87,7 @@ HMCL 提供了一系列调试选项,用于控制启动器的行为。
| `HMCL_FORCE_GPU` | | 指定是否强制使用 GPU 加速渲染 | `false` |
| `HMCL_ANIMATION_FRAME_RATE` | | 指定 HMCL 的动画帧率 | `60` | |
| `HMCL_LANGUAGE` | | 指定 HMCL 的默认语言 | 使用系统默认语言 |
| `HMCL_UI_SCALE` | | 指定 HMCL 的 UI 缩放比例 | 遵循系统当前的缩放比例 | 支持倍数 (1.5)、百分比 (150%) 或 DPI (144dpi) |
| | `-Dhmcl.dir=<path>` | 指定 HMCL 的当前数据文件夹 | `./.hmcl` | |
| | `-Dhmcl.home=<path>` | 指定 HMCL 的用户数据文件夹 | Windows: `%APPDATA%\.hmcl`<br>Linux/BSD: `$XDG_DATA_HOME/hmcl`<br>macOS: `~Library/Application Support/hmcl` | |
| | `-Dhmcl.self_integrity_check.disable=true` | 检查更新时不检查本体完整性 | | |
+1
View File
@@ -87,6 +87,7 @@ HMCL 提供了一系列除錯選項,用於控制啟動器的行為。
| `HMCL_FORCE_GPU` | | 設定是否強制使用 GPU 加速繪製 | `false` |
| `HMCL_ANIMATION_FRAME_RATE` | | 設定 HMCL 的動畫幀率 | `60` | |
| `HMCL_LANGUAGE` | | 設定 HMCL 的預設語言 | 使用系統預設語言 |
| `HMCL_UI_SCALE` | | 設定 HMCL 的 UI 縮放比例 | 遵循系統目前的縮放比例 | 支援倍數 (1.5)、百分比 (150%) 或 DPI (144dpi) |
| | `-Dhmcl.dir=<path>` | 設定 HMCL 的目前資料存放位置 | `./.hmcl` | |
| | `-Dhmcl.home=<path>` | 設定 HMCL 的使用者資料存放位置 | Windows: `%APPDATA%\.hmcl`<br>Linux/BSD: `$XDG_DATA_HOME/hmcl`<br>macOS: `~Library/Application Support/hmcl` | |
| | `-Dhmcl.self_integrity_check.disable=true` | 檢查更新時不檢查程式完整性 | | |
+1 -1
View File
@@ -126,7 +126,7 @@ Then you can start translating in this file.
The `I18N.properties` file follows the [resource fallback mechanism](#resource-fallback-mechanism) to look up missing translations.
That is, you can translate entry by entry, and any untranslated entries will automatically fall back to English.
After translating part of the file, you can [build HMCL yourself](./README.md#compilation),
After translating part of the file, you can [build HMCL yourself](./Contributing.md#build-hmcl),
and your translations will be included in the compiled HMCL.
If your computer's default environment is not the target language,
you can set the environment variable `HMCL_LANGUAGE` to the language tag you just found from the table,
+3 -3
View File
@@ -115,11 +115,11 @@ HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译
`I18N.properties` 文件会遵循[资源回退机制](#资源回退机制)查询缺失的译文。
也就是说,你可以逐条目进行翻译,而你尚未翻译的条目会自动回退到英语上。
在翻译了一部分后,你可以[自行构建 HMCL](./README_zh.md#编译),编译出的 HMCL 中就会包含你的译文。
在翻译了一部分后,你可以[自行构建 HMCL](./Contributing_zh.md#构建-hmcl),编译出的 HMCL 中就会包含你的译文。
如果你的电脑默认环境不是该语言,你可以将环境变量 `HMCL_LANGUAGE` 指定为你刚刚从表格中找到的语言标签,
HMCL 会自动切换至这个语言。
到这里,你就可以把文件推送到 GitHub上,并向 HMCL 提交 PR 了。
到这里,你就可以把文件推送到 GitHub 上,并向 HMCL 提交 PR 了。
HMCL 的维护者会替你完成其他步骤。
## 本地化资源
@@ -175,7 +175,7 @@ HMCL 的维护者会替你完成其他步骤。
如果当前语言标签中的语言代码子标签未在 IANA 语言子标签注册表中进行注册,HMCL 会先尝试将其映射为注册表中已注册的标签。
例如,HMCL会将语言代码 `eng` 替换为 `en`。
例如,HMCL 会将语言代码 `eng` 替换为 `en`。
2. 映射宏语言至子语言
+7 -7
View File
@@ -6,7 +6,7 @@
HMCL 為多種語言提供在地化支援。
本文件介紹了 HMCL 對這些語言的支援狀態,並給想要為 HMCL 的本地化工作做出貢獻的貢獻者提供了一份指南。
本文件介紹了 HMCL 對這些語言的支援狀態,並給想要為 HMCL 的在地化工作做出貢獻的貢獻者提供了一份指南。
## 支援的語言
@@ -68,11 +68,11 @@ HMCL 使用符合 IETF BCP 47 規範的語言標籤。
</details>
HMCL 會要求所有 Pull Request 在更新文件和在地化資源時同步更新所有**主要**支援的語言對應的資源。
HMCL 會要求所有 Pull Request 在更新文件和在地化資源時同步更新所有「**主要**」支援的語言對應的資源。
如果 PR 作者對相關語言並不了解,那麼可以直接在評論中提出翻譯請求,
維護者會在合併 PR 前幫助 PR 作者翻譯這些文字。
而對於**次要**支援的語言,我們不能保證這些在地化資源總是會同步更新。
而對於「**次要**」支援的語言,我們不能保證這些在地化資源總是會同步更新。
我們需要熟練掌握這些語言的協作者幫助我們進行維護。
我們會在文件中記錄願意幫助我們維護這些語言在地化資源的志願者。
@@ -109,17 +109,17 @@ HMCL 的絕大多數文字都位於這個檔案中,翻譯此檔案就能翻譯
作為翻譯的第一步,請從[這張表格](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)中查詢這個語言對應的兩字母或三字母語言標籤。
例如,英語的語言標籤為 `en`。
在確定了語言標籤後,請在 [`I18N.properties` 檔案旁](../HMCL/src/main/resources/assets/lang)建立 `I18N_<在地化檔案後綴>.properties` (例如 `I18N_en.properties`) 文件。
在確定了語言標籤後,請在 [`I18N.properties` 檔案旁](../HMCL/src/main/resources/assets/lang)建立 `I18N_<在地化檔案後綴>.properties` (例如 `I18N_en.properties`) 檔案。
隨後,你就可以開始在這個檔案中進行翻譯工作了。
`I18N.properties` 檔案會遵循[資源回退機制](#資源回退機制)查詢缺失的譯文。
也就是說,你可以逐條目進行翻譯,而你尚未翻譯的條目會自動回退到英語上。
在翻譯了一部分後,你可以[自行構建 HMCL](./README_zh.md#編譯),編譯出的 HMCL 中就會包含你的譯文。
在翻譯了一部分後,你可以[自行構建 HMCL](./Contributing_zh_Hant.md#構建-hmcl),編譯出的 HMCL 中就會包含你的譯文。
如果你的電腦預設環境不是該語言,你可以將環境變數 `HMCL_LANGUAGE` 指定為你剛剛從表格中找到的語言標籤,
HMCL 會自動切換至這個語言。
到這裡,你就可以把檔案推送到 GitHub上,並向 HMCL 提交 PR 了。
到這裡,你就可以把檔案推送到 GitHub 上,並向 HMCL 提交 PR 了。
HMCL 的維護者會替你完成其他步驟。
## 在地化資源
@@ -175,7 +175,7 @@ HMCL 的維護者會替你完成其他步驟。
如果目前語言標籤中的語言代碼子標籤未在 IANA 語言子標籤登錄檔中進行註冊,HMCL 會先嘗試將其映射為登錄檔中已註冊的標籤。
例如,HMCL會將語言代碼 `eng` 取代為 `en`。
例如,HMCL 會將語言代碼 `eng` 取代為 `en`。
2. 映射巨集語言至子語言
+3 -1
View File
@@ -18,7 +18,8 @@ pci-ids = "0.4.0"
java-info = "1.0"
authlib-injector = "1.2.7"
monet-fx = "0.4.0"
terracotta = "0.4.1"
terracotta = "0.4.2"
nayuki-qrcodegen = "1.8.0"
# testing
junit = "6.0.1"
@@ -49,6 +50,7 @@ pci-ids = { module = "org.glavo:pci-ids", version.ref = "pci-ids" }
java-info = { module = "org.glavo:java-info", version.ref = "java-info" }
authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = "authlib-injector" }
monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" }
nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcodegen" }
# testing
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }