Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5612ca2a12 |
No files matched your search
@@ -28,6 +28,7 @@ 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") ?: ""
|
||||
@@ -59,7 +60,6 @@ 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,6 +152,7 @@ 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,17 +34,14 @@ 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
|
||||
@@ -98,17 +95,14 @@ 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();
|
||||
@@ -410,20 +404,4 @@ 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,28 +22,27 @@ 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.theme.Themes;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
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.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
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.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
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;
|
||||
|
||||
@@ -53,34 +52,6 @@ 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
|
||||
@@ -122,32 +93,19 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
String parameters = session.getQueryParameterString();
|
||||
|
||||
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
|
||||
|
||||
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"));
|
||||
}
|
||||
if (query.containsKey("code")) {
|
||||
idToken = query.get("id_token");
|
||||
future.complete(query.get("code"));
|
||||
} else {
|
||||
LOG.warning("Failed to authenticate: missing authorization code in parameters");
|
||||
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
|
||||
LOG.warning("Error: " + parameters);
|
||||
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
|
||||
}
|
||||
|
||||
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("%success%", i18n("message.success"))
|
||||
.replace("%ok%", i18n("button.ok"))
|
||||
.replace("%close_page%", i18n("account.methods.microsoft.close_page"));
|
||||
.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, "");
|
||||
@@ -165,8 +123,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
|
||||
public static class Factory implements OAuth.Callback {
|
||||
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
|
||||
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
|
||||
|
||||
@Override
|
||||
public OAuth.Session startServer() throws IOException, AuthenticationException {
|
||||
@@ -193,13 +150,17 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
|
||||
public void openBrowser(String url) throws IOException {
|
||||
lastlyOpenedURL = url;
|
||||
|
||||
switch (grantFlow) {
|
||||
case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url));
|
||||
case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url));
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
|
||||
FXUtils.openLink(url);
|
||||
|
||||
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -207,6 +168,17 @@ 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 {
|
||||
|
||||
@@ -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.MicrosoftAccountLoginPane;
|
||||
import org.jackhuang.hmcl.ui.account.OAuthAccountLoginDialog;
|
||||
|
||||
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(() -> {
|
||||
MicrosoftAccountLoginPane pane = new MicrosoftAccountLoginPane(account, it -> {
|
||||
OAuthAccountLoginDialog pane = new OAuthAccountLoginDialog((OAuthAccount) account, it -> {
|
||||
res.set(it);
|
||||
latch.countDown();
|
||||
}, latch::countDown, false);
|
||||
}, latch::countDown);
|
||||
Controllers.dialog(pane);
|
||||
});
|
||||
latch.await();
|
||||
|
||||
@@ -343,7 +343,6 @@ 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,6 +18,8 @@
|
||||
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;
|
||||
|
||||
@@ -28,7 +30,6 @@ 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
|
||||
@@ -129,12 +130,6 @@ 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;
|
||||
|
||||
@@ -149,20 +144,35 @@ public enum SVG {
|
||||
return path;
|
||||
}
|
||||
|
||||
public SVGPath createIcon() {
|
||||
var path = new SVGPath();
|
||||
path.getStyleClass().add("svg");
|
||||
path.setContent(getPath());
|
||||
return path;
|
||||
public SVGPath createSVGPath() {
|
||||
var p = new SVGPath();
|
||||
p.setContent(getPath());
|
||||
p.getStyleClass().add("svg");
|
||||
return p;
|
||||
}
|
||||
|
||||
public SVGContainer createIcon(double size) {
|
||||
return new SVGContainer(this, size);
|
||||
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 SVGPath createIcon(ObservableValue<? extends Paint> color) {
|
||||
SVGPath path = createIcon();
|
||||
path.fillProperty().bind(color);
|
||||
return path;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* 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,19 +20,14 @@ 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 final class WeakListenerHolder {
|
||||
public class WeakListenerHolder {
|
||||
private final List<Object> refs = new ArrayList<>(0);
|
||||
|
||||
public WeakListenerHolder() {
|
||||
@@ -53,22 +48,6 @@ public final 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 MicrosoftAccountLoginPane()));
|
||||
microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT)));
|
||||
|
||||
AdvancedListItem offlineItem = new AdvancedListItem();
|
||||
offlineItem.getStyleClass().add("navigation-drawer-item");
|
||||
|
||||
@@ -23,7 +23,9 @@ 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;
|
||||
@@ -43,6 +45,7 @@ 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;
|
||||
@@ -51,6 +54,7 @@ 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;
|
||||
@@ -81,11 +85,13 @@ 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;
|
||||
|
||||
@@ -140,10 +146,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
btnCancel.setOnAction(e -> onCancel());
|
||||
onEscPressed(this, btnCancel::fire);
|
||||
|
||||
actions = new HBox(spinner, btnCancel);
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
HBox hbox = new HBox(spinner, btnCancel);
|
||||
hbox.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
setActions(lblErrorMessage, actions);
|
||||
setActions(lblErrorMessage, hbox);
|
||||
}
|
||||
|
||||
if (showMethodSwitcher) {
|
||||
@@ -220,6 +226,7 @@ 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 -> {
|
||||
@@ -273,16 +280,93 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
btnAccept.disableProperty().unbind();
|
||||
detailsContainer.getChildren().remove(detailsPane);
|
||||
lblErrorMessage.setText("");
|
||||
setActions(lblErrorMessage, actions);
|
||||
lblErrorMessage.setVisible(true);
|
||||
}
|
||||
|
||||
if (factory == Accounts.FACTORY_MICROSOFT) {
|
||||
detailsPane = new MicrosoftAccountLoginPane(true);
|
||||
setActions();
|
||||
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);
|
||||
} else {
|
||||
detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire);
|
||||
btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not());
|
||||
setActions(lblErrorMessage, actions);
|
||||
}
|
||||
|
||||
detailsContainer.getChildren().add(detailsPane);
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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,6 +18,7 @@
|
||||
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;
|
||||
@@ -27,8 +28,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.SVGContainer;
|
||||
import org.jackhuang.hmcl.ui.animation.Motion;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -99,17 +100,27 @@ 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));
|
||||
|
||||
var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE);
|
||||
leftGraphic.setMouseTransparent(true);
|
||||
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);
|
||||
AdvancedListItem.setMargin(leftGraphic, AdvancedListItem.LEFT_ICON_MARGIN);
|
||||
FXUtils.onChange(item.activeProperty(), active -> leftGraphic.setIcon(active ? selectedGraphic : unselectedGraphic, Motion.SHORT4));
|
||||
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));
|
||||
|
||||
item.setLeftGraphic(leftGraphic);
|
||||
return add(item);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ 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();
|
||||
@@ -67,11 +66,7 @@ public class HintPane extends VBox {
|
||||
}
|
||||
|
||||
public void setSegment(String segment) {
|
||||
this.setSegment(segment, Controllers::onHyperlinkAction);
|
||||
}
|
||||
|
||||
public void setSegment(String segment, Consumer<String> hyperlinkAction) {
|
||||
flow.getChildren().setAll(FXUtils.parseSegment(segment, hyperlinkAction));
|
||||
flow.getChildren().setAll(FXUtils.parseSegment(segment, Controllers::onHyperlinkAction));
|
||||
}
|
||||
|
||||
public void setChildren(Node... children) {
|
||||
|
||||
@@ -31,12 +31,16 @@ 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 {
|
||||
@@ -68,7 +72,7 @@ public final class MessageDialogPane extends HBox {
|
||||
private @Nullable ButtonBase cancelButton;
|
||||
|
||||
public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) {
|
||||
this.setSpacing(16);
|
||||
this.setSpacing(8);
|
||||
this.getStyleClass().add("jfx-dialog-layout");
|
||||
|
||||
Label graphic = new Label();
|
||||
@@ -215,6 +219,36 @@ 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,6 +18,8 @@
|
||||
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;
|
||||
@@ -31,12 +33,14 @@ import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
|
||||
/// A spinner pane that can show spinner, failed reason, or content.
|
||||
@DefaultProperty("content")
|
||||
public class SpinnerPane extends Control {
|
||||
private static final String DEFAULT_STYLE_CLASS = "spinner-pane";
|
||||
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");
|
||||
|
||||
public SpinnerPane() {
|
||||
getStyleClass().add(DEFAULT_STYLE_CLASS);
|
||||
getStyleClass().add("spinner-pane");
|
||||
}
|
||||
|
||||
public void showSpinner() {
|
||||
@@ -48,195 +52,108 @@ public class SpinnerPane extends Control {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
private void updateContent() {
|
||||
if (getSkin() instanceof Skin skin) {
|
||||
skin.updateContent();
|
||||
}
|
||||
public Node getContent() {
|
||||
return content.get();
|
||||
}
|
||||
|
||||
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 Node getContent() {
|
||||
return contentProperty().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;
|
||||
this.content.set(content);
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return loading != null && loading.get();
|
||||
return loading.get();
|
||||
}
|
||||
|
||||
public BooleanProperty loadingProperty() {
|
||||
return loading;
|
||||
}
|
||||
|
||||
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;
|
||||
this.loading.set(loading);
|
||||
}
|
||||
|
||||
public String getFailedReason() {
|
||||
return failedReason != null ? failedReason.get() : null;
|
||||
return failedReason.get();
|
||||
}
|
||||
|
||||
public StringProperty failedReasonProperty() {
|
||||
return failedReason;
|
||||
}
|
||||
|
||||
public void setFailedReason(String failedReason) {
|
||||
failedReasonProperty().set(failedReason);
|
||||
this.failedReason.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);
|
||||
|
||||
updateContent();
|
||||
this.getChildren().setAll(root);
|
||||
}
|
||||
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));
|
||||
});
|
||||
|
||||
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
|
||||
FXUtils.onChangeAndOperate(getSkinnable().content, newValue -> {
|
||||
if (newValue == null) {
|
||||
contentPane.getChildren().clear();
|
||||
} else {
|
||||
contentPane.getChildren().setAll(newValue);
|
||||
}
|
||||
});
|
||||
getChildren().setAll(root);
|
||||
|
||||
nextContent = contentPane;
|
||||
}
|
||||
|
||||
if (nextContent != failedPane && failedReasonLabel != null) {
|
||||
failedReasonLabel.setText(null);
|
||||
}
|
||||
|
||||
root.setContent(nextContent, ContainerAnimations.FADE);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
|
||||
} else {
|
||||
handler.reject(e.getMessage());
|
||||
}
|
||||
}), i18n("message.downloading"), TaskCancellationAction.NORMAL);
|
||||
}).executor(true), 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,8 +162,6 @@ 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,8 +48,6 @@ 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;
|
||||
@@ -110,39 +108,23 @@ public final class JavaDownloadDialog extends StackPane {
|
||||
}
|
||||
|
||||
private final class DownloadMojangJava extends DialogPane {
|
||||
private final List<JFXCheckBox> checkboxes = new ArrayList<>();
|
||||
private final ToggleGroup toggleGroup = new ToggleGroup();
|
||||
|
||||
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) {
|
||||
JFXCheckBox button = new JFXCheckBox("Java " + version.majorVersion());
|
||||
JFXRadioButton button = new JFXRadioButton("Java " + version.majorVersion());
|
||||
button.setUserData(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);
|
||||
toggleGroup.getToggles().add(button);
|
||||
if (JavaManager.REPOSITORY.isInstalled(platform, version))
|
||||
button.setDisable(true);
|
||||
}
|
||||
|
||||
setBody(vbox);
|
||||
@@ -155,52 +137,38 @@ 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());
|
||||
|
||||
List<GameJavaVersion> selectedVersions = new ArrayList<>();
|
||||
for (JFXCheckBox box : checkboxes) {
|
||||
if (!box.isDisabled() && box.isSelected()) {
|
||||
selectedVersions.add((GameJavaVersion) box.getUserData());
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedVersions.isEmpty())
|
||||
GameJavaVersion javaVersion = (GameJavaVersion) toggleGroup.getSelectedToggle().getUserData();
|
||||
if (javaVersion == null)
|
||||
return;
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} 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);
|
||||
return downloadTask(javaVersion);
|
||||
}), i18n("download.java"), TaskCancellationAction.NORMAL);
|
||||
}, null);
|
||||
else
|
||||
Controllers.taskDialog(downloadTask(javaVersion), i18n("download.java.process"), TaskCancellationAction.NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -31,6 +32,7 @@ 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;
|
||||
@@ -549,7 +551,8 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
private final HBox right;
|
||||
|
||||
private final ImageView iconImageView;
|
||||
private final SVGContainer iconSVGView;
|
||||
private final SVGPath iconSVG;
|
||||
private final StackPane iconSVGWrapper;
|
||||
|
||||
private final Tooltip tooltip = new Tooltip();
|
||||
|
||||
@@ -565,7 +568,15 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
this.iconImageView = new ImageView();
|
||||
FXUtils.limitSize(iconImageView, 32, 32);
|
||||
|
||||
this.iconSVGView = new SVGContainer(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);
|
||||
|
||||
BorderPane.setAlignment(left, Pos.CENTER);
|
||||
root.setLeft(left);
|
||||
@@ -627,8 +638,8 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> impl
|
||||
iconImageView.setImage(fileItem.getImage());
|
||||
left.getChildren().setAll(iconImageView);
|
||||
} else {
|
||||
iconSVGView.setIcon(item.getIcon());
|
||||
left.getChildren().setAll(iconSVGView);
|
||||
iconSVG.setContent(item.getIcon().getPath());
|
||||
left.getChildren().setAll(iconSVGWrapper);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* 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() {
|
||||
}
|
||||
}
|
||||
@@ -572,41 +572,6 @@
|
||||
-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;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
* *
|
||||
|
||||
@@ -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.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.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.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,6 +141,7 @@ 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.
|
||||
@@ -367,6 +368,7 @@ 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\
|
||||
@@ -476,7 +478,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 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\
|
||||
You can try removing this block using MCEdit or deleting the mod that added it.\n\
|
||||
\n\
|
||||
Block Type: %1$s\n\
|
||||
Location: %2$s
|
||||
@@ -492,7 +494,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 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\
|
||||
You can try removing the entity using MCEdit or deleting the mod that added it.\n\
|
||||
\n\
|
||||
Entity Type: %1$s\n\
|
||||
Location: %2$s
|
||||
|
||||
@@ -120,6 +120,7 @@ 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=انقر هنا لإضافة واحد.
|
||||
@@ -340,6 +341,7 @@ 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\
|
||||
@@ -442,6 +444,12 @@ 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\
|
||||
يمكنك محاولة حذفه أو تحديثه.
|
||||
@@ -452,6 +460,12 @@ 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,6 +123,7 @@ 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.
|
||||
@@ -345,6 +346,7 @@ 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\
|
||||
@@ -448,6 +450,12 @@ 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.
|
||||
@@ -461,6 +469,12 @@ 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,6 +91,7 @@ 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を購入する
|
||||
@@ -348,11 +349,13 @@ 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,6 +110,7 @@ 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=離綫之式
|
||||
@@ -344,6 +345,7 @@ 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誡:引之允滯,宜迭其源。
|
||||
@@ -408,14 +410,14 @@ game.crash.feedback=<b>無錄是頁以示人!</b>宜擊<b>「導崩記」</b>
|
||||
game.crash.info=戲訊
|
||||
game.crash.reason=潰退之由
|
||||
game.crash.reason.analyzing=方求之焉……
|
||||
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.block=塊謬而崩。\n或操 <a href="https://www.mcedit.net">MCEdit</a> 修檔以去之,抑徑去改囊之有干者。\n塊類:%1$s\n塊位:%2$s
|
||||
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或操輿圖纂具修檔以去之,抑徑去改囊之有干者。\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.entity=實體謬而崩。\n或操 <a href="https://www.mcedit.net">MCEdit</a> 修檔以去之,抑徑去改囊之有干者。\n實體類:%1$s\n實體位:%2$s
|
||||
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,6 +122,7 @@ 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=Нажмите здесь, чтобы добавить.
|
||||
@@ -342,6 +343,7 @@ 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\
|
||||
@@ -442,6 +444,12 @@ 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\
|
||||
Попробуйте удалить или обновить его.
|
||||
@@ -452,6 +460,12 @@ 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,6 +120,7 @@ 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=Натисніть тут, щоб додати.
|
||||
@@ -341,6 +342,7 @@ 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\
|
||||
@@ -427,6 +429,12 @@ 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\
|
||||
Ви можете спробувати видалити або оновити його.
|
||||
@@ -435,6 +443,10 @@ 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,13 +117,12 @@ 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.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.manual=請在彈出的網頁中輸入上方顯示的代碼以完成登入。\n\
|
||||
若網站未能顯示,請手動在瀏覽器中開啟:%s\n\
|
||||
<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。請下載官方版本來重新整理帳戶。
|
||||
@@ -363,6 +362,7 @@ 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你可以嘗試使用地圖編輯工具編輯世界刪除該方塊,或者直接刪除對應的模組。\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.block=目前遊戲由於某個方塊不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該方塊,或者直接刪除對應的模組。\n方塊類型:%1$s\n方塊坐標:%2$s
|
||||
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你可以嘗試使用地圖編輯工具編輯世界刪除該實體,或者直接刪除對應的模組。\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.entity=目前遊戲由於某個實體不能正常工作,無法繼續執行。\n你可以嘗試使用 MCEdit 工具編輯世界刪除該實體,或者直接刪除對應的模組。\n實體類型:%1$s\n實體坐標:%2$s
|
||||
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,14 +118,13 @@ account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软
|
||||
account.methods.microsoft.logging_in=登录中……
|
||||
account.methods.microsoft.makegameidsettings=创建档案 / 编辑档案名称
|
||||
account.methods.microsoft.hint=点击“登录”按钮开始添加微软账户。
|
||||
account.methods.microsoft.methods.device=扫描二维码登录
|
||||
account.methods.microsoft.methods.device.hint=扫描二维码或访问 <a href="%s">%s</a>,在打开的页面中输入 <b>%s</b> 完成登录。
|
||||
account.methods.microsoft.methods.browser=在浏览器登录
|
||||
account.methods.microsoft.methods.browser.hint=点击“登录”按钮或者<a href="%s">复制链接</a>并在浏览器中粘贴以登录。
|
||||
account.methods.microsoft.manual=<b>若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。</b>\n\
|
||||
account.methods.microsoft.manual=请在弹出的网页中输入上方显示的代码以完成登录。\n\
|
||||
若网站未能显示,请手动在浏览器中打开:%s\n\
|
||||
<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。请下载官方版本来刷新账户。
|
||||
@@ -365,6 +364,7 @@ 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你可以尝试使用地图编辑工具编辑世界删除该方块,或者直接删除对应的模组。\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.block=当前游戏由于某个方块不能正常工作,无法继续运行。\n你可以尝试使用 <a href="https://www.mcedit.net">MCEdit</a> 工具编辑世界删除该方块,或者直接删除对应的模组。\n方块类型:%1$s\n方块坐标:%2$s
|
||||
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你可以尝试使用地图编辑工具编辑世界删除该实体,或者直接删除对应的模组。\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.entity=当前游戏由于某个实体不能正常工作,无法继续运行。\n你可以尝试使用 <a href="https://www.mcedit.net">MCEdit</a> 工具编辑世界删除该实体,或者直接删除对应的模组。\n实体类型:%1$s\n实体坐标:%2$s
|
||||
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,135 +19,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
<html lang="%lang%">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<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 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>
|
||||
%close-page%
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
open("about:blank","_self").close();
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -24,16 +24,12 @@ 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(
|
||||
@@ -81,31 +77,17 @@ public class OAuth {
|
||||
|
||||
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
|
||||
Session session = options.callback.startServer();
|
||||
|
||||
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")
|
||||
)));
|
||||
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 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("code_verifier", codeVerifier),
|
||||
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("client_secret", options.callback.getClientSecret()),
|
||||
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(AuthorizationResponse.class);
|
||||
@@ -124,7 +106,7 @@ public class OAuth {
|
||||
options.callback.grantDeviceCode(deviceTokenResponse.userCode, deviceTokenResponse.verificationURI);
|
||||
|
||||
// Microsoft OAuth Flow
|
||||
options.callback.openBrowser(GrantFlow.DEVICE, deviceTokenResponse.verificationURI);
|
||||
options.callback.openBrowser(deviceTokenResponse.verificationURI);
|
||||
|
||||
long startTime = System.nanoTime();
|
||||
long interval = TimeUnit.MILLISECONDS.convert(deviceTokenResponse.interval, TimeUnit.SECONDS);
|
||||
@@ -171,6 +153,10 @@ 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")
|
||||
@@ -188,20 +174,6 @@ 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;
|
||||
@@ -235,9 +207,6 @@ public class OAuth {
|
||||
}
|
||||
|
||||
public interface Session {
|
||||
String getState();
|
||||
|
||||
String getCodeVerifier();
|
||||
|
||||
String getRedirectURI();
|
||||
|
||||
@@ -268,12 +237,15 @@ public class OAuth {
|
||||
/**
|
||||
* Open browser
|
||||
*
|
||||
* @param grantFlow the grant flow.
|
||||
* @param url OAuth url.
|
||||
* @param url OAuth url.
|
||||
*/
|
||||
void openBrowser(GrantFlow grantFlow, String url) throws IOException;
|
||||
void openBrowser(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, OAuth.GrantFlow flow) throws AuthenticationException {
|
||||
protected MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException {
|
||||
this.service = requireNonNull(service);
|
||||
|
||||
MicrosoftSession acquiredSession = service.authenticate(flow);
|
||||
MicrosoftSession acquiredSession = service.authenticate();
|
||||
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(OAuth.GrantFlow.DEVICE);
|
||||
MicrosoftSession acquiredSession = service.authenticate();
|
||||
if (!Objects.equals(characterUUID, acquiredSession.getProfile().getId())) {
|
||||
throw new WrongAccountException(characterUUID, acquiredSession.getProfile().getId());
|
||||
}
|
||||
|
||||
+3
-2
@@ -20,7 +20,6 @@ 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;
|
||||
@@ -40,7 +39,9 @@ public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
|
||||
|
||||
@Override
|
||||
public MicrosoftAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException {
|
||||
return new MicrosoftAccount(service, (OAuth.GrantFlow) additionalData);
|
||||
Objects.requireNonNull(selector);
|
||||
|
||||
return new MicrosoftAccount(service, selector);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -69,9 +69,9 @@ public class MicrosoftService {
|
||||
return profileRepository;
|
||||
}
|
||||
|
||||
public MicrosoftSession authenticate(OAuth.GrantFlow flow) throws AuthenticationException {
|
||||
public MicrosoftSession authenticate() throws AuthenticationException {
|
||||
try {
|
||||
OAuth.Result result = OAuth.MICROSOFT.authenticate(flow, new OAuth.Options(SCOPE, callback));
|
||||
OAuth.Result result = OAuth.MICROSOFT.authenticate(OAuth.GrantFlow.DEVICE, new OAuth.Options(SCOPE, callback));
|
||||
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
|
||||
@@ -20,8 +20,6 @@ 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;
|
||||
|
||||
@@ -49,60 +47,25 @@ public class MultipleSourceVersionList extends VersionList<RemoteVersion> {
|
||||
|
||||
private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
|
||||
VersionList<?> versionList = backends[sourceIndex];
|
||||
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()) {
|
||||
return versionList.refreshAsync(gameVersion)
|
||||
.thenComposeAsync(() -> {
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@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", NetworkUtils.USER_AGENT);
|
||||
.header("User-Agent", Holder.USER_AGENT);
|
||||
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
@@ -518,6 +518,7 @@ 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,7 +22,6 @@ 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;
|
||||
@@ -346,7 +345,12 @@ public abstract class Task<T> {
|
||||
if (count < 0 || total < 0)
|
||||
throw new IllegalArgumentException("Invalid count or total: count=" + count + ", total=" + total);
|
||||
|
||||
updateProgress(count < total ? (double) count / total : 1.0);
|
||||
double progress;
|
||||
if (total >= count)
|
||||
progress = 1.0;
|
||||
else
|
||||
progress = (double) count / total;
|
||||
updateProgress(progress);
|
||||
}
|
||||
|
||||
protected void updateProgress(double progress) {
|
||||
@@ -780,37 +784,6 @@ 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,6 +419,26 @@ 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());
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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,58 +276,6 @@ 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 == '_';
|
||||
}
|
||||
@@ -545,15 +493,6 @@ public final class StringUtils {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static String escapeXmlAttribute(String str) {
|
||||
return str
|
||||
.replace("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public static String repeats(char ch, int repeat) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < repeat; i++) {
|
||||
|
||||
@@ -40,8 +40,6 @@ 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;
|
||||
@@ -170,7 +168,6 @@ 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;
|
||||
|
||||
@@ -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](./Contributing.md#build-hmcl),
|
||||
After translating part of the file, you can [build HMCL yourself](./README.md#compilation),
|
||||
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,
|
||||
|
||||
@@ -115,11 +115,11 @@ HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译
|
||||
`I18N.properties` 文件会遵循[资源回退机制](#资源回退机制)查询缺失的译文。
|
||||
也就是说,你可以逐条目进行翻译,而你尚未翻译的条目会自动回退到英语上。
|
||||
|
||||
在翻译了一部分后,你可以[自行构建 HMCL](./Contributing_zh.md#构建-hmcl),编译出的 HMCL 中就会包含你的译文。
|
||||
在翻译了一部分后,你可以[自行构建 HMCL](./README_zh.md#编译),编译出的 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. 映射宏语言至子语言
|
||||
|
||||
|
||||
@@ -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](./Contributing_zh_Hant.md#構建-hmcl),編譯出的 HMCL 中就會包含你的譯文。
|
||||
在翻譯了一部分後,你可以[自行構建 HMCL](./README_zh.md#編譯),編譯出的 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. 映射巨集語言至子語言
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ java-info = "1.0"
|
||||
authlib-injector = "1.2.7"
|
||||
monet-fx = "0.4.0"
|
||||
terracotta = "0.4.2"
|
||||
nayuki-qrcodegen = "1.8.0"
|
||||
|
||||
# testing
|
||||
junit = "6.0.1"
|
||||
@@ -50,7 +49,6 @@ 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" }
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user