Compare commits
65
Commits
v3.5.3.222
...
v3.5.3.223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d830dbf96 | ||
|
|
0396e96334 | ||
|
|
7b23c7bc79 | ||
|
|
d3ca50259a | ||
|
|
499a65d5fb | ||
|
|
0b61f9856c | ||
|
|
c8409dd258 | ||
|
|
e5ea4cd79f | ||
|
|
4d54c7f6ba | ||
|
|
0f4a5c26df | ||
|
|
a8b09e2195 | ||
|
|
3ed6e38ad3 | ||
|
|
2028b0ef9c | ||
|
|
aa7f9b91c7 | ||
|
|
d6598310b2 | ||
|
|
ce6c06987c | ||
|
|
90955d7916 | ||
|
|
b3bbd21f1f | ||
|
|
735a407f0e | ||
|
|
8461b0418c | ||
|
|
e46ab8bf9e | ||
|
|
330548b24e | ||
|
|
cf5ccca42d | ||
|
|
a18bd0729b | ||
|
|
662b42c40f | ||
|
|
b69c400a08 | ||
|
|
f1eb2da57c | ||
|
|
99be810b5e | ||
|
|
03541a6f85 | ||
|
|
5fe1ef899d | ||
|
|
6a81f4d240 | ||
|
|
3c6aa489e8 | ||
|
|
ba7043fa92 | ||
|
|
bfb6c99484 | ||
|
|
a6b26a0cd9 | ||
|
|
0c7b0f285e | ||
|
|
5e1ae56740 | ||
|
|
b17bac050b | ||
|
|
b1952a7b37 | ||
|
|
cc1fc1e4be | ||
|
|
6c07924d9b | ||
|
|
835668d781 | ||
|
|
8f7c09d2cc | ||
|
|
837939e27c | ||
|
|
058a8e2335 | ||
|
|
a5af5265be | ||
|
|
8a724587ca | ||
|
|
bef9c0ad88 | ||
|
|
8d29775410 | ||
|
|
620c0e4c73 | ||
|
|
b5c92c1564 | ||
|
|
3c3c28aaa5 | ||
|
|
a80fc34ef1 | ||
|
|
08d7ff138b | ||
|
|
0c172e5d08 | ||
|
|
bce2d1ea5c | ||
|
|
cbc1e26b6f | ||
|
|
3b5ddcafa6 | ||
|
|
c6a5624708 | ||
|
|
23cc568bac | ||
|
|
7ff679c3c8 | ||
|
|
c873352ee9 | ||
|
|
a4164a86af | ||
|
|
c21671e86b | ||
|
|
d9bdc2b1f7 |
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = false
|
||||
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha1"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha1"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha1"
|
||||
env:
|
||||
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
|
||||
- name: Generate release note
|
||||
@@ -58,6 +60,8 @@ jobs:
|
||||
HMCL-${{ env.HMCL_VERSION }}.exe.sha1
|
||||
HMCL-${{ env.HMCL_VERSION }}.jar
|
||||
HMCL-${{ env.HMCL_VERSION }}.jar.sha1
|
||||
HMCL-${{ env.HMCL_VERSION }}.sh
|
||||
HMCL-${{ env.HMCL_VERSION }}.sh.sha1
|
||||
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
|
||||
name: ${{ env.HMCL_TAG_NAME }}
|
||||
tag_name: ${{ env.HMCL_TAG_NAME }}
|
||||
@@ -19,6 +19,10 @@ jobs:
|
||||
java-package: jdk+fx
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
env:
|
||||
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
|
||||
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- name: Upload Artifacts
|
||||
|
||||
@@ -52,8 +52,6 @@ version = "$versionRoot.$buildNumber"
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
implementation("libs:JFoenix")
|
||||
|
||||
implementation("de.javawi.jstun:jstun:0.7.4")
|
||||
}
|
||||
|
||||
fun digest(algorithm: String, bytes: ByteArray) = MessageDigest.getInstance(algorithm).digest(bytes)
|
||||
|
||||
@@ -69,12 +69,6 @@ public final class Launcher extends Application {
|
||||
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
|
||||
}
|
||||
|
||||
if (!Architecture.CURRENT_ARCH.isX86()
|
||||
&& !(OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.CURRENT_ARCH == Architecture.ARM64)
|
||||
&& ConfigHolder.globalConfig().getAgreementVersion() < 1) {
|
||||
Main.showWarningAndContinue(i18n("fatal.unsupported_platform"));
|
||||
}
|
||||
|
||||
// runLater to ensure ConfigHolder.init() finished initialization
|
||||
Platform.runLater(() -> {
|
||||
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
|
||||
|
||||
@@ -59,8 +59,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
|
||||
private void generateOptionsTxt() {
|
||||
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
|
||||
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
|
||||
|
||||
if (optionsFile.exists())
|
||||
return;
|
||||
if (configFolder.isDirectory())
|
||||
if (findFiles(configFolder, "options.txt"))
|
||||
return;
|
||||
try {
|
||||
// TODO: Dirty implementation here
|
||||
if (I18n.getCurrentLocale().getLocale() == Locale.CHINA)
|
||||
@@ -70,6 +75,20 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean findFiles(File folder, String fileName) {
|
||||
File[] fs = folder.listFiles();
|
||||
if (fs != null) {
|
||||
for (File f : fs) {
|
||||
if (f.isDirectory())
|
||||
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
|
||||
return true;
|
||||
if (f.getName().equals(fileName))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ManagedProcess launch() throws IOException, InterruptedException {
|
||||
generateOptionsTxt();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.game;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
@@ -62,6 +63,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
@@ -171,17 +173,7 @@ public final class LauncherHelper {
|
||||
.thenComposeAsync(() -> {
|
||||
return gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null);
|
||||
})
|
||||
.thenComposeAsync(Task.supplyAsync(() -> {
|
||||
try {
|
||||
return account.logIn();
|
||||
} catch (CredentialExpiredException e) {
|
||||
LOG.info("Credential has expired: " + e);
|
||||
return DialogController.logIn(account);
|
||||
} catch (AuthenticationException e) {
|
||||
LOG.warning("Authentication failed, try playing offline: " + e);
|
||||
return account.playOffline().orElseThrow(() -> e);
|
||||
}
|
||||
}).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
|
||||
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
|
||||
LaunchOptions launchOptions = repository.getLaunchOptions(selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, scriptFile != null);
|
||||
return new HMCLGameLauncher(
|
||||
@@ -375,7 +367,11 @@ public final class LauncherHelper {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetJavaVersion != null) {
|
||||
if (targetJavaVersion == null) {
|
||||
Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, continueAction, () -> {
|
||||
future.completeExceptionally(new CancellationException("No accepted java"));
|
||||
});
|
||||
} else {
|
||||
downloadJava(gameVersion.toString(), targetJavaVersion, profile)
|
||||
.thenAcceptAsync(downloadedJavaVersion -> {
|
||||
future.complete(downloadedJavaVersion);
|
||||
@@ -564,7 +560,11 @@ public final class LauncherHelper {
|
||||
|
||||
JFXHyperlink link = new JFXHyperlink(i18n("download.external_link"));
|
||||
link.setOnAction(e -> {
|
||||
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
|
||||
if (javaVersion.getMajorVersion() == JavaVersion.JAVA_8) {
|
||||
FXUtils.openLink(ORACLEJDK_DOWNLOAD_LINK);
|
||||
} else {
|
||||
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
|
||||
}
|
||||
future.completeExceptionally(new CancellationException());
|
||||
});
|
||||
|
||||
@@ -618,6 +618,43 @@ public final class LauncherHelper {
|
||||
return future;
|
||||
}
|
||||
|
||||
private static Task<AuthInfo> logIn(Account account) {
|
||||
return Task.composeAsync(() -> {
|
||||
try {
|
||||
return Task.completed(account.logIn());
|
||||
} catch (CredentialExpiredException e) {
|
||||
LOG.log(Level.INFO, "Credential has expired", e);
|
||||
|
||||
return Task.completed(DialogController.logIn(account));
|
||||
} catch (AuthenticationException e) {
|
||||
LOG.log(Level.WARNING, "Authentication failed, try playing offline", e);
|
||||
|
||||
CompletableFuture<Task<AuthInfo>> future = new CompletableFuture<>();
|
||||
runInFX(() -> {
|
||||
JFXButton loginOfflineButton = new JFXButton(i18n("account.login.offline"));
|
||||
loginOfflineButton.setOnAction(event -> {
|
||||
try {
|
||||
future.complete(Task.completed(account.playOffline()));
|
||||
} catch (AuthenticationException e2) {
|
||||
future.completeExceptionally(e2);
|
||||
}
|
||||
});
|
||||
JFXButton retryButton = new JFXButton(i18n("button.retry"));
|
||||
retryButton.setOnAction(event -> {
|
||||
future.complete(logIn(account));
|
||||
});
|
||||
Controllers.dialog(new MessageDialogPane.Builder(i18n("account.failed.server_disconnected"), i18n("account.failed"), MessageType.ERROR)
|
||||
.addAction(loginOfflineButton)
|
||||
.addAction(retryButton)
|
||||
.addCancel(() ->
|
||||
future.completeExceptionally(new CancellationException()))
|
||||
.build());
|
||||
});
|
||||
return Task.fromCompletableFuture(future).thenComposeAsync(task -> task);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Optional<String> getLog4jPatch(Version version) {
|
||||
Optional<String> log4jVersion = version.getLibraries().stream()
|
||||
.filter(it -> it.is("org.apache.logging.log4j", "log4j-core")
|
||||
@@ -815,6 +852,7 @@ public final class LauncherHelper {
|
||||
|
||||
}
|
||||
|
||||
private static final String ORACLEJDK_DOWNLOAD_LINK = "https://www.java.com/download";
|
||||
private static final String OPENJDK_DOWNLOAD_LINK = "https://docs.microsoft.com/java/openjdk/download";
|
||||
|
||||
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@@ -68,6 +68,8 @@ public class GlobalConfig implements Cloneable, Observable {
|
||||
|
||||
private IntegerProperty agreementVersion = new SimpleIntegerProperty();
|
||||
|
||||
private IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
|
||||
|
||||
private StringProperty multiplayerToken = new SimpleStringProperty();
|
||||
|
||||
private BooleanProperty multiplayerRelay = new SimpleBooleanProperty();
|
||||
@@ -113,6 +115,18 @@ public class GlobalConfig implements Cloneable, Observable {
|
||||
this.agreementVersion.set(agreementVersion);
|
||||
}
|
||||
|
||||
public int getPlatformPromptVersion() {
|
||||
return platformPromptVersion.get();
|
||||
}
|
||||
|
||||
public IntegerProperty platformPromptVersionProperty() {
|
||||
return platformPromptVersion;
|
||||
}
|
||||
|
||||
public void setPlatformPromptVersion(int platformPromptVersion) {
|
||||
this.platformPromptVersion.set(platformPromptVersion);
|
||||
}
|
||||
|
||||
public boolean isMultiplayerRelay() {
|
||||
return multiplayerRelay.get();
|
||||
}
|
||||
@@ -152,6 +166,7 @@ public class GlobalConfig implements Cloneable, Observable {
|
||||
public static class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
|
||||
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
|
||||
"agreementVersion",
|
||||
"platformPromptVersion",
|
||||
"multiplayerToken",
|
||||
"multiplayerRelay",
|
||||
"multiplayerAgreementVersion"
|
||||
@@ -165,6 +180,7 @@ public class GlobalConfig implements Cloneable, Observable {
|
||||
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
|
||||
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
|
||||
jsonObject.add("multiplayerToken", context.serialize(src.getMultiplayerToken()));
|
||||
jsonObject.add("multiplayerRelay", context.serialize(src.isMultiplayerRelay()));
|
||||
jsonObject.add("multiplayerAgreementVersion", context.serialize(src.getMultiplayerAgreementVersion()));
|
||||
@@ -183,6 +199,7 @@ public class GlobalConfig implements Cloneable, Observable {
|
||||
|
||||
GlobalConfig config = new GlobalConfig();
|
||||
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
config.setMultiplayerToken(Optional.ofNullable(obj.get("multiplayerToken")).map(JsonElement::getAsString).orElse(null));
|
||||
config.setMultiplayerRelay(Optional.ofNullable(obj.get("multiplayerRelay")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
config.setMultiplayerAgreementVersion(Optional.ofNullable(obj.get("multiplayerAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
|
||||
|
||||
@@ -53,7 +53,9 @@ import org.jackhuang.hmcl.util.Lazy;
|
||||
import org.jackhuang.hmcl.util.Logging;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.JavaVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
@@ -187,6 +189,19 @@ public final class Controllers {
|
||||
stage.initStyle(StageStyle.TRANSPARENT);
|
||||
stage.setScene(scene);
|
||||
|
||||
if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) {
|
||||
Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1);
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
Controllers.dialog(i18n("fatal.unsupported_platform.osx_arm64"), null, MessageType.INFO, continueAction);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction);
|
||||
} else {
|
||||
Controllers.dialog(i18n("fatal.unsupported_platform"), null, MessageType.WARNING, continueAction);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (globalConfig().getAgreementVersion() < 1) {
|
||||
JFXDialogLayout agreementPane = new JFXDialogLayout();
|
||||
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
|
||||
|
||||
@@ -77,7 +77,7 @@ public class GameCrashWindow extends Stage {
|
||||
private final StringProperty arch = new SimpleStringProperty(Architecture.SYSTEM_ARCH.getDisplayName());
|
||||
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
|
||||
private final BooleanProperty loading = new SimpleBooleanProperty();
|
||||
private final Label feedbackLabel = new Label(i18n("game.crash.feedback"));
|
||||
private final TextFlow feedbackTextFlow = new TextFlow();
|
||||
|
||||
private final ManagedProcess managedProcess;
|
||||
private final DefaultGameRepository repository;
|
||||
@@ -104,6 +104,8 @@ public class GameCrashWindow extends Stage {
|
||||
|
||||
this.view = new View();
|
||||
|
||||
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
|
||||
|
||||
setScene(new Scene(view, 800, 480));
|
||||
getScene().getStylesheets().addAll(config().getTheme().getStylesheets(config().getLauncherFontFamily()));
|
||||
setTitle(i18n("game.crash.title"));
|
||||
@@ -137,7 +139,7 @@ public class GameCrashWindow extends Stage {
|
||||
|
||||
if (exception != null) {
|
||||
LOG.log(Level.WARNING, "Failed to analyze crash report", exception);
|
||||
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
|
||||
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
|
||||
} else {
|
||||
List<CrashReportAnalyzer.Result> results = pair.getKey();
|
||||
Set<String> keywords = pair.getValue();
|
||||
@@ -177,12 +179,12 @@ public class GameCrashWindow extends Stage {
|
||||
if (!keywords.isEmpty()) {
|
||||
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.stacktrace", String.join(", ", keywords))));
|
||||
} else {
|
||||
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
|
||||
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
|
||||
}
|
||||
|
||||
feedbackLabel.setVisible(true);
|
||||
feedbackTextFlow.setVisible(true);
|
||||
} else {
|
||||
feedbackLabel.setVisible(false);
|
||||
feedbackTextFlow.setVisible(false);
|
||||
reasonTextFlow.getChildren().setAll(segments);
|
||||
}
|
||||
}
|
||||
@@ -350,7 +352,7 @@ public class GameCrashWindow extends Stage {
|
||||
|
||||
gameDirPane.setPadding(new Insets(8));
|
||||
VBox.setVgrow(gameDirPane, Priority.ALWAYS);
|
||||
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow));
|
||||
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow, feedbackTextFlow));
|
||||
}
|
||||
|
||||
HBox toolBar = new HBox();
|
||||
@@ -368,7 +370,7 @@ public class GameCrashWindow extends Stage {
|
||||
toolBar.setPadding(new Insets(8));
|
||||
toolBar.setSpacing(8);
|
||||
toolBar.getStyleClass().add("jfx-tool-bar");
|
||||
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, feedbackLabel);
|
||||
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton);
|
||||
}
|
||||
|
||||
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
|
||||
|
||||
@@ -36,6 +36,7 @@ import javafx.scene.layout.*;
|
||||
import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.util.Log4jLevel;
|
||||
import org.jackhuang.hmcl.util.Logging;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
|
||||
import javax.swing.*;
|
||||
@@ -99,7 +100,7 @@ public final class LogWindow extends Stage {
|
||||
}
|
||||
|
||||
public void logLine(String line, Log4jLevel level) {
|
||||
Log log = new Log(parseEscapeSequence(line), level);
|
||||
Log log = new Log(Logging.filterForbiddenToken(parseEscapeSequence(line)), level);
|
||||
logs.add(log);
|
||||
if (levelShownMap.get(level).get())
|
||||
impl.listView.getItems().add(log);
|
||||
|
||||
@@ -286,7 +286,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> {
|
||||
runInFX(() -> deviceCode.set(value));
|
||||
}));
|
||||
HBox box = new HBox(8);
|
||||
FlowPane box = new FlowPane();
|
||||
box.setHgap(8);
|
||||
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
|
||||
birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"));
|
||||
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
|
||||
@@ -294,8 +295,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.yggdrasil.purchase"));
|
||||
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
|
||||
deauthorizeLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"));
|
||||
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
|
||||
createProfileLink.setOnAction(e -> FXUtils.openLink("https://www.minecraft.net/msaprofile/mygames/editprofile"));
|
||||
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
|
||||
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink);
|
||||
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, createProfileLink);
|
||||
GridPane.setColumnSpan(box, 2);
|
||||
|
||||
vbox.getChildren().setAll(hintPane, box);
|
||||
|
||||
@@ -48,9 +48,9 @@ public class OAuthAccountLoginDialog extends DialogPane {
|
||||
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
|
||||
if (deviceCode != null) {
|
||||
FXUtils.copyText(deviceCode.getUserCode());
|
||||
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
|
||||
hintPane.setSegment(i18n("account.login.refresh.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
|
||||
} else {
|
||||
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
|
||||
hintPane.setSegment(i18n("account.login.refresh.microsoft.hint"));
|
||||
}
|
||||
});
|
||||
hintPane.setOnMouseClicked(e -> {
|
||||
|
||||
@@ -19,26 +19,37 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class AdvancedListBox extends VBox {
|
||||
public class AdvancedListBox extends ScrollPane {
|
||||
private final VBox container = new VBox();
|
||||
|
||||
{
|
||||
getStyleClass().add("advanced-list-box-content");
|
||||
setContent(container);
|
||||
|
||||
FXUtils.smoothScrolling(this);
|
||||
|
||||
setFitToHeight(true);
|
||||
setFitToWidth(true);
|
||||
setHbarPolicy(ScrollBarPolicy.NEVER);
|
||||
|
||||
container.getStyleClass().add("advanced-list-box-content");
|
||||
}
|
||||
|
||||
public AdvancedListBox add(Node child) {
|
||||
if (child instanceof Pane || child instanceof AdvancedListItem)
|
||||
getChildren().add(child);
|
||||
container.getChildren().add(child);
|
||||
else {
|
||||
StackPane pane = new StackPane();
|
||||
pane.getStyleClass().add("advanced-list-box-item");
|
||||
pane.getChildren().setAll(child);
|
||||
getChildren().add(pane);
|
||||
container.getChildren().add(pane);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -53,27 +64,27 @@ public class AdvancedListBox extends VBox {
|
||||
|
||||
public AdvancedListBox add(int index, Node child) {
|
||||
if (child instanceof Pane || child instanceof AdvancedListItem)
|
||||
getChildren().add(index, child);
|
||||
container.getChildren().add(index, child);
|
||||
else {
|
||||
StackPane pane = new StackPane();
|
||||
pane.getStyleClass().add("advanced-list-box-item");
|
||||
pane.getChildren().setAll(child);
|
||||
getChildren().add(index, pane);
|
||||
container.getChildren().add(index, pane);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public AdvancedListBox remove(Node child) {
|
||||
getChildren().remove(indexOf(child));
|
||||
container.getChildren().remove(indexOf(child));
|
||||
return this;
|
||||
}
|
||||
|
||||
public int indexOf(Node child) {
|
||||
if (child instanceof Pane) {
|
||||
return getChildren().indexOf(child);
|
||||
return container.getChildren().indexOf(child);
|
||||
} else {
|
||||
for (int i = 0; i < getChildren().size(); ++i) {
|
||||
Node node = getChildren().get(i);
|
||||
for (int i = 0; i < container.getChildren().size(); ++i) {
|
||||
Node node = container.getChildren().get(i);
|
||||
if (node instanceof StackPane) {
|
||||
ObservableList<Node> list = ((StackPane) node).getChildren();
|
||||
if (list.size() == 1 && list.get(0) == child)
|
||||
@@ -88,7 +99,11 @@ public class AdvancedListBox extends VBox {
|
||||
return add(new ClassTitle(category));
|
||||
}
|
||||
|
||||
public void setSpacing(double spacing) {
|
||||
container.setSpacing(spacing);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
getChildren().clear();
|
||||
container.getChildren().clear();
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,11 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.css.PseudoClass;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -115,12 +115,17 @@ public class ComponentList extends Control {
|
||||
expanded = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Orientation getContentBias() {
|
||||
return Orientation.HORIZONTAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected javafx.scene.control.Skin<?> createDefaultSkin() {
|
||||
return new Skin(this);
|
||||
}
|
||||
|
||||
protected static class Skin extends SkinBase<ComponentList> {
|
||||
protected static class Skin extends ControlSkinBase<ComponentList> {
|
||||
private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
|
||||
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
|
||||
|
||||
@@ -166,7 +171,7 @@ public class ComponentList extends Control {
|
||||
VBox vbox = new VBox();
|
||||
vbox.setFillWidth(true);
|
||||
Bindings.bindContent(vbox.getChildren(), list);
|
||||
getChildren().setAll(vbox);
|
||||
node = vbox;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2022 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.construct;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.Skin;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class ControlSkinBase<C extends Control> implements Skin<C> {
|
||||
private final C control;
|
||||
|
||||
protected Node node;
|
||||
|
||||
/**
|
||||
* Constructor for all SkinBase instances.
|
||||
*
|
||||
* @param control The control for which this Skin should attach to.
|
||||
*/
|
||||
protected ControlSkinBase(C control) {
|
||||
this.control = control;
|
||||
}
|
||||
|
||||
@Override
|
||||
public C getSkinnable() {
|
||||
return control;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getNode() {
|
||||
Objects.requireNonNull(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class DialogPane extends JFXDialogLayout {
|
||||
private final StringProperty title = new SimpleStringProperty();
|
||||
private final BooleanProperty valid = new SimpleBooleanProperty();
|
||||
private final BooleanProperty valid = new SimpleBooleanProperty(true);
|
||||
protected final SpinnerPane acceptPane = new SpinnerPane();
|
||||
protected final JFXButton cancelButton = new JFXButton();
|
||||
protected final Label warningLabel = new Label();
|
||||
|
||||
@@ -123,10 +123,11 @@ public final class MessageDialogPane extends StackPane {
|
||||
|
||||
public Builder addAction(Node actionNode) {
|
||||
dialog.addButton(actionNode);
|
||||
actionNode.getStyleClass().add("dialog-accept");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder ok(Runnable ok) {
|
||||
public Builder ok(@Nullable Runnable ok) {
|
||||
JFXButton btnOk = new JFXButton(i18n("button.ok"));
|
||||
btnOk.getStyleClass().add("dialog-accept");
|
||||
if (ok != null) {
|
||||
@@ -137,7 +138,22 @@ public final class MessageDialogPane extends StackPane {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder yesOrNo(Runnable yes, Runnable no) {
|
||||
public Builder addCancel(@Nullable Runnable cancel) {
|
||||
return addCancel(i18n("button.cancel"), cancel);
|
||||
}
|
||||
|
||||
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
|
||||
JFXButton btnCancel = new JFXButton(cancelText);
|
||||
btnCancel.getStyleClass().add("dialog-cancel");
|
||||
if (cancel != null) {
|
||||
btnCancel.setOnAction(e -> cancel.run());
|
||||
}
|
||||
dialog.addButton(btnCancel);
|
||||
dialog.setCancelButton(btnCancel);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder yesOrNo(@Nullable Runnable yes, @Nullable Runnable no) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.yes"));
|
||||
btnYes.getStyleClass().add("dialog-accept");
|
||||
if (yes != null) {
|
||||
@@ -145,27 +161,14 @@ public final class MessageDialogPane extends StackPane {
|
||||
}
|
||||
dialog.addButton(btnYes);
|
||||
|
||||
JFXButton btnNo = new JFXButton(i18n("button.no"));
|
||||
btnNo.getStyleClass().add("dialog-cancel");
|
||||
if (no != null) {
|
||||
btnNo.setOnAction(e -> no.run());
|
||||
}
|
||||
dialog.addButton(btnNo);
|
||||
dialog.setCancelButton(btnNo);
|
||||
addCancel(i18n("button.no"), no);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder actionOrCancel(ButtonBase actionButton, Runnable cancel) {
|
||||
dialog.addButton(actionButton);
|
||||
|
||||
JFXButton btnCancel = new JFXButton(i18n("button.cancel"));
|
||||
btnCancel.getStyleClass().add("dialog-cancel");
|
||||
if (cancel != null) {
|
||||
btnCancel.setOnAction(e -> cancel.run());
|
||||
}
|
||||
dialog.addButton(btnCancel);
|
||||
dialog.setCancelButton(btnCancel);
|
||||
|
||||
addCancel(cancel);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Toggle;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
@@ -165,19 +164,12 @@ public class MultiFileItem<T> extends VBox {
|
||||
pane.setLeft(left);
|
||||
|
||||
if (StringUtils.isNotBlank(subtitle)) {
|
||||
Optional<String> shortSubtitle = StringUtils.truncate(subtitle);
|
||||
Label right;
|
||||
if (shortSubtitle.isPresent()) {
|
||||
right = new Label(shortSubtitle.get());
|
||||
right.setTooltip(new Tooltip(subtitle));
|
||||
} else {
|
||||
right = new Label(subtitle);
|
||||
}
|
||||
BorderPane.setAlignment(right, Pos.CENTER_RIGHT);
|
||||
right.setWrapText(true);
|
||||
right.getStyleClass().add("subtitle-label");
|
||||
right.setStyle("-fx-font-size: 10;");
|
||||
pane.setRight(right);
|
||||
Label center = new Label(subtitle);
|
||||
BorderPane.setAlignment(center, Pos.CENTER_RIGHT);
|
||||
center.setWrapText(true);
|
||||
center.getStyleClass().add("subtitle-label");
|
||||
center.setStyle("-fx-font-size: 10;");
|
||||
pane.setCenter(center);
|
||||
}
|
||||
|
||||
return pane;
|
||||
|
||||
@@ -50,8 +50,9 @@ public class OptionToggleButton extends StackPane {
|
||||
titleLabel.textProperty().bind(title);
|
||||
Label subtitleLabel = new Label();
|
||||
subtitleLabel.setMouseTransparent(true);
|
||||
subtitleLabel.setWrapText(true);
|
||||
subtitleLabel.textProperty().bind(subtitle);
|
||||
pane.setLeft(left);
|
||||
pane.setCenter(left);
|
||||
left.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
JFXToggleButton toggleButton = new JFXToggleButton();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2022 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.construct;
|
||||
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class ServerAddressValidator extends ValidatorBase {
|
||||
private final boolean nullable;
|
||||
|
||||
public ServerAddressValidator() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
public ServerAddressValidator(@NamedArg("nullable") boolean nullable) {
|
||||
this(i18n("input.url"), nullable);
|
||||
}
|
||||
|
||||
public ServerAddressValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
|
||||
super(message);
|
||||
this.nullable = nullable;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void eval() {
|
||||
if (srcControl.get() instanceof TextInputControl) {
|
||||
evalTextInputField();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[-a-zA-Z0-9@:%._+~#=]{1,256}(:\\d+)?");
|
||||
|
||||
private void evalTextInputField() {
|
||||
TextInputControl textField = ((TextInputControl) srcControl.get());
|
||||
|
||||
if (StringUtils.isBlank(textField.getText()))
|
||||
hasErrors.set(!nullable);
|
||||
else
|
||||
hasErrors.set(!PATTERN.matcher(textField.getText()).matches());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2022 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.construct;
|
||||
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
|
||||
@@ -91,9 +91,11 @@ class AdditionalInstallersPage extends InstallersPage {
|
||||
String optiFine = analyzer.getVersion(OPTIFINE).orElse(null);
|
||||
String fabric = analyzer.getVersion(FABRIC).orElse(null);
|
||||
String fabricApi = analyzer.getVersion(FABRIC_API).orElse(null);
|
||||
String quilt = analyzer.getVersion(QUILT).orElse(null);
|
||||
String quiltApi = analyzer.getVersion(QUILT_API).orElse(null);
|
||||
|
||||
InstallerItem[] libraries = group.getLibraries();
|
||||
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi};
|
||||
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
|
||||
|
||||
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
|
||||
|
||||
|
||||
@@ -53,10 +53,9 @@ public class DownloadSettingsPage extends StackPane {
|
||||
getChildren().setAll(scrollPane);
|
||||
|
||||
{
|
||||
ComponentList downloadSource = new ComponentList();
|
||||
|
||||
VBox downloadSource = new VBox(8);
|
||||
downloadSource.getStyleClass().add("card-non-transparent");
|
||||
{
|
||||
VBox pane = new VBox(8);
|
||||
|
||||
VBox chooseWrapper = new VBox();
|
||||
chooseWrapper.setPadding(new Insets(8, 0, 8, 0));
|
||||
@@ -75,7 +74,7 @@ public class DownloadSettingsPage extends StackPane {
|
||||
JFXComboBox<String> cboVersionListSource = new JFXComboBox<>();
|
||||
cboVersionListSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
|
||||
versionListSourcePane.setRight(cboVersionListSource);
|
||||
FXUtils.setLimitWidth(cboVersionListSource, 420);
|
||||
FXUtils.setLimitWidth(cboVersionListSource, 400);
|
||||
|
||||
cboVersionListSource.getItems().setAll(DownloadProviders.providersById.keySet());
|
||||
selectedItemPropertyFor(cboVersionListSource).bindBidirectional(config().versionListSourceProperty());
|
||||
@@ -91,31 +90,28 @@ public class DownloadSettingsPage extends StackPane {
|
||||
|
||||
JFXComboBox<String> cboDownloadSource = new JFXComboBox<>();
|
||||
cboDownloadSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
|
||||
downloadSourcePane.setRight(cboDownloadSource);
|
||||
downloadSourcePane.setCenter(cboDownloadSource);
|
||||
FXUtils.setLimitWidth(cboDownloadSource, 420);
|
||||
|
||||
cboDownloadSource.getItems().setAll(DownloadProviders.rawProviders.keySet());
|
||||
selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty());
|
||||
}
|
||||
|
||||
pane.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
|
||||
downloadSource.getContent().add(pane);
|
||||
downloadSource.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
|
||||
}
|
||||
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.version_list_source")), downloadSource);
|
||||
}
|
||||
|
||||
{
|
||||
ComponentList downloadThreads = new ComponentList();
|
||||
|
||||
VBox downloadThreads = new VBox(16);
|
||||
downloadThreads.getStyleClass().add("card-non-transparent");
|
||||
{
|
||||
VBox pane = new VBox(16);
|
||||
pane.setPadding(new Insets(8, 0, 8, 0));
|
||||
|
||||
{
|
||||
JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto"));
|
||||
VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0));
|
||||
chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty());
|
||||
pane.getChildren().add(chkAutoDownloadThreads);
|
||||
downloadThreads.getChildren().add(chkAutoDownloadThreads);
|
||||
|
||||
chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> {
|
||||
if (newValue) {
|
||||
@@ -150,7 +146,7 @@ public class DownloadSettingsPage extends StackPane {
|
||||
});
|
||||
|
||||
hbox.getChildren().setAll(label, slider, threadsField);
|
||||
pane.getChildren().add(hbox);
|
||||
downloadThreads.getChildren().add(hbox);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -158,26 +154,22 @@ public class DownloadSettingsPage extends StackPane {
|
||||
VBox.setMargin(hintPane, new Insets(0, 0, 0, 30));
|
||||
hintPane.disableProperty().bind(config().autoDownloadThreadsProperty());
|
||||
hintPane.setText(i18n("settings.launcher.download.threads.hint"));
|
||||
pane.getChildren().add(hintPane);
|
||||
downloadThreads.getChildren().add(hintPane);
|
||||
}
|
||||
|
||||
downloadThreads.getContent().add(pane);
|
||||
}
|
||||
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads);
|
||||
}
|
||||
|
||||
{
|
||||
ComponentList proxyList = new ComponentList();
|
||||
|
||||
VBox proxyWrapper = new VBox();
|
||||
proxyWrapper.setPadding(new Insets(8, 0, 8, 0));
|
||||
proxyWrapper.setSpacing(10);
|
||||
VBox proxyList = new VBox(10);
|
||||
proxyList.getStyleClass().add("card-non-transparent");
|
||||
|
||||
VBox proxyPane = new VBox();
|
||||
{
|
||||
JFXCheckBox chkDisableProxy = new JFXCheckBox(i18n("settings.launcher.proxy.disable"));
|
||||
proxyWrapper.getChildren().add(chkDisableProxy);
|
||||
VBox.setMargin(chkDisableProxy, new Insets(8, 0, 0, 0));
|
||||
proxyList.getChildren().add(chkDisableProxy);
|
||||
reversedSelectedPropertyFor(chkDisableProxy).bindBidirectional(config().hasProxyProperty());
|
||||
proxyPane.disableProperty().bind(chkDisableProxy.selectedProperty());
|
||||
}
|
||||
@@ -310,9 +302,8 @@ public class DownloadSettingsPage extends StackPane {
|
||||
|
||||
proxyPane.getChildren().add(authPane);
|
||||
}
|
||||
proxyWrapper.getChildren().add(proxyPane);
|
||||
proxyList.getChildren().add(proxyPane);
|
||||
}
|
||||
proxyList.getContent().add(proxyWrapper);
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.proxy")), proxyList);
|
||||
}
|
||||
|
||||
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2022 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.multiplayer;
|
||||
|
||||
import com.jfoenix.controls.JFXCheckBox;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.layout.ColumnConstraints;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.util.FutureCallback;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware {
|
||||
|
||||
private final FutureCallback<CreationRequest> callback;
|
||||
private final LocalServerDetector lanServerDetectorThread;
|
||||
private final BooleanProperty allowAllJoinRequests = new SimpleBooleanProperty(true);
|
||||
|
||||
private LocalServerDetector.PingResponse server;
|
||||
|
||||
CreateMultiplayerRoomDialog(FutureCallback<CreationRequest> callback) {
|
||||
this.callback = callback;
|
||||
|
||||
setTitle(i18n("multiplayer.session.create"));
|
||||
|
||||
GridPane body = new GridPane();
|
||||
body.setMaxWidth(500);
|
||||
body.getColumnConstraints().addAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
|
||||
body.setVgap(8);
|
||||
body.setHgap(16);
|
||||
body.setDisable(true);
|
||||
setBody(body);
|
||||
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
hintPane.setText(i18n("multiplayer.session.create.hint"));
|
||||
GridPane.setColumnSpan(hintPane, 2);
|
||||
|
||||
body.addRow(0, hintPane);
|
||||
|
||||
Label nameField = new Label();
|
||||
nameField.setText(Optional.ofNullable(Accounts.getSelectedAccount())
|
||||
.map(Account::getUsername)
|
||||
.map(username -> i18n("multiplayer.session.name.format", username))
|
||||
.orElse(""));
|
||||
body.addRow(1, new Label(i18n("multiplayer.session.create.name")), nameField);
|
||||
|
||||
Label portLabel = new Label(i18n("multiplayer.nat.testing"));
|
||||
portLabel.setText(i18n("multiplayer.nat.testing"));
|
||||
body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel);
|
||||
|
||||
JFXCheckBox allowAllJoinRequestsCheckBox = new JFXCheckBox(i18n("multiplayer.session.create.join.allow"));
|
||||
allowAllJoinRequestsCheckBox.selectedProperty().bindBidirectional(allowAllJoinRequests);
|
||||
GridPane.setColumnSpan(allowAllJoinRequestsCheckBox, 2);
|
||||
body.addRow(3, allowAllJoinRequestsCheckBox);
|
||||
|
||||
setValid(false);
|
||||
|
||||
JFXHyperlink noinLink = new JFXHyperlink();
|
||||
noinLink.setText("mcer.cn");
|
||||
noinLink.setOnAction(e -> FXUtils.openLink("https://mcer.cn/circle/cato"));
|
||||
|
||||
setActions(warningLabel, noinLink, acceptPane, cancelButton);
|
||||
|
||||
lanServerDetectorThread = new LocalServerDetector(3);
|
||||
lanServerDetectorThread.onDetectedLanServer().register(event -> {
|
||||
runInFX(() -> {
|
||||
if (event.getLanServer() != null && event.getLanServer().isValid()) {
|
||||
nameField.setText(event.getLanServer().getMotd());
|
||||
portLabel.setText(event.getLanServer().getAd().toString());
|
||||
setValid(true);
|
||||
} else {
|
||||
nameField.setText("");
|
||||
portLabel.setText("");
|
||||
onFailure(i18n("multiplayer.session.create.port.error"));
|
||||
setValid(false);
|
||||
}
|
||||
server = event.getLanServer();
|
||||
body.setDisable(false);
|
||||
getProgressBar().setVisible(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAccept() {
|
||||
setLoading();
|
||||
|
||||
callback.call(new CreationRequest(
|
||||
Objects.requireNonNull(server),
|
||||
allowAllJoinRequests.get()
|
||||
), () -> {
|
||||
runInFX(this::onSuccess);
|
||||
}, msg -> {
|
||||
runInFX(() -> onFailure(msg));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialogShown() {
|
||||
getProgressBar().setVisible(true);
|
||||
getProgressBar().setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
|
||||
lanServerDetectorThread.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialogClosed() {
|
||||
lanServerDetectorThread.interrupt();
|
||||
}
|
||||
|
||||
public static class CreationRequest {
|
||||
private final LocalServerDetector.PingResponse server;
|
||||
private final boolean allowAllJoinRequests;
|
||||
|
||||
public CreationRequest(LocalServerDetector.PingResponse server, boolean allowAllJoinRequests) {
|
||||
this.server = server;
|
||||
this.allowAllJoinRequests = allowAllJoinRequests;
|
||||
}
|
||||
|
||||
public LocalServerDetector.PingResponse getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
public boolean isAllowAllJoinRequests() {
|
||||
return allowAllJoinRequests;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* Copyright (C) 2022 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
|
||||
@@ -17,31 +17,108 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.multiplayer;
|
||||
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ServerSocketChannel;
|
||||
import java.nio.channels.SocketChannel;
|
||||
import java.nio.channels.UnresolvedAddressException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class LocalServerBroadcaster implements Runnable {
|
||||
private final int port;
|
||||
private final MultiplayerManager.CatoSession session;
|
||||
public class LocalServerBroadcaster implements AutoCloseable {
|
||||
private final String address;
|
||||
private final ThreadGroup threadGroup = new ThreadGroup("JoinSession");
|
||||
|
||||
public LocalServerBroadcaster(int port, MultiplayerManager.CatoSession session) {
|
||||
this.port = port;
|
||||
this.session = session;
|
||||
}
|
||||
private final EventManager<Event> onExit = new EventManager<>();
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
private boolean running = true;
|
||||
|
||||
public LocalServerBroadcaster(String address) {
|
||||
this.address = address;
|
||||
this.threadGroup.setDaemon(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
public void close() {
|
||||
running = false;
|
||||
threadGroup.interrupt();
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public EventManager<Event> onExit() {
|
||||
return onExit;
|
||||
}
|
||||
|
||||
public static final Pattern ADDRESS_PATTERN = Pattern.compile("^\\s*(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):(\\d{1,5})\\s*$");
|
||||
|
||||
public void start() {
|
||||
Thread forwardPortThread = new Thread(threadGroup, this::forwardPort, "ForwardPort");
|
||||
forwardPortThread.start();
|
||||
}
|
||||
|
||||
private void forwardPort() {
|
||||
try {
|
||||
Matcher matcher = ADDRESS_PATTERN.matcher(address);
|
||||
if (!matcher.find()) {
|
||||
throw new MalformedURLException();
|
||||
}
|
||||
try (Socket forwardingSocket = new Socket();
|
||||
ServerSocket serverSocket = new ServerSocket()) {
|
||||
forwardingSocket.setSoTimeout(30000);
|
||||
forwardingSocket.connect(new InetSocketAddress(matcher.group(1), Lang.parseInt(matcher.group(2), 0)));
|
||||
|
||||
serverSocket.bind(null);
|
||||
|
||||
Thread broadcastMOTDThread = new Thread(threadGroup, () -> broadcastMOTD(serverSocket.getLocalPort()), "BroadcastMOTD");
|
||||
broadcastMOTDThread.start();
|
||||
|
||||
LOG.log(Level.INFO, "Listening " + serverSocket.getLocalSocketAddress());
|
||||
|
||||
while (running) {
|
||||
Socket forwardedSocket = serverSocket.accept();
|
||||
LOG.log(Level.INFO, "Accepting client");
|
||||
new Thread(threadGroup, () -> forwardTraffic(forwardingSocket, forwardedSocket), "Forward S->D").start();
|
||||
new Thread(threadGroup, () -> forwardTraffic(forwardedSocket, forwardingSocket), "Forward D->S").start();
|
||||
}
|
||||
}
|
||||
} catch (IOException | UnresolvedAddressException e) {
|
||||
LOG.log(Level.WARNING, "Error in forwarding port", e);
|
||||
} finally {
|
||||
close();
|
||||
onExit.fireEvent(new Event(this));
|
||||
}
|
||||
}
|
||||
|
||||
private void forwardTraffic(Socket src, Socket dest) {
|
||||
try(InputStream is = src.getInputStream(); OutputStream os = dest.getOutputStream()) {
|
||||
byte[] buf = new byte[1024];
|
||||
while (true) {
|
||||
int len = is.read(buf, 0, buf.length);
|
||||
if (len < 0) break;
|
||||
LOG.log(Level.INFO, "Forwarding buffer " + len);
|
||||
os.write(buf, 0, len);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Disconnected", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastMOTD(int port) {
|
||||
DatagramSocket socket;
|
||||
InetAddress broadcastAddress;
|
||||
try {
|
||||
@@ -52,9 +129,9 @@ public class LocalServerBroadcaster implements Runnable {
|
||||
return;
|
||||
}
|
||||
|
||||
while (session.isRunning()) {
|
||||
while (running) {
|
||||
try {
|
||||
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", session.getName()), port).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd"), port).getBytes(StandardCharsets.UTF_8);
|
||||
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
|
||||
socket.send(packet);
|
||||
LOG.finest("Broadcast server 0.0.0.0:" + port);
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
|
||||
public class LocalServerDetector extends Thread {
|
||||
|
||||
private final EventManager<DetectedLanServerEvent> onDetectedLanServer = new EventManager<>();
|
||||
private final int retry;
|
||||
|
||||
public LocalServerDetector(int retry) {
|
||||
this.retry = retry;
|
||||
|
||||
setName("LocalServerDetector");
|
||||
setDaemon(true);
|
||||
}
|
||||
|
||||
public EventManager<DetectedLanServerEvent> onDetectedLanServer() {
|
||||
return onDetectedLanServer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
MulticastSocket socket;
|
||||
InetAddress broadcastAddress;
|
||||
try {
|
||||
socket = new MulticastSocket(4445);
|
||||
socket.setSoTimeout(5000);
|
||||
socket.joinGroup(broadcastAddress = InetAddress.getByName("224.0.2.60"));
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to create datagram socket", e);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] buf = new byte[1024];
|
||||
|
||||
int tried = 0;
|
||||
while (!isInterrupted()) {
|
||||
DatagramPacket packet = new DatagramPacket(buf, 1024);
|
||||
|
||||
try {
|
||||
socket.receive(packet);
|
||||
} catch (SocketTimeoutException e) {
|
||||
if (tried++ > retry) {
|
||||
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, null));
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to detect lan server", e);
|
||||
break;
|
||||
}
|
||||
|
||||
String response = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
|
||||
LOG.fine("Local server " + packet.getAddress() + ":" + packet.getPort() + " broadcast message: " + response);
|
||||
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, PingResponse.parsePingResponse(response)));
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.leaveGroup(broadcastAddress);
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to leave multicast listening group", e);
|
||||
}
|
||||
|
||||
socket.close();
|
||||
}
|
||||
|
||||
public static class DetectedLanServerEvent extends Event {
|
||||
private final PingResponse lanServer;
|
||||
|
||||
public DetectedLanServerEvent(Object source, PingResponse lanServer) {
|
||||
super(source);
|
||||
this.lanServer = lanServer;
|
||||
}
|
||||
|
||||
public PingResponse getLanServer() {
|
||||
return lanServer;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PingResponse {
|
||||
private final String motd;
|
||||
private final Integer ad;
|
||||
|
||||
public PingResponse(String motd, Integer ad) {
|
||||
this.motd = motd;
|
||||
this.ad = ad;
|
||||
}
|
||||
|
||||
public String getMotd() {
|
||||
return motd;
|
||||
}
|
||||
|
||||
public Integer getAd() {
|
||||
return ad;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return ad != null;
|
||||
}
|
||||
|
||||
public static PingResponse parsePingResponse(String message) {
|
||||
return new PingResponse(
|
||||
StringUtils.substringBefore(
|
||||
StringUtils.substringAfter(message, "[MOTD]"),
|
||||
"[/MOTD]"),
|
||||
Lang.toIntOrNull(StringUtils.substringBefore(
|
||||
StringUtils.substringAfter(message, "[AD]"),
|
||||
"[/AD]"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSubtype;
|
||||
import org.jackhuang.hmcl.util.gson.JsonType;
|
||||
|
||||
public final class MultiplayerChannel {
|
||||
|
||||
private MultiplayerChannel() {
|
||||
}
|
||||
|
||||
@JsonType(
|
||||
property = "type",
|
||||
subtypes = {
|
||||
@JsonSubtype(clazz = HandshakeRequest.class, name = "handshake"),
|
||||
@JsonSubtype(clazz = JoinRequest.class, name = "join"),
|
||||
@JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive")
|
||||
}
|
||||
)
|
||||
public static class Request {
|
||||
}
|
||||
|
||||
public static class HandshakeRequest extends Request {
|
||||
}
|
||||
|
||||
public static class JoinRequest extends Request {
|
||||
private final String clientVersion;
|
||||
private final String username;
|
||||
|
||||
public JoinRequest(String clientVersion, String username) {
|
||||
this.clientVersion = clientVersion;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getClientVersion() {
|
||||
return clientVersion;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KeepAliveRequest extends Request {
|
||||
private final long timestamp;
|
||||
|
||||
public KeepAliveRequest(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonType(
|
||||
property = "type",
|
||||
subtypes = {
|
||||
@JsonSubtype(clazz = HandshakeResponse.class, name = "handshake"),
|
||||
@JsonSubtype(clazz = JoinResponse.class, name = "join"),
|
||||
@JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive"),
|
||||
@JsonSubtype(clazz = KickResponse.class, name = "kick")
|
||||
}
|
||||
)
|
||||
public static class Response {
|
||||
|
||||
}
|
||||
|
||||
public static class HandshakeResponse extends Response {
|
||||
}
|
||||
|
||||
public static class JoinResponse extends Response {
|
||||
private final String sessionName;
|
||||
private final int port;
|
||||
|
||||
public JoinResponse(String sessionName, int port) {
|
||||
this.sessionName = sessionName;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public String getSessionName() {
|
||||
return sessionName;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KeepAliveResponse extends Response {
|
||||
private final long timestamp;
|
||||
|
||||
public KeepAliveResponse(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KickResponse extends Response {
|
||||
private final String msg;
|
||||
|
||||
public KickResponse(String msg) {
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
public String getMsg() {
|
||||
return msg;
|
||||
}
|
||||
|
||||
public static final String VERSION_NOT_MATCHED = "version_not_matched";
|
||||
public static final String KICKED = "kicked";
|
||||
public static final String JOIN_ACEEPTANCE_TIMEOUT = "join_acceptance_timeout";
|
||||
}
|
||||
|
||||
public static class CatoClient extends Event {
|
||||
private final String username;
|
||||
|
||||
public CatoClient(Object source, String username) {
|
||||
super(source);
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
public static String verifyJson(String jsonString) {
|
||||
if (jsonString.indexOf('\r') >= 0 || jsonString.indexOf('\n') >= 0) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return jsonString;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.ConnectException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.TimerTask;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
|
||||
public class MultiplayerClient extends Thread {
|
||||
private final String id;
|
||||
private final int port;
|
||||
|
||||
private int gamePort;
|
||||
private boolean connected = false;
|
||||
|
||||
private final EventManager<ConnectedEvent> onConnected = new EventManager<>();
|
||||
private final EventManager<Event> onDisconnected = new EventManager<>();
|
||||
private final EventManager<KickEvent> onKicked = new EventManager<>();
|
||||
private final EventManager<Event> onHandshake = new EventManager<>();
|
||||
|
||||
public MultiplayerClient(String id, int port) {
|
||||
this.id = id;
|
||||
this.port = port;
|
||||
|
||||
setName("MultiplayerClient");
|
||||
setDaemon(true);
|
||||
}
|
||||
|
||||
public synchronized void setGamePort(int gamePort) {
|
||||
this.gamePort = gamePort;
|
||||
}
|
||||
|
||||
public synchronized int getGamePort() {
|
||||
return gamePort;
|
||||
}
|
||||
|
||||
public EventManager<ConnectedEvent> onConnected() {
|
||||
return onConnected;
|
||||
}
|
||||
|
||||
public EventManager<Event> onDisconnected() {
|
||||
return onDisconnected;
|
||||
}
|
||||
|
||||
public EventManager<KickEvent> onKicked() {
|
||||
return onKicked;
|
||||
}
|
||||
|
||||
public EventManager<Event> onHandshake() {
|
||||
return onHandshake;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
LOG.info("Connecting to 127.0.0.1:" + port);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
KeepAliveThread keepAliveThread = null;
|
||||
try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
|
||||
MultiplayerServer.Endpoint endpoint = new MultiplayerServer.Endpoint(socket, writer);
|
||||
socket.setSoTimeout(30000);
|
||||
LOG.info("Connected to 127.0.0.1:" + port);
|
||||
|
||||
endpoint.write(new HandshakeRequest());
|
||||
endpoint.write(new JoinRequest(MultiplayerManager.CATO_VERSION, id));
|
||||
|
||||
LOG.fine("Sent join request with id=" + id);
|
||||
|
||||
keepAliveThread = new KeepAliveThread(endpoint);
|
||||
keepAliveThread.start();
|
||||
|
||||
TimerTask task = Lang.setTimeout(() -> {
|
||||
// If after 15 seconds, we didn't receive the HandshakeResponse,
|
||||
// We fail to establish the connection with server.
|
||||
|
||||
try {
|
||||
LOG.log(Level.WARNING, "Socket connection timeout, closing socket");
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to close socket", e);
|
||||
}
|
||||
}, 25 * 1000);
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.fine("Message from server:" + line);
|
||||
|
||||
Response response = JsonUtils.fromNonNullJson(line, Response.class);
|
||||
|
||||
if (response instanceof JoinResponse) {
|
||||
JoinResponse joinResponse = JsonUtils.fromNonNullJson(line, JoinResponse.class);
|
||||
setGamePort(joinResponse.getPort());
|
||||
|
||||
connected = true;
|
||||
|
||||
onConnected.fireEvent(new ConnectedEvent(this, joinResponse.getSessionName(), joinResponse.getPort()));
|
||||
|
||||
LOG.fine("Received join response with port " + joinResponse.getPort());
|
||||
} else if (response instanceof KickResponse) {
|
||||
LOG.fine("Kicked by the server");
|
||||
onKicked.fireEvent(new KickEvent(this, ((KickResponse) response).getMsg()));
|
||||
return;
|
||||
} else if (response instanceof KeepAliveResponse) {
|
||||
} else if (response instanceof HandshakeResponse) {
|
||||
LOG.fine("Established connection with server");
|
||||
onHandshake.fireEvent(new Event(this));
|
||||
task.cancel();
|
||||
} else {
|
||||
LOG.log(Level.WARNING, "Unrecognized packet from server:" + line);
|
||||
}
|
||||
}
|
||||
} catch (ConnectException e) {
|
||||
LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)");
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException ex) {
|
||||
LOG.warning("MultiplayerClient interrupted");
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
} catch (IOException | JsonParseException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (keepAliveThread != null) {
|
||||
keepAliveThread.interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
LOG.info("Lost connection to 127.0.0.1:" + port);
|
||||
onDisconnected.fireEvent(new Event(this));
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
private static class KeepAliveThread extends Thread {
|
||||
|
||||
private final MultiplayerServer.Endpoint endpoint;
|
||||
|
||||
public KeepAliveThread(MultiplayerServer.Endpoint endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (!isInterrupted()) {
|
||||
try {
|
||||
endpoint.write(new KeepAliveRequest(System.currentTimeMillis()));
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to send keep alive packet", e);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConnectedEvent extends Event {
|
||||
private final String sessionName;
|
||||
private final int port;
|
||||
|
||||
public ConnectedEvent(Object source, String sessionName, int port) {
|
||||
super(source);
|
||||
this.sessionName = sessionName;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public String getSessionName() {
|
||||
return sessionName;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KickEvent extends Event {
|
||||
private final String reason;
|
||||
|
||||
public KickEvent(Object source, String reason) {
|
||||
super(source);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@
|
||||
package org.jackhuang.hmcl.ui.multiplayer;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.application.Platform;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
@@ -27,8 +25,10 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.DateTypeAdapter;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
@@ -40,50 +40,79 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.wrap;
|
||||
import static org.jackhuang.hmcl.util.Lang.*;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
/**
|
||||
* Cato Management.
|
||||
*/
|
||||
public final class MultiplayerManager {
|
||||
static final String CATO_VERSION = "1.2.2";
|
||||
private static final String CATO_DOWNLOAD_URL = "https://gitcode.net/to/cato/-/raw/master/client/";
|
||||
private static final String CATO_HASH_URL = CATO_DOWNLOAD_URL + "cato-all-files.sha1";
|
||||
private static final String CATO_PATH = getCatoPath();
|
||||
public static final int CATO_AGREEMENT_VERSION = 2;
|
||||
static final String HIPER_VERSION = "1.2.2";
|
||||
private static final String HIPER_DOWNLOAD_URL = "https://gitcode.net/to/hiper/-/raw/master/";
|
||||
private static final String HIPER_PACKAGES_URL = HIPER_DOWNLOAD_URL + "packages.sha1";
|
||||
private static final String HIPER_POINTS_URL = "https://cert.mcer.cn/point.yml";
|
||||
private static final Path HIPER_CONFIG_PATH = Metadata.HMCL_DIRECTORY.resolve("hiper.yml");
|
||||
public static final Path HIPER_PATH = getHiperLocalDirectory().resolve(getHiperFileName());
|
||||
public static final int HIPER_AGREEMENT_VERSION = 3;
|
||||
private static final String REMOTE_ADDRESS = "127.0.0.1";
|
||||
private static final String LOCAL_ADDRESS = "0.0.0.0";
|
||||
|
||||
private static final Map<Architecture, String> archMap = mapOf(
|
||||
pair(Architecture.ARM32, "arm-7"),
|
||||
pair(Architecture.ARM64, "arm64"),
|
||||
pair(Architecture.X86, "386"),
|
||||
pair(Architecture.X86_64, "amd64"),
|
||||
pair(Architecture.LOONGARCH64, "loong64"),
|
||||
pair(Architecture.MIPS, "mips"),
|
||||
pair(Architecture.MIPS64, "mips64"),
|
||||
pair(Architecture.MIPS64EL, "mips64le"),
|
||||
pair(Architecture.PPC64LE, "ppc64le"),
|
||||
pair(Architecture.RISCV, "riscv64"),
|
||||
pair(Architecture.MIPSEL, "mipsle")
|
||||
);
|
||||
|
||||
private static final Map<OperatingSystem, String> osMap = mapOf(
|
||||
pair(OperatingSystem.LINUX, "linux"),
|
||||
pair(OperatingSystem.WINDOWS, "windows"),
|
||||
pair(OperatingSystem.OSX, "darwin")
|
||||
);
|
||||
|
||||
private static final String HIPER_TARGET_NAME = String.format("%s-%s",
|
||||
osMap.getOrDefault(OperatingSystem.CURRENT_OS, "windows"),
|
||||
archMap.getOrDefault(Architecture.CURRENT_ARCH, "amd64"));
|
||||
|
||||
|
||||
private static CompletableFuture<Map<String, String>> HASH;
|
||||
|
||||
private MultiplayerManager() {
|
||||
}
|
||||
|
||||
private static CompletableFuture<Map<String, String>> getCatoHash() {
|
||||
public static void clearConfiguration() {
|
||||
HIPER_CONFIG_PATH.toFile().delete();
|
||||
}
|
||||
|
||||
private static CompletableFuture<Map<String, String>> getPackagesHash() {
|
||||
FXUtils.checkFxUserThread();
|
||||
if (HASH == null) {
|
||||
HASH = CompletableFuture.supplyAsync(wrap(() -> {
|
||||
String hashList = HttpRequest.GET(CATO_HASH_URL).getString();
|
||||
String hashList = HttpRequest.GET(HIPER_PACKAGES_URL).getString();
|
||||
Map<String, String> hashes = new HashMap<>();
|
||||
for (String line : hashList.split("\n")) {
|
||||
String[] items = line.trim().split(" ");
|
||||
String[] items = line.trim().split(" {2}");
|
||||
if (items.length == 2 && items[0].length() == 40) {
|
||||
hashes.put(items[1], items[0]);
|
||||
} else {
|
||||
LOG.warning("Failed to parse cato hash file, hash line " + line);
|
||||
LOG.warning("Failed to parse Hiper packages.sha1 file, line: " + line);
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
@@ -92,400 +121,206 @@ public final class MultiplayerManager {
|
||||
return HASH;
|
||||
}
|
||||
|
||||
public static Task<Void> downloadCato() {
|
||||
return Task.fromCompletableFuture(getCatoHash()).thenComposeAsync(catoHashes ->
|
||||
new FileDownloadTask(
|
||||
NetworkUtils.toURL(CATO_DOWNLOAD_URL + getCatoFileName()),
|
||||
getCatoExecutable().toFile(),
|
||||
catoHashes.get(getCatoFileName()) == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", catoHashes.get(getCatoFileName()))
|
||||
).thenRunAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
|
||||
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(getCatoExecutable());
|
||||
perm.add(PosixFilePermission.OWNER_EXECUTE);
|
||||
Files.setPosixFilePermissions(getCatoExecutable(), perm);
|
||||
}
|
||||
}));
|
||||
public static Task<Void> downloadHiper() {
|
||||
return Task.fromCompletableFuture(getPackagesHash()).thenComposeAsync(packagesHash -> {
|
||||
|
||||
BiFunction<String, String, FileDownloadTask> getFileDownloadTask = (String remotePath, String localFileName) -> {
|
||||
String hash = packagesHash.get(remotePath);
|
||||
return new FileDownloadTask(
|
||||
NetworkUtils.toURL(String.format("%s%s", HIPER_DOWNLOAD_URL, remotePath)),
|
||||
getHiperLocalDirectory().resolve(localFileName).toFile(),
|
||||
hash == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", hash));
|
||||
};
|
||||
|
||||
List<Task<?>> tasks;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
if (!packagesHash.containsKey(String.format("%s/hiper.exe", HIPER_TARGET_NAME))) {
|
||||
throw new HiperUnsupportedPlatformException();
|
||||
}
|
||||
tasks = Arrays.asList(
|
||||
getFileDownloadTask.apply(String.format("%s/hiper.exe", HIPER_TARGET_NAME), "hiper.exe"),
|
||||
getFileDownloadTask.apply(String.format("%s/wintun.dll", HIPER_TARGET_NAME), "wintun.dll")
|
||||
// getFileDownloadTask.apply("tap-windows-9.21.2.exe", "tap-windows-9.21.2.exe")
|
||||
);
|
||||
} else {
|
||||
if (!packagesHash.containsKey(String.format("%s/hiper", HIPER_TARGET_NAME))) {
|
||||
throw new HiperUnsupportedPlatformException();
|
||||
}
|
||||
tasks = Collections.singletonList(getFileDownloadTask.apply(String.format("%s/hiper", HIPER_TARGET_NAME), "hiper"));
|
||||
}
|
||||
return Task.allOf(tasks).thenRunAsync(() -> {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
|
||||
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(HIPER_PATH);
|
||||
perm.add(PosixFilePermission.OWNER_EXECUTE);
|
||||
Files.setPosixFilePermissions(HIPER_PATH, perm);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static Path getCatoExecutable() {
|
||||
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH);
|
||||
private static void verifyChecksumAndDeleteIfNotMatched(Path file, @Nullable String expectedChecksum) throws IOException {
|
||||
try {
|
||||
if (expectedChecksum != null) {
|
||||
ChecksumMismatchException.verifyChecksum(file, "SHA-1", expectedChecksum);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Files.deleteIfExists(file);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<CatoSession> startCato(String token, State state) {
|
||||
return getCatoHash().thenApplyAsync(wrap(catoHashes -> {
|
||||
Path exe = getCatoExecutable();
|
||||
if (!Files.isRegularFile(exe)) {
|
||||
throw new CatoNotExistsException(exe);
|
||||
public static CompletableFuture<HiperSession> startHiper(String token) {
|
||||
return getPackagesHash().thenApplyAsync(wrap(packagesHash -> {
|
||||
if (!Files.isRegularFile(HIPER_PATH)) {
|
||||
throw new HiperNotExistsException(HIPER_PATH);
|
||||
}
|
||||
|
||||
if (!isPortAvailable(3478)) {
|
||||
throw new CatoAlreadyStartedException();
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("hiper.exe"), packagesHash.get(String.format("%s/hiper.exe", HIPER_TARGET_NAME)));
|
||||
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("wintun.dll"), packagesHash.get(String.format("%s/wintun.dll", HIPER_TARGET_NAME)));
|
||||
// verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("tap-windows-9.21.2.exe"), packagesHash.get("tap-windows-9.21.2.exe"));
|
||||
} else {
|
||||
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("hiper"), packagesHash.get(String.format("%s/hiper", HIPER_TARGET_NAME)));
|
||||
}
|
||||
|
||||
// 下载 HiPer 配置文件
|
||||
String certFileContent;
|
||||
try {
|
||||
String hash = catoHashes.get(getCatoFileName());
|
||||
if (hash != null) {
|
||||
ChecksumMismatchException.verifyChecksum(exe, "SHA-1", hash);
|
||||
certFileContent = HttpRequest.GET(String.format("https://cert.mcer.cn/%s.yml", token)).getString();
|
||||
if (!certFileContent.equals("")) {
|
||||
certFileContent += "\nlogging:\n format: json\n file_path: ./hiper.log";
|
||||
FileUtils.writeText(HIPER_CONFIG_PATH, certFileContent);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Files.deleteIfExists(exe);
|
||||
throw e;
|
||||
LOG.log(Level.WARNING, "configuration file cloud cache index code has been not available , try to use the local configuration file", e);
|
||||
}
|
||||
|
||||
String[] commands = StringUtils.isBlank(token)
|
||||
? new String[]{exe.toString()}
|
||||
: new String[]{exe.toString(), "-auth.token", token};
|
||||
String[] commands = new String[]{HIPER_PATH.toString(), "-config", HIPER_CONFIG_PATH.toString()};
|
||||
Process process = new ProcessBuilder()
|
||||
.command(commands)
|
||||
.start();
|
||||
|
||||
return new CatoSession(state, process, Arrays.asList(commands));
|
||||
return new HiperSession(process, Arrays.asList(commands));
|
||||
}));
|
||||
}
|
||||
|
||||
public static CompletableFuture<CatoSession> joinSession(String token, String peer, Mode mode, int remotePort, int localPort, JoinSessionHandler handler) throws IncompatibleCatoVersionException {
|
||||
LOG.info(String.format("Joining session (token=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, peer, mode, remotePort, localPort));
|
||||
|
||||
return startCato(token, State.SLAVE).thenComposeAsync(wrap(session -> {
|
||||
CompletableFuture<CatoSession> future = new CompletableFuture<>();
|
||||
|
||||
session.forwardPort(peer, LOCAL_ADDRESS, localPort, REMOTE_ADDRESS, remotePort, mode);
|
||||
|
||||
Consumer<CatoExitEvent> onExit = event -> {
|
||||
boolean ready = session.isReady();
|
||||
switch (event.getExitCode()) {
|
||||
case 1:
|
||||
if (!ready) {
|
||||
future.completeExceptionally(new CatoExitTimeoutException());
|
||||
}
|
||||
break;
|
||||
}
|
||||
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
|
||||
};
|
||||
session.onExit.register(onExit);
|
||||
|
||||
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
|
||||
future.completeExceptionally(new PeerConnectionTimeoutException());
|
||||
session.stop();
|
||||
}, 15 * 1000);
|
||||
|
||||
session.onPeerConnected.register(event -> {
|
||||
peerConnectionTimeoutTask.cancel();
|
||||
|
||||
MultiplayerClient client = new MultiplayerClient(session.getId(), localPort);
|
||||
session.addRelatedThread(client);
|
||||
session.setClient(client);
|
||||
|
||||
TimerTask task = Lang.setTimeout(() -> {
|
||||
Platform.runLater(() -> future.completeExceptionally(new JoinRequestTimeoutException()));
|
||||
session.stop();
|
||||
}, 30 * 1000);
|
||||
|
||||
client.onConnected().register(connectedEvent -> {
|
||||
try {
|
||||
int port = findAvailablePort();
|
||||
session.forwardPort(peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode);
|
||||
session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(port, session), "LocalServerBroadcaster", true));
|
||||
session.setName(connectedEvent.getSessionName());
|
||||
client.setGamePort(port);
|
||||
Platform.runLater(() -> {
|
||||
session.onExit.unregister(onExit);
|
||||
future.complete(session);
|
||||
});
|
||||
} catch (IOException e) {
|
||||
session.stop();
|
||||
Platform.runLater(() -> future.completeExceptionally(e));
|
||||
}
|
||||
task.cancel();
|
||||
});
|
||||
client.onKicked().register(kickedEvent -> {
|
||||
session.stop();
|
||||
task.cancel();
|
||||
Platform.runLater(() -> {
|
||||
future.completeExceptionally(new KickedException(kickedEvent.getReason()));
|
||||
});
|
||||
});
|
||||
client.onDisconnected().register(disconnectedEvent -> {
|
||||
Platform.runLater(() -> {
|
||||
if (!client.isConnected()) {
|
||||
// We fail to establish connection with server
|
||||
future.completeExceptionally(new ConnectionErrorException());
|
||||
}
|
||||
});
|
||||
});
|
||||
client.onHandshake().register(handshakeEvent -> {
|
||||
if (handler != null) {
|
||||
handler.onWaitingForJoinResponse();
|
||||
}
|
||||
});
|
||||
client.start();
|
||||
});
|
||||
|
||||
return future;
|
||||
}));
|
||||
}
|
||||
|
||||
public static CompletableFuture<CatoSession> createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) {
|
||||
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
|
||||
|
||||
return startCato(token, State.MASTER).thenComposeAsync(wrap(session -> {
|
||||
CompletableFuture<CatoSession> future = new CompletableFuture<>();
|
||||
|
||||
MultiplayerServer server = new MultiplayerServer(sessionName, gamePort, allowAllJoinRequests);
|
||||
server.startServer();
|
||||
|
||||
session.setName(sessionName);
|
||||
session.allowForwardingAddress(REMOTE_ADDRESS, server.getPort());
|
||||
session.allowForwardingAddress(REMOTE_ADDRESS, gamePort);
|
||||
session.showAllowedAddress();
|
||||
|
||||
Consumer<CatoExitEvent> onExit = event -> {
|
||||
boolean ready = session.isReady();
|
||||
switch (event.getExitCode()) {
|
||||
case 1:
|
||||
if (!ready) {
|
||||
future.completeExceptionally(new CatoExitTimeoutException());
|
||||
}
|
||||
break;
|
||||
}
|
||||
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
|
||||
};
|
||||
|
||||
session.onExit.register(onExit);
|
||||
session.setServer(server);
|
||||
session.addRelatedThread(server);
|
||||
|
||||
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
|
||||
future.completeExceptionally(new PeerConnectionTimeoutException());
|
||||
session.stop();
|
||||
}, 15 * 1000);
|
||||
|
||||
session.onPeerConnected.register(event -> {
|
||||
peerConnectionTimeoutTask.cancel();
|
||||
Platform.runLater(() -> {
|
||||
session.onExit.unregister(onExit);
|
||||
future.complete(session);
|
||||
});
|
||||
});
|
||||
|
||||
return future;
|
||||
}));
|
||||
}
|
||||
|
||||
public static final Pattern INVITATION_CODE_PATTERN = Pattern.compile("^(?<id>.*?)#(?<port>\\d{2,5})$");
|
||||
|
||||
public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException {
|
||||
Matcher matcher = INVITATION_CODE_PATTERN.matcher(invitationCode);
|
||||
if (!matcher.find()) throw new IllegalArgumentException("Invalid invitation code");
|
||||
return new Invitation(matcher.group("id"), Integer.parseInt(matcher.group("port")));
|
||||
}
|
||||
|
||||
public static int findAvailablePort() throws IOException {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
public static String getHiperFileName() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
return "hiper.exe";
|
||||
} else {
|
||||
return "hiper";
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isPortAvailable(int port) {
|
||||
try (ServerSocket socket = new ServerSocket(port)) {
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
public static Path getHiperLocalDirectory() {
|
||||
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve("hiper").resolve("hiper").resolve(HIPER_VERSION);
|
||||
}
|
||||
|
||||
private static String getCatoFileName() {
|
||||
switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato-client-windows-amd64.exe";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato-client-windows-arm64.exe";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.X86) {
|
||||
return "cato-client-windows-i386.exe";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
case OSX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato-client-darwin-amd64";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato-client-darwin-arm64";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
case LINUX:
|
||||
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
|
||||
return "cato-client-linux-amd64";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM32) {
|
||||
return "cato-client-linux-arm7";
|
||||
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
|
||||
return "cato-client-linux-arm64";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static String getCatoPath() {
|
||||
String name = getCatoFileName();
|
||||
if (StringUtils.isBlank(name)) return "";
|
||||
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/" + name;
|
||||
}
|
||||
|
||||
public static class CatoSession extends ManagedProcess {
|
||||
private final EventManager<CatoExitEvent> onExit = new EventManager<>();
|
||||
private final EventManager<CatoIdEvent> onIdGenerated = new EventManager<>();
|
||||
private final EventManager<Event> onPeerConnected = new EventManager<>();
|
||||
|
||||
private String name;
|
||||
private final State type;
|
||||
private String id;
|
||||
private boolean peerConnected = false;
|
||||
private MultiplayerClient client;
|
||||
private MultiplayerServer server;
|
||||
public static class HiperSession extends ManagedProcess {
|
||||
private final EventManager<HiperExitEvent> onExit = new EventManager<>();
|
||||
private final EventManager<HiperIPEvent> onIPAllocated = new EventManager<>();
|
||||
private final EventManager<HiperShowValidUntilEvent> onValidUntil = new EventManager<>();
|
||||
private final BufferedWriter writer;
|
||||
private int error = 0;
|
||||
|
||||
CatoSession(State type, Process process, List<String> commands) {
|
||||
HiperSession(Process process, List<String> commands) {
|
||||
super(process, commands);
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
|
||||
|
||||
LOG.info("Started cato with command: " + new CommandBuilder().addAll(commands));
|
||||
LOG.info("Started hiper with command: " + new CommandBuilder().addAll(commands));
|
||||
|
||||
this.type = type;
|
||||
addRelatedThread(Lang.thread(this::waitFor, "CatoExitWaiter", true));
|
||||
pumpInputStream(this::checkCatoLog);
|
||||
pumpErrorStream(this::checkCatoLog);
|
||||
addRelatedThread(Lang.thread(this::waitFor, "HiperExitWaiter", true));
|
||||
pumpInputStream(this::onLog);
|
||||
pumpErrorStream(this::onLog);
|
||||
|
||||
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public synchronized MultiplayerClient getClient() {
|
||||
return client;
|
||||
}
|
||||
private void onLog(String log) {
|
||||
LOG.info("[Hiper] " + log);
|
||||
|
||||
public synchronized CatoSession setClient(MultiplayerClient client) {
|
||||
this.client = client;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MultiplayerServer getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
public CatoSession setServer(MultiplayerServer server) {
|
||||
this.server = server;
|
||||
return this;
|
||||
}
|
||||
|
||||
private void checkCatoLog(String log) {
|
||||
LOG.info("Cato: " + log);
|
||||
if (id == null) {
|
||||
Matcher matcher = TEMP_TOKEN_PATTERN.matcher(log);
|
||||
if (matcher.find()) {
|
||||
id = matcher.group("id");
|
||||
onIdGenerated.fireEvent(new CatoIdEvent(this, id));
|
||||
}
|
||||
if (log.contains("failed to load config")) {
|
||||
error = HiperExitEvent.INVALID_CONFIGURATION;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!peerConnected) {
|
||||
Matcher matcher = PEER_CONNECTED_PATTERN.matcher(log);
|
||||
if (matcher.find()) {
|
||||
peerConnected = true;
|
||||
onPeerConnected.fireEvent(new Event(this));
|
||||
try {
|
||||
Map<?, ?> logJson = JsonUtils.fromNonNullJson(log, Map.class);
|
||||
String msg = "";
|
||||
if (logJson.containsKey("msg")) {
|
||||
msg = tryCast(logJson.get("msg"), String.class).orElse("");
|
||||
if (msg.contains("Failed to get a tun/tap device")) {
|
||||
error = HiperExitEvent.FAILED_GET_DEVICE;
|
||||
}
|
||||
if (msg.contains("Failed to load certificate from config")) {
|
||||
error = HiperExitEvent.FAILED_LOAD_CONFIG;
|
||||
}
|
||||
if (msg.contains("Validity of client certificate")) {
|
||||
Optional<String> validUntil = tryCast(logJson.get("valid"), String.class);
|
||||
if (validUntil.isPresent()) {
|
||||
try {
|
||||
Date date = DateTypeAdapter.deserializeToDate(validUntil.get());
|
||||
onValidUntil.fireEvent(new HiperShowValidUntilEvent(this, date));
|
||||
} catch (JsonParseException e) {
|
||||
LOG.log(Level.WARNING, "Failed to parse certification expire time string: " + validUntil.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (logJson.containsKey("network")) {
|
||||
Map<?, ?> network = tryCast(logJson.get("network"), Map.class).orElse(Collections.emptyMap());
|
||||
if (network.containsKey("IP") && msg.contains("Main HostMap created")) {
|
||||
Optional<String> ip = tryCast(network.get("IP"), String.class);
|
||||
ip.ifPresent(s -> onIPAllocated.fireEvent(new HiperIPEvent(this, s)));
|
||||
}
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
LOG.log(Level.WARNING, "Failed to parse hiper log: " + log, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitFor() {
|
||||
try {
|
||||
int exitCode = getProcess().waitFor();
|
||||
LOG.info("cato exited with exitcode " + exitCode);
|
||||
onExit.fireEvent(new CatoExitEvent(this, exitCode));
|
||||
LOG.info("Hiper exited with exitcode " + exitCode);
|
||||
if (error != 0) {
|
||||
onExit.fireEvent(new HiperExitEvent(this, error));
|
||||
} else {
|
||||
onExit.fireEvent(new HiperExitEvent(this, exitCode));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED));
|
||||
onExit.fireEvent(new HiperExitEvent(this, HiperExitEvent.INTERRUPTED));
|
||||
} finally {
|
||||
try {
|
||||
if (writer != null)
|
||||
writer.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to close cato stdin writer", e);
|
||||
LOG.log(Level.WARNING, "Failed to close Hiper stdin writer", e);
|
||||
}
|
||||
}
|
||||
destroyRelatedThreads();
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return id != null;
|
||||
}
|
||||
|
||||
public synchronized String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public synchronized void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public State getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String generateInvitationCode(int serverPort) {
|
||||
if (id == null) {
|
||||
throw new IllegalStateException("id not generated");
|
||||
}
|
||||
return id + "#" + serverPort;
|
||||
}
|
||||
|
||||
public synchronized void invokeCommand(String command) throws IOException {
|
||||
LOG.info("Invoking cato: " + command);
|
||||
writer.write(command);
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
public void forwardPort(String peerId, String localAddress, int localPort, String remoteAddress, int remotePort, Mode mode) throws IOException {
|
||||
invokeCommand(String.format("net add %s %s:%d %s:%d %s", peerId, localAddress, localPort, remoteAddress, remotePort, mode.getName()));
|
||||
}
|
||||
|
||||
public void allowForwardingAddress(String address, int port) throws IOException {
|
||||
invokeCommand(String.format("ufw net open %s:%d", address, port));
|
||||
}
|
||||
|
||||
public void showAllowedAddress() throws IOException {
|
||||
invokeCommand("ufw net whitelist");
|
||||
}
|
||||
|
||||
public EventManager<CatoExitEvent> onExit() {
|
||||
public EventManager<HiperExitEvent> onExit() {
|
||||
return onExit;
|
||||
}
|
||||
|
||||
public EventManager<CatoIdEvent> onIdGenerated() {
|
||||
return onIdGenerated;
|
||||
public EventManager<HiperIPEvent> onIPAllocated() {
|
||||
return onIPAllocated;
|
||||
}
|
||||
|
||||
public EventManager<Event> onPeerConnected() {
|
||||
return onPeerConnected;
|
||||
public EventManager<HiperShowValidUntilEvent> onValidUntil() {
|
||||
return onValidUntil;
|
||||
}
|
||||
|
||||
private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\((?<id>\\w+)\\)");
|
||||
private static final Pattern PEER_CONNECTED_PATTERN = Pattern.compile("Connected to main net");
|
||||
private static final Pattern LOG_PATTERN = Pattern.compile("(\\[\\d+])\\s+(\\w+)\\s+(\\w+-{0,1}\\w+):\\s(.*)");
|
||||
}
|
||||
|
||||
public static class CatoExitEvent extends Event {
|
||||
public static class HiperExitEvent extends Event {
|
||||
private final int exitCode;
|
||||
|
||||
public CatoExitEvent(Object source, int exitCode) {
|
||||
public HiperExitEvent(Object source, int exitCode) {
|
||||
super(source);
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
@@ -494,85 +329,44 @@ public final class MultiplayerManager {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
public static final int EXIT_CODE_INTERRUPTED = -1;
|
||||
public static final int EXIT_CODE_SESSION_EXPIRED = 10;
|
||||
public static final int INTERRUPTED = -1;
|
||||
public static final int INVALID_CONFIGURATION = -2;
|
||||
public static final int CERTIFICATE_EXPIRED = -3;
|
||||
public static final int FAILED_GET_DEVICE = -4;
|
||||
public static final int FAILED_LOAD_CONFIG = -5;
|
||||
}
|
||||
|
||||
public static class CatoIdEvent extends Event {
|
||||
private final String id;
|
||||
public static class HiperIPEvent extends Event {
|
||||
private final String ip;
|
||||
|
||||
public CatoIdEvent(Object source, String id) {
|
||||
public HiperIPEvent(Object source, String ip) {
|
||||
super(source);
|
||||
this.id = id;
|
||||
this.ip = ip;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
public String getIP() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
enum State {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
MASTER,
|
||||
SLAVE
|
||||
}
|
||||
public static class HiperShowValidUntilEvent extends Event {
|
||||
private final Date validAt;
|
||||
|
||||
public static class Invitation {
|
||||
private final String id;
|
||||
@SerializedName("p")
|
||||
private final int channelPort;
|
||||
|
||||
public Invitation(String id, int channelPort) {
|
||||
this.id = id;
|
||||
this.channelPort = channelPort;
|
||||
public HiperShowValidUntilEvent(Object source, Date validAt) {
|
||||
super(source);
|
||||
this.validAt = validAt;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getChannelPort() {
|
||||
return channelPort;
|
||||
public Date getValidUntil() {
|
||||
return validAt;
|
||||
}
|
||||
}
|
||||
|
||||
public interface JoinSessionHandler {
|
||||
void onWaitingForJoinResponse();
|
||||
}
|
||||
|
||||
public static class IncompatibleCatoVersionException extends Exception {
|
||||
private final String expected;
|
||||
private final String actual;
|
||||
|
||||
public IncompatibleCatoVersionException(String expected, String actual) {
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
|
||||
public String getExpected() {
|
||||
return expected;
|
||||
}
|
||||
|
||||
public String getActual() {
|
||||
return actual;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
P2P,
|
||||
BRIDGE;
|
||||
|
||||
String getName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
public static class CatoExitException extends RuntimeException {
|
||||
public static class HiperExitException extends RuntimeException {
|
||||
private final int exitCode;
|
||||
private final boolean ready;
|
||||
|
||||
public CatoExitException(int exitCode, boolean ready) {
|
||||
public HiperExitException(int exitCode, boolean ready) {
|
||||
this.exitCode = exitCode;
|
||||
this.ready = ready;
|
||||
}
|
||||
@@ -586,13 +380,13 @@ public final class MultiplayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CatoExitTimeoutException extends RuntimeException {
|
||||
public static class HiperExitTimeoutException extends RuntimeException {
|
||||
}
|
||||
|
||||
public static class CatoSessionExpiredException extends RuntimeException {
|
||||
public static class HiperSessionExpiredException extends HiperInvalidConfigurationException {
|
||||
}
|
||||
|
||||
public static class CatoAlreadyStartedException extends RuntimeException {
|
||||
public static class HiperInvalidConfigurationException extends RuntimeException {
|
||||
}
|
||||
|
||||
public static class JoinRequestTimeoutException extends RuntimeException {
|
||||
@@ -616,10 +410,10 @@ public final class MultiplayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CatoNotExistsException extends RuntimeException {
|
||||
public static class HiperNotExistsException extends RuntimeException {
|
||||
private final Path file;
|
||||
|
||||
public CatoNotExistsException(Path file) {
|
||||
public HiperNotExistsException(Path file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
@@ -627,4 +421,11 @@ public final class MultiplayerManager {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
public static class HiperInvalidTokenException extends RuntimeException {
|
||||
}
|
||||
|
||||
public static class HiperUnsupportedPlatformException extends RuntimeException {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,40 +19,27 @@ package org.jackhuang.hmcl.ui.multiplayer;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXDialogLayout;
|
||||
import de.javawi.jstun.test.DiscoveryInfo;
|
||||
import de.javawi.jstun.test.DiscoveryTest;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Skin;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.setting.DownloadProviders;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.util.HMCLService;
|
||||
import org.jackhuang.hmcl.util.Result;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
|
||||
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.KickResponse.*;
|
||||
import static org.jackhuang.hmcl.util.Lang.resolveException;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -60,24 +47,24 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("multiplayer")));
|
||||
|
||||
private final ObjectProperty<MultiplayerManager.State> multiplayerState = new SimpleObjectProperty<>(MultiplayerManager.State.DISCONNECTED);
|
||||
private final ReadOnlyStringWrapper token = new ReadOnlyStringWrapper();
|
||||
private final ReadOnlyObjectWrapper<@Nullable Result<DiscoveryInfo>> natState = new ReadOnlyObjectWrapper<>();
|
||||
private final ReadOnlyIntegerWrapper gamePort = new ReadOnlyIntegerWrapper(-1);
|
||||
private final ReadOnlyObjectWrapper<MultiplayerManager.CatoSession> session = new ReadOnlyObjectWrapper<>();
|
||||
private final ObservableList<MultiplayerChannel.CatoClient> clients = FXCollections.observableArrayList();
|
||||
private final ReadOnlyObjectWrapper<MultiplayerManager.HiperSession> session = new ReadOnlyObjectWrapper<>();
|
||||
private final IntegerProperty port = new SimpleIntegerProperty();
|
||||
private final StringProperty address = new SimpleStringProperty();
|
||||
private final ReadOnlyObjectWrapper<Date> expireTime = new ReadOnlyObjectWrapper<>();
|
||||
|
||||
private Consumer<MultiplayerManager.CatoExitEvent> onExit;
|
||||
private Consumer<MultiplayerManager.CatoIdEvent> onIdGenerated;
|
||||
private Consumer<Event> onPeerConnected;
|
||||
private Consumer<MultiplayerManager.HiperExitEvent> onExit;
|
||||
private Consumer<MultiplayerManager.HiperIPEvent> onIPAllocated;
|
||||
private Consumer<MultiplayerManager.HiperShowValidUntilEvent> onValidUntil;
|
||||
|
||||
private final ReadOnlyObjectWrapper<LocalServerBroadcaster> broadcaster = new ReadOnlyObjectWrapper<>();
|
||||
private Consumer<Event> onBroadcasterExit = null;
|
||||
|
||||
public MultiplayerPage() {
|
||||
testNAT();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageShown() {
|
||||
checkAgreement(this::downloadCatoIfNecessary);
|
||||
checkAgreement(this::downloadHiPerIfNecessary);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -85,70 +72,64 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
return new MultiplayerPageSkin(this);
|
||||
}
|
||||
|
||||
public ObservableList<MultiplayerChannel.CatoClient> getClients() {
|
||||
return clients;
|
||||
public int getPort() {
|
||||
return port.get();
|
||||
}
|
||||
|
||||
public MultiplayerManager.State getMultiplayerState() {
|
||||
return multiplayerState.get();
|
||||
public IntegerProperty portProperty() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public ObjectProperty<MultiplayerManager.State> multiplayerStateProperty() {
|
||||
return multiplayerState;
|
||||
public void setPort(int port) {
|
||||
this.port.set(port);
|
||||
}
|
||||
|
||||
public void setMultiplayerState(MultiplayerManager.State multiplayerState) {
|
||||
this.multiplayerState.set(multiplayerState);
|
||||
public String getAddress() {
|
||||
return address.get();
|
||||
}
|
||||
|
||||
public Result<DiscoveryInfo> getNatState() {
|
||||
return natState.get();
|
||||
public StringProperty addressProperty() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<Result<DiscoveryInfo>> natStateProperty() {
|
||||
return natState.getReadOnlyProperty();
|
||||
public void setAddress(String address) {
|
||||
this.address.set(address);
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token.get();
|
||||
public LocalServerBroadcaster getBroadcaster() {
|
||||
return broadcaster.get();
|
||||
}
|
||||
|
||||
public ReadOnlyStringProperty tokenProperty() {
|
||||
return token.getReadOnlyProperty();
|
||||
public ReadOnlyObjectWrapper<LocalServerBroadcaster> broadcasterProperty() {
|
||||
return broadcaster;
|
||||
}
|
||||
|
||||
public int getGamePort() {
|
||||
return gamePort.get();
|
||||
public void setBroadcaster(LocalServerBroadcaster broadcaster) {
|
||||
this.broadcaster.set(broadcaster);
|
||||
}
|
||||
|
||||
public ReadOnlyIntegerProperty gamePortProperty() {
|
||||
return gamePort.getReadOnlyProperty();
|
||||
public Date getExpireTime() {
|
||||
return expireTime.get();
|
||||
}
|
||||
|
||||
public MultiplayerManager.CatoSession getSession() {
|
||||
public ReadOnlyObjectWrapper<Date> expireTimeProperty() {
|
||||
return expireTime;
|
||||
}
|
||||
|
||||
public void setExpireTime(Date expireTime) {
|
||||
this.expireTime.set(expireTime);
|
||||
}
|
||||
|
||||
public MultiplayerManager.HiperSession getSession() {
|
||||
return session.get();
|
||||
}
|
||||
|
||||
public ReadOnlyObjectProperty<MultiplayerManager.CatoSession> sessionProperty() {
|
||||
public ReadOnlyObjectProperty<MultiplayerManager.HiperSession> sessionProperty() {
|
||||
return session.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
private void testNAT() {
|
||||
Task.supplyAsync(() -> {
|
||||
DiscoveryTest tester = new DiscoveryTest(null, 0, "stun.miwifi.com", 3478);
|
||||
return tester.test();
|
||||
}).whenComplete(Schedulers.javafx(), (info, exception) -> {
|
||||
if (exception == null) {
|
||||
natState.set(Result.ok(info));
|
||||
} else {
|
||||
natState.set(Result.error());
|
||||
}
|
||||
LOG.log(Level.INFO, "Nat test result " + MultiplayerPageSkin.getNATType(natState.get()), exception);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void checkAgreement(Runnable runnable) {
|
||||
if (globalConfig().getMultiplayerAgreementVersion() < MultiplayerManager.CATO_AGREEMENT_VERSION) {
|
||||
if (globalConfig().getMultiplayerAgreementVersion() < MultiplayerManager.HIPER_AGREEMENT_VERSION) {
|
||||
JFXDialogLayout agreementPane = new JFXDialogLayout();
|
||||
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
|
||||
agreementPane.setBody(new Label(i18n("multiplayer.agreement.prompt")));
|
||||
@@ -157,7 +138,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept"));
|
||||
yesButton.getStyleClass().add("dialog-accept");
|
||||
yesButton.setOnAction(e -> {
|
||||
globalConfig().setMultiplayerAgreementVersion(MultiplayerManager.CATO_AGREEMENT_VERSION);
|
||||
globalConfig().setMultiplayerAgreementVersion(MultiplayerManager.HIPER_AGREEMENT_VERSION);
|
||||
runnable.run();
|
||||
agreementPane.fireEvent(new DialogCloseEvent());
|
||||
});
|
||||
@@ -174,21 +155,18 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadCatoIfNecessary() {
|
||||
if (StringUtils.isBlank(MultiplayerManager.getCatoPath())) {
|
||||
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
fireEvent(new PageCloseEvent());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MultiplayerManager.getCatoExecutable().toFile().exists()) {
|
||||
private void downloadHiPerIfNecessary() {
|
||||
if (!MultiplayerManager.HIPER_PATH.toFile().exists()) {
|
||||
setDisabled(true);
|
||||
TaskExecutor executor = MultiplayerManager.downloadCato()
|
||||
Controllers.taskDialog(MultiplayerManager.downloadHiper()
|
||||
.whenComplete(Schedulers.javafx(), exception -> {
|
||||
setDisabled(false);
|
||||
if (exception != null) {
|
||||
if (exception instanceof CancellationException) {
|
||||
Controllers.showToast(i18n("message.cancelled"));
|
||||
} else if (exception instanceof MultiplayerManager.HiperUnsupportedPlatformException) {
|
||||
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
fireEvent(new PageCloseEvent());
|
||||
} else {
|
||||
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
|
||||
fireEvent(new PageCloseEvent());
|
||||
@@ -196,316 +174,128 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
|
||||
} else {
|
||||
Controllers.showToast(i18n("multiplayer.download.success"));
|
||||
}
|
||||
}).executor();
|
||||
Controllers.taskDialog(executor, i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
|
||||
executor.start();
|
||||
}), i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
|
||||
} else {
|
||||
setDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void copyInvitationCode() {
|
||||
if (getSession() == null || !getSession().isReady() || gamePort.get() < 0 || getMultiplayerState() != MultiplayerManager.State.MASTER) {
|
||||
throw new IllegalStateException("CatoSession not ready");
|
||||
}
|
||||
|
||||
FXUtils.copyText(getSession().generateInvitationCode(getSession().getServer().getPort()));
|
||||
}
|
||||
|
||||
public void createRoom() {
|
||||
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
|
||||
throw new IllegalStateException("CatoSession already ready");
|
||||
}
|
||||
|
||||
Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> {
|
||||
int gamePort = result.getServer().getAd();
|
||||
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
|
||||
MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getServer().getMotd(), gamePort, result.isAllowAllJoinRequests())
|
||||
.thenAcceptAsync(session -> {
|
||||
session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> {
|
||||
runInFX(() -> {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO)
|
||||
.yesOrNo(resolveClient, () -> rejectClient.accept(MultiplayerChannel.KickResponse.JOIN_ACEEPTANCE_TIMEOUT))
|
||||
.cancelOnTimeout(30 * 1000)
|
||||
.build());
|
||||
});
|
||||
});
|
||||
session.getServer().onClientAdded().register(event -> {
|
||||
runInFX(() -> {
|
||||
clients.add(event);
|
||||
});
|
||||
});
|
||||
session.getServer().onClientDisconnected().register(event -> {
|
||||
runInFX(() -> {
|
||||
clients.remove(event);
|
||||
});
|
||||
});
|
||||
initCatoSession(session);
|
||||
|
||||
this.gamePort.set(gamePort);
|
||||
setMultiplayerState(MultiplayerManager.State.MASTER);
|
||||
resolve.run();
|
||||
}, Platform::runLater)
|
||||
.exceptionally(throwable -> {
|
||||
reject.accept(localizeCreateErrorMessage(throwable, isStaticToken));
|
||||
return null;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public void joinRoom() {
|
||||
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
|
||||
throw new IllegalStateException("CatoSession already ready");
|
||||
}
|
||||
|
||||
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
|
||||
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
|
||||
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
|
||||
|
||||
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
|
||||
MultiplayerManager.Invitation invitation;
|
||||
try {
|
||||
invitation = MultiplayerManager.parseInvitationCode(invitationCode);
|
||||
} catch (Exception e) {
|
||||
LOG.log(Level.WARNING, "Failed to join session", e);
|
||||
reject.accept(i18n("multiplayer.session.join.invitation_code.error"));
|
||||
return;
|
||||
}
|
||||
|
||||
int localPort; // invitation channel
|
||||
try {
|
||||
localPort = MultiplayerManager.findAvailablePort();
|
||||
} catch (Exception e) {
|
||||
reject.accept(i18n("multiplayer.session.join.port.error"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
MultiplayerManager.joinSession(
|
||||
globalConfig().getMultiplayerToken(),
|
||||
invitation.getId(),
|
||||
globalConfig().isMultiplayerRelay() && (StringUtils.isNotBlank(globalConfig().getMultiplayerToken()) || StringUtils.isNotBlank(System.getProperty("hmcl.multiplayer.relay")))
|
||||
? MultiplayerManager.Mode.BRIDGE
|
||||
: MultiplayerManager.Mode.P2P,
|
||||
invitation.getChannelPort(),
|
||||
localPort, new MultiplayerManager.JoinSessionHandler() {
|
||||
@Override
|
||||
public void onWaitingForJoinResponse() {
|
||||
runInFX(() -> {
|
||||
hintQuestion.setQuestion(i18n("multiplayer.session.join.wait"));
|
||||
});
|
||||
}
|
||||
})
|
||||
.thenAcceptAsync(session -> {
|
||||
initCatoSession(session);
|
||||
|
||||
AtomicBoolean kicked = new AtomicBoolean();
|
||||
|
||||
session.getClient().onDisconnected().register(() -> {
|
||||
runInFX(() -> {
|
||||
stopCatoSession();
|
||||
if (!kicked.get()) {
|
||||
Controllers.dialog(i18n("multiplayer.session.join.lost_connection"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
session.getClient().onKicked().register(kickedEvent -> {
|
||||
runInFX(() -> {
|
||||
kicked.set(true);
|
||||
Controllers.dialog(i18n("multiplayer.session.join.kicked", localizeKickMessage(kickedEvent.getReason())));
|
||||
});
|
||||
});
|
||||
|
||||
gamePort.set(session.getClient().getGamePort());
|
||||
setMultiplayerState(MultiplayerManager.State.SLAVE);
|
||||
resolve.run();
|
||||
}, Platform::runLater)
|
||||
.exceptionally(throwable -> {
|
||||
reject.accept(localizeJoinErrorMessage(throwable, isStaticToken));
|
||||
return null;
|
||||
});
|
||||
} catch (MultiplayerManager.IncompatibleCatoVersionException e) {
|
||||
reject.accept(i18n("multiplayer.session.join.invitation_code.version"));
|
||||
}
|
||||
})
|
||||
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.join.hint")))
|
||||
.addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator())));
|
||||
}
|
||||
|
||||
private String localizeKickMessage(String message) {
|
||||
if (VERSION_NOT_MATCHED.equals(message)) {
|
||||
return i18n("multiplayer.session.join.kicked.version_not_matched");
|
||||
} else if (KICKED.equals(message)) {
|
||||
return i18n("multiplayer.session.join.kicked.kicked");
|
||||
} else if (JOIN_ACEEPTANCE_TIMEOUT.equals(message)) {
|
||||
return i18n("multiplayer.session.join.kicked.join_acceptance_timeout");
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private String localizeErrorMessage(Throwable t, boolean isStaticToken, Function<Throwable, String> fallback) {
|
||||
private String localizeErrorMessage(Throwable t) {
|
||||
Throwable e = resolveException(t);
|
||||
if (e instanceof CancellationException) {
|
||||
LOG.info("Connection rejected by the server");
|
||||
return i18n("message.cancelled");
|
||||
} else if (e instanceof MultiplayerManager.KickedException) {
|
||||
LOG.info("Kicked by server");
|
||||
return i18n("multiplayer.session.join.kicked", localizeKickMessage(((MultiplayerManager.KickedException) e).getReason()));
|
||||
} else if (e instanceof MultiplayerManager.CatoAlreadyStartedException) {
|
||||
LOG.info("Cato already started");
|
||||
return i18n("multiplayer.session.error.already_started");
|
||||
} else if (e instanceof MultiplayerManager.CatoNotExistsException) {
|
||||
LOG.log(Level.WARNING, "Cato not found " + ((MultiplayerManager.CatoNotExistsException) e).getFile(), e);
|
||||
return i18n("multiplayer.session.error.file_not_found");
|
||||
} else if (e instanceof MultiplayerManager.JoinRequestTimeoutException) {
|
||||
LOG.info("Cato already started");
|
||||
return i18n("multiplayer.session.join.wait_timeout");
|
||||
} else if (e instanceof MultiplayerManager.ConnectionErrorException) {
|
||||
LOG.info("Failed to establish connection with server");
|
||||
return i18n("multiplayer.session.join.error.connection");
|
||||
} else if (e instanceof MultiplayerManager.CatoExitTimeoutException) {
|
||||
LOG.info("Cato failed to connect to main net");
|
||||
if (isStaticToken) {
|
||||
return i18n("multiplayer.exit.timeout.static_token");
|
||||
} else {
|
||||
return i18n("multiplayer.exit.timeout.dynamic_token");
|
||||
}
|
||||
} else if (e instanceof MultiplayerManager.CatoExitException) {
|
||||
LOG.info("Cato exited accidentally");
|
||||
int exitCode = ((MultiplayerManager.CatoExitException) e).getExitCode();
|
||||
if (!((MultiplayerManager.CatoExitException) e).isReady()) {
|
||||
return i18n("multiplayer.exit.before_ready", exitCode);
|
||||
} else {
|
||||
return i18n("multiplayer.exit.after_ready", exitCode);
|
||||
}
|
||||
} else if (e instanceof MultiplayerManager.HiperInvalidConfigurationException) {
|
||||
LOG.info("Hiper invalid configuration");
|
||||
return i18n("multiplayer.token.malformed");
|
||||
} else if (e instanceof MultiplayerManager.HiperNotExistsException) {
|
||||
LOG.log(Level.WARNING, "Hiper not found " + ((MultiplayerManager.HiperNotExistsException) e).getFile(), e);
|
||||
return i18n("multiplayer.error.file_not_found");
|
||||
} else if (e instanceof MultiplayerManager.HiperExitException) {
|
||||
LOG.info("HiPer exited accidentally");
|
||||
int exitCode = ((MultiplayerManager.HiperExitException) e).getExitCode();
|
||||
return i18n("multiplayer.exit", exitCode);
|
||||
} else if (e instanceof MultiplayerManager.HiperInvalidTokenException) {
|
||||
LOG.info("invalid token");
|
||||
return i18n("multiplayer.token.invalid");
|
||||
} else if (e instanceof ChecksumMismatchException) {
|
||||
return i18n("exception.artifact_malformed");
|
||||
} else {
|
||||
return fallback.apply(e);
|
||||
return e.getLocalizedMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String localizeCreateErrorMessage(Throwable t, boolean isStaticToken) {
|
||||
return localizeErrorMessage(t, isStaticToken, e -> {
|
||||
LOG.log(Level.WARNING, "Failed to create session", e);
|
||||
if (isStaticToken) {
|
||||
return i18n("multiplayer.session.create.error.static_token") + e.getLocalizedMessage();
|
||||
} else {
|
||||
return i18n("multiplayer.session.create.error.dynamic_token") + e.getLocalizedMessage();
|
||||
}
|
||||
});
|
||||
public void start() {
|
||||
MultiplayerManager.startHiper(globalConfig().getMultiplayerToken())
|
||||
.thenAcceptAsync(session -> {
|
||||
this.session.set(session);
|
||||
onExit = session.onExit().registerWeak(this::onExit);
|
||||
onIPAllocated = session.onIPAllocated().registerWeak(this::onIPAllocated);
|
||||
onValidUntil = session.onValidUntil().registerWeak(this::onValidUntil);
|
||||
}, Schedulers.javafx())
|
||||
.exceptionally(throwable -> {
|
||||
runInFX(() -> Controllers.dialog(localizeErrorMessage(throwable), null, MessageDialogPane.MessageType.ERROR));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private String localizeJoinErrorMessage(Throwable t, boolean isStaticToken) {
|
||||
return localizeErrorMessage(t, isStaticToken, e -> {
|
||||
LOG.log(Level.WARNING, "Failed to join session", e);
|
||||
return i18n("multiplayer.session.join.error");
|
||||
});
|
||||
}
|
||||
|
||||
public void kickPlayer(MultiplayerChannel.CatoClient client) {
|
||||
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
|
||||
throw new IllegalStateException("CatoSession not ready");
|
||||
}
|
||||
|
||||
Controllers.confirm(i18n("multiplayer.session.create.members.kick.prompt"), i18n("multiplayer.session.create.members.kick"), MessageDialogPane.MessageType.WARNING,
|
||||
() -> {
|
||||
getSession().getServer().kickPlayer(client);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public void closeRoom() {
|
||||
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
|
||||
throw new IllegalStateException("CatoSession not ready");
|
||||
}
|
||||
|
||||
Controllers.confirm(i18n("multiplayer.session.close.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
|
||||
this::stopCatoSession, null);
|
||||
}
|
||||
|
||||
public void quitRoom() {
|
||||
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.SLAVE) {
|
||||
throw new IllegalStateException("CatoSession not ready");
|
||||
}
|
||||
|
||||
Controllers.confirm(i18n("multiplayer.session.quit.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
|
||||
this::stopCatoSession, null);
|
||||
}
|
||||
|
||||
public void cancelRoom() {
|
||||
if (getSession() == null || getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.CONNECTING) {
|
||||
throw new IllegalStateException("CatoSession not existing or already ready");
|
||||
}
|
||||
|
||||
stopCatoSession();
|
||||
}
|
||||
|
||||
private void initCatoSession(MultiplayerManager.CatoSession session) {
|
||||
runInFX(() -> {
|
||||
onExit = session.onExit().registerWeak(this::onCatoExit);
|
||||
onIdGenerated = session.onIdGenerated().registerWeak(this::onCatoIdGenerated);
|
||||
onPeerConnected = session.onPeerConnected().registerWeak(this::onCatoPeerConnected);
|
||||
|
||||
this.clients.clear();
|
||||
this.session.set(session);
|
||||
});
|
||||
}
|
||||
|
||||
private void stopCatoSession() {
|
||||
public void stop() {
|
||||
if (getSession() != null) {
|
||||
getSession().stop();
|
||||
}
|
||||
clearCatoSession();
|
||||
if (getBroadcaster() != null) {
|
||||
getBroadcaster().close();
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
|
||||
private void clearCatoSession() {
|
||||
this.session.set(null);
|
||||
this.token.set(null);
|
||||
this.gamePort.set(-1);
|
||||
this.multiplayerState.set(MultiplayerManager.State.DISCONNECTED);
|
||||
public void broadcast(String url) {
|
||||
LocalServerBroadcaster broadcaster = new LocalServerBroadcaster(url);
|
||||
this.onBroadcasterExit = broadcaster.onExit().registerWeak(this::onBroadcasterExit);
|
||||
broadcaster.start();
|
||||
this.broadcaster.set(broadcaster);
|
||||
}
|
||||
|
||||
private void onCatoExit(MultiplayerManager.CatoExitEvent event) {
|
||||
public void stopBroadcasting() {
|
||||
if (getBroadcaster() != null) {
|
||||
getBroadcaster().close();
|
||||
setBroadcaster(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void onBroadcasterExit(Event event) {
|
||||
runInFX(() -> {
|
||||
if (this.broadcaster.get() == event.getSource()) {
|
||||
this.broadcaster.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void clearSession() {
|
||||
this.session.set(null);
|
||||
this.onExit = null;
|
||||
this.onIPAllocated = null;
|
||||
this.onValidUntil = null;
|
||||
this.broadcaster.set(null);
|
||||
this.onBroadcasterExit = null;
|
||||
}
|
||||
|
||||
private void onIPAllocated(MultiplayerManager.HiperIPEvent event) {
|
||||
runInFX(() -> this.address.set(event.getIP()));
|
||||
}
|
||||
|
||||
private void onValidUntil(MultiplayerManager.HiperShowValidUntilEvent event) {
|
||||
runInFX(() -> this.expireTime.set(event.getValidUntil()));
|
||||
}
|
||||
|
||||
private void onExit(MultiplayerManager.HiperExitEvent event) {
|
||||
runInFX(() -> {
|
||||
boolean ready = ((MultiplayerManager.CatoSession) event.getSource()).isReady();
|
||||
switch (event.getExitCode()) {
|
||||
case 0:
|
||||
break;
|
||||
case MultiplayerManager.CatoExitEvent.EXIT_CODE_SESSION_EXPIRED:
|
||||
Controllers.dialog(i18n("multiplayer.session.expired"));
|
||||
case MultiplayerManager.HiperExitEvent.CERTIFICATE_EXPIRED:
|
||||
MultiplayerManager.clearConfiguration();
|
||||
Controllers.dialog(i18n("multiplayer.token.expired"));
|
||||
break;
|
||||
case 1:
|
||||
if (!ready) {
|
||||
Controllers.dialog(i18n("multiplayer.exit.timeout"));
|
||||
}
|
||||
case MultiplayerManager.HiperExitEvent.INVALID_CONFIGURATION:
|
||||
MultiplayerManager.clearConfiguration();
|
||||
Controllers.dialog(i18n("multiplayer.token.malformed"));
|
||||
break;
|
||||
case -1:
|
||||
case MultiplayerManager.HiperExitEvent.INTERRUPTED:
|
||||
// do nothing
|
||||
break;
|
||||
case MultiplayerManager.HiperExitEvent.FAILED_GET_DEVICE:
|
||||
Controllers.dialog(i18n("multiplayer.error.failed_get_device"));
|
||||
break;
|
||||
case MultiplayerManager.HiperExitEvent.FAILED_LOAD_CONFIG:
|
||||
Controllers.dialog(i18n("multiplayer.error.failed_load_config"));
|
||||
break;
|
||||
default:
|
||||
if (!((MultiplayerManager.CatoSession) event.getSource()).isReady()) {
|
||||
Controllers.dialog(i18n("multiplayer.exit.before_ready", event.getExitCode()));
|
||||
} else {
|
||||
Controllers.dialog(i18n("multiplayer.exit.after_ready", event.getExitCode()));
|
||||
}
|
||||
Controllers.dialog(i18n("multiplayer.exit", event.getExitCode()));
|
||||
break;
|
||||
}
|
||||
|
||||
clearCatoSession();
|
||||
});
|
||||
}
|
||||
|
||||
private void onCatoPeerConnected(Event event) {
|
||||
runInFX(() -> {
|
||||
});
|
||||
}
|
||||
|
||||
private void onCatoIdGenerated(MultiplayerManager.CatoIdEvent event) {
|
||||
runInFX(() -> {
|
||||
token.set(event.getId());
|
||||
setMultiplayerState(((MultiplayerManager.CatoSession) event.getSource()).getType());
|
||||
// clearSession();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,32 +19,29 @@ package org.jackhuang.hmcl.ui.multiplayer;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import com.jfoenix.controls.JFXPasswordField;
|
||||
import de.javawi.jstun.test.DiscoveryInfo;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.StringConverter;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.*;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.HMCLService;
|
||||
import org.jackhuang.hmcl.util.Result;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
@@ -63,57 +60,6 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
|
||||
super(control);
|
||||
|
||||
{
|
||||
VBox roomPane = new VBox();
|
||||
{
|
||||
AdvancedListItem createRoomItem = new AdvancedListItem();
|
||||
createRoomItem.setTitle(i18n("multiplayer.session.create"));
|
||||
createRoomItem.setLeftGraphic(wrap(SVG::plusCircleOutline));
|
||||
createRoomItem.setActionButtonVisible(false);
|
||||
createRoomItem.setOnAction(e -> control.createRoom());
|
||||
|
||||
AdvancedListItem joinRoomItem = new AdvancedListItem();
|
||||
joinRoomItem.setTitle(i18n("multiplayer.session.join"));
|
||||
joinRoomItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
|
||||
joinRoomItem.setActionButtonVisible(false);
|
||||
joinRoomItem.setOnAction(e -> control.joinRoom());
|
||||
|
||||
AdvancedListItem copyLinkItem = new AdvancedListItem();
|
||||
copyLinkItem.setTitle(i18n("multiplayer.session.copy_room_code"));
|
||||
copyLinkItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
|
||||
copyLinkItem.setActionButtonVisible(false);
|
||||
copyLinkItem.setOnAction(e -> control.copyInvitationCode());
|
||||
|
||||
AdvancedListItem cancelItem = new AdvancedListItem();
|
||||
cancelItem.setTitle(i18n("button.cancel"));
|
||||
cancelItem.setLeftGraphic(wrap(SVG::closeCircle));
|
||||
cancelItem.setActionButtonVisible(false);
|
||||
cancelItem.setOnAction(e -> control.cancelRoom());
|
||||
|
||||
AdvancedListItem quitItem = new AdvancedListItem();
|
||||
quitItem.setTitle(i18n("multiplayer.session.quit"));
|
||||
quitItem.setLeftGraphic(wrap(SVG::closeCircle));
|
||||
quitItem.setActionButtonVisible(false);
|
||||
quitItem.setOnAction(e -> control.quitRoom());
|
||||
|
||||
AdvancedListItem closeRoomItem = new AdvancedListItem();
|
||||
closeRoomItem.setTitle(i18n("multiplayer.session.close"));
|
||||
closeRoomItem.setLeftGraphic(wrap(SVG::closeCircle));
|
||||
closeRoomItem.setActionButtonVisible(false);
|
||||
closeRoomItem.setOnAction(e -> control.closeRoom());
|
||||
|
||||
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
|
||||
if (state == MultiplayerManager.State.DISCONNECTED) {
|
||||
roomPane.getChildren().setAll(createRoomItem, joinRoomItem);
|
||||
} else if (state == MultiplayerManager.State.CONNECTING) {
|
||||
roomPane.getChildren().setAll(cancelItem);
|
||||
} else if (state == MultiplayerManager.State.MASTER) {
|
||||
roomPane.getChildren().setAll(copyLinkItem, closeRoomItem);
|
||||
} else if (state == MultiplayerManager.State.SLAVE) {
|
||||
roomPane.getChildren().setAll(quitItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
.addNavigationDrawerItem(item -> {
|
||||
item.setTitle(i18n("version.launch"));
|
||||
@@ -123,8 +69,6 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
});
|
||||
})
|
||||
.startCategory(i18n("multiplayer.session"))
|
||||
.add(roomPane)
|
||||
.startCategory(i18n("help"))
|
||||
.addNavigationDrawerItem(item -> {
|
||||
item.setTitle(i18n("help"));
|
||||
@@ -146,128 +90,198 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
|
||||
content.setFillWidth(true);
|
||||
ScrollPane scrollPane = new ScrollPane(content);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setFitToHeight(true);
|
||||
setCenter(scrollPane);
|
||||
|
||||
ComponentList roomPane = new ComponentList();
|
||||
VBox mainPane = new VBox(16);
|
||||
{
|
||||
TransitionPane transitionPane = new TransitionPane();
|
||||
roomPane.getContent().setAll(transitionPane);
|
||||
|
||||
VBox disconnectedPane = new VBox(8);
|
||||
ComponentList offPane = new ComponentList();
|
||||
{
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
hintPane.setText(i18n("multiplayer.state.disconnected.hint"));
|
||||
hintPane.setText(i18n("multiplayer.off.hint"));
|
||||
|
||||
Label label = new Label(i18n("multiplayer.state.disconnected"));
|
||||
BorderPane tokenPane = new BorderPane();
|
||||
{
|
||||
Label tokenTitle = new Label(i18n("multiplayer.token"));
|
||||
BorderPane.setAlignment(tokenTitle, Pos.CENTER_LEFT);
|
||||
tokenPane.setLeft(tokenTitle);
|
||||
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
|
||||
JFXPasswordField tokenField = new JFXPasswordField();
|
||||
BorderPane.setAlignment(tokenField, Pos.CENTER_LEFT);
|
||||
BorderPane.setMargin(tokenField, new Insets(0, 8, 0, 8));
|
||||
tokenPane.setCenter(tokenField);
|
||||
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
|
||||
tokenField.setPromptText(i18n("multiplayer.token.prompt"));
|
||||
|
||||
disconnectedPane.getChildren().setAll(hintPane, label);
|
||||
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.token.apply"));
|
||||
BorderPane.setAlignment(applyLink, Pos.CENTER_RIGHT);
|
||||
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
|
||||
tokenPane.setRight(applyLink);
|
||||
}
|
||||
|
||||
HBox startPane = new HBox();
|
||||
{
|
||||
JFXButton startButton = new JFXButton(i18n("multiplayer.off.start"));
|
||||
startButton.getStyleClass().add("jfx-button-raised");
|
||||
startButton.setButtonType(JFXButton.ButtonType.RAISED);
|
||||
startButton.setOnMouseClicked(e -> control.start());
|
||||
|
||||
startPane.getChildren().setAll(startButton);
|
||||
startPane.setAlignment(Pos.CENTER_RIGHT);
|
||||
}
|
||||
|
||||
offPane.getContent().setAll(hintPane, tokenPane, startPane);
|
||||
}
|
||||
|
||||
VBox connectingPane = new VBox(8);
|
||||
ComponentList onPane = new ComponentList();
|
||||
{
|
||||
Label label = new Label(i18n("multiplayer.state.connecting"));
|
||||
BorderPane expirationPane = new BorderPane();
|
||||
expirationPane.setLeft(new Label(i18n("multiplayer.session.expiration")));
|
||||
Label expirationLabel = new Label();
|
||||
expirationLabel.textProperty().bind(Bindings.createStringBinding(() ->
|
||||
control.getExpireTime() == null ? "" : Locales.SIMPLE_DATE_FORMAT.get().format(control.getExpireTime()),
|
||||
control.expireTimeProperty()));
|
||||
expirationPane.setCenter(expirationLabel);
|
||||
|
||||
connectingPane.getChildren().setAll(label);
|
||||
GridPane masterPane = new GridPane();
|
||||
masterPane.setVgap(8);
|
||||
masterPane.setHgap(16);
|
||||
ColumnConstraints titleColumn = new ColumnConstraints();
|
||||
ColumnConstraints valueColumn = new ColumnConstraints();
|
||||
ColumnConstraints rightColumn = new ColumnConstraints();
|
||||
masterPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
|
||||
valueColumn.setFillWidth(true);
|
||||
valueColumn.setHgrow(Priority.ALWAYS);
|
||||
{
|
||||
BorderPane titlePane = new BorderPane();
|
||||
GridPane.setColumnSpan(titlePane, 3);
|
||||
Label title = new Label(i18n("multiplayer.master"));
|
||||
titlePane.setLeft(title);
|
||||
|
||||
JFXHyperlink tutorial = new JFXHyperlink(i18n("multiplayer.master.video_tutorial"));
|
||||
titlePane.setRight(tutorial);
|
||||
tutorial.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-tutorial-master"));
|
||||
masterPane.addRow(0, titlePane);
|
||||
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
GridPane.setColumnSpan(hintPane, 3);
|
||||
hintPane.setText(i18n("multiplayer.master.hint"));
|
||||
masterPane.addRow(1, hintPane);
|
||||
|
||||
Label portTitle = new Label(i18n("multiplayer.master.port"));
|
||||
BorderPane.setAlignment(portTitle, Pos.CENTER_LEFT);
|
||||
|
||||
JFXTextField portTextField = new JFXTextField();
|
||||
GridPane.setColumnSpan(portTextField, 2);
|
||||
FXUtils.setValidateWhileTextChanged(portTextField, true);
|
||||
portTextField.getValidators().add(new Validator(i18n("multiplayer.master.port.validate"), (text) -> {
|
||||
Integer value = Lang.toIntOrNull(text);
|
||||
return value != null && 0 <= value && value <= 65535;
|
||||
}));
|
||||
portTextField.textProperty().bindBidirectional(control.portProperty(), new StringConverter<Number>() {
|
||||
@Override
|
||||
public String toString(Number object) {
|
||||
return Integer.toString(object.intValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Number fromString(String string) {
|
||||
return Lang.parseInt(string, 0);
|
||||
}
|
||||
});
|
||||
masterPane.addRow(2, portTitle, portTextField);
|
||||
|
||||
Label serverAddressTitle = new Label(i18n("multiplayer.master.server_address"));
|
||||
BorderPane.setAlignment(serverAddressTitle, Pos.CENTER_LEFT);
|
||||
Label serverAddressLabel = new Label();
|
||||
BorderPane.setAlignment(serverAddressLabel, Pos.CENTER_LEFT);
|
||||
serverAddressLabel.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
return (control.getAddress() == null ? "" : control.getAddress()) + ":" + control.getPort();
|
||||
}, control.addressProperty(), control.portProperty()));
|
||||
JFXButton copyButton = new JFXButton(i18n("multiplayer.master.server_address.copy"));
|
||||
copyButton.setOnAction(e -> FXUtils.copyText(serverAddressLabel.getText()));
|
||||
masterPane.addRow(3, serverAddressTitle, serverAddressLabel, copyButton);
|
||||
}
|
||||
|
||||
VBox slavePane = new VBox(8);
|
||||
{
|
||||
BorderPane titlePane = new BorderPane();
|
||||
Label title = new Label(i18n("multiplayer.slave"));
|
||||
titlePane.setLeft(title);
|
||||
|
||||
JFXHyperlink tutorial = new JFXHyperlink(i18n("multiplayer.slave.video_tutorial"));
|
||||
tutorial.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-tutorial-slave"));
|
||||
titlePane.setRight(tutorial);
|
||||
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
GridPane.setColumnSpan(hintPane, 3);
|
||||
hintPane.setText(i18n("multiplayer.slave.hint"));
|
||||
slavePane.getChildren().add(hintPane);
|
||||
|
||||
GridPane notBroadcastingPane = new GridPane();
|
||||
{
|
||||
notBroadcastingPane.setVgap(8);
|
||||
notBroadcastingPane.setHgap(16);
|
||||
notBroadcastingPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
|
||||
|
||||
Label addressTitle = new Label(i18n("multiplayer.slave.server_address"));
|
||||
|
||||
JFXTextField addressField = new JFXTextField();
|
||||
FXUtils.setValidateWhileTextChanged(addressField, true);
|
||||
addressField.getValidators().add(new ServerAddressValidator());
|
||||
|
||||
JFXButton startButton = new JFXButton(i18n("multiplayer.slave.server_address.start"));
|
||||
startButton.setOnAction(e -> control.broadcast(addressField.getText()));
|
||||
notBroadcastingPane.addRow(0, addressTitle, addressField, startButton);
|
||||
}
|
||||
|
||||
GridPane broadcastingPane = new GridPane();
|
||||
{
|
||||
broadcastingPane.setVgap(8);
|
||||
broadcastingPane.setHgap(16);
|
||||
broadcastingPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
|
||||
|
||||
Label addressTitle = new Label(i18n("multiplayer.slave.server_address"));
|
||||
Label addressLabel = new Label();
|
||||
addressLabel.textProperty().bind(Bindings.createStringBinding(() ->
|
||||
control.getBroadcaster() != null ? control.getBroadcaster().getAddress() : "",
|
||||
control.broadcasterProperty()));
|
||||
|
||||
JFXButton stopButton = new JFXButton(i18n("multiplayer.slave.server_address.stop"));
|
||||
stopButton.setOnAction(e -> control.stopBroadcasting());
|
||||
broadcastingPane.addRow(0, addressTitle, addressLabel, stopButton);
|
||||
}
|
||||
|
||||
FXUtils.onChangeAndOperate(control.broadcasterProperty(), broadcaster -> {
|
||||
if (broadcaster == null) {
|
||||
slavePane.getChildren().setAll(titlePane, hintPane, notBroadcastingPane);
|
||||
} else {
|
||||
slavePane.getChildren().setAll(titlePane, hintPane, broadcastingPane);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
FXUtils.onChangeAndOperate(control.expireTimeProperty(), t -> {
|
||||
if (t == null) {
|
||||
onPane.getContent().setAll(masterPane, slavePane);
|
||||
} else {
|
||||
onPane.getContent().setAll(expirationPane, masterPane, slavePane);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
VBox masterPane = new VBox(8);
|
||||
{
|
||||
HintPane masterHintPane = new HintPane();
|
||||
masterHintPane.setText(i18n("multiplayer.state.master.hint"));
|
||||
|
||||
Label label = new Label(i18n("multiplayer.state.master"));
|
||||
label.textProperty().bind(Bindings.createStringBinding(() ->
|
||||
i18n("multiplayer.state.master", control.getSession() == null ? "" : control.getSession().getName(), control.getGamePort()),
|
||||
control.gamePortProperty(), control.sessionProperty()));
|
||||
|
||||
Label membersLabel = new Label(i18n("multiplayer.session.create.members"));
|
||||
|
||||
VBox clientsPane = new VBox(8);
|
||||
clients = MappedObservableList.create(control.getClients(), ClientItem::new);
|
||||
Bindings.bindContent(clientsPane.getChildren(), clients);
|
||||
|
||||
masterPane.getChildren().setAll(masterHintPane, label, membersLabel, clientsPane);
|
||||
}
|
||||
|
||||
BorderPane slavePane = new BorderPane();
|
||||
{
|
||||
HintPane slaveHintPane = new HintPane();
|
||||
slaveHintPane.setText(i18n("multiplayer.state.slave.hint"));
|
||||
slavePane.setTop(slaveHintPane);
|
||||
|
||||
Label label = new Label();
|
||||
label.textProperty().bind(Bindings.createStringBinding(() ->
|
||||
i18n("multiplayer.state.slave", control.getSession() == null ? "" : control.getSession().getName(), "0.0.0.0:" + control.getGamePort()),
|
||||
control.sessionProperty(), control.gamePortProperty()));
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
slavePane.setCenter(label);
|
||||
|
||||
JFXButton copyButton = new JFXButton(i18n("multiplayer.state.slave.copy"));
|
||||
copyButton.setOnAction(e -> FXUtils.copyText("0.0.0.0:" + control.getGamePort()));
|
||||
slavePane.setRight(copyButton);
|
||||
}
|
||||
|
||||
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
|
||||
if (state == MultiplayerManager.State.DISCONNECTED) {
|
||||
transitionPane.setContent(disconnectedPane, ContainerAnimations.NONE.getAnimationProducer());
|
||||
} else if (state == MultiplayerManager.State.CONNECTING) {
|
||||
transitionPane.setContent(connectingPane, ContainerAnimations.NONE.getAnimationProducer());
|
||||
} else if (state == MultiplayerManager.State.MASTER) {
|
||||
transitionPane.setContent(masterPane, ContainerAnimations.NONE.getAnimationProducer());
|
||||
} else if (state == MultiplayerManager.State.SLAVE) {
|
||||
transitionPane.setContent(slavePane, ContainerAnimations.NONE.getAnimationProducer());
|
||||
FXUtils.onChangeAndOperate(getSkinnable().sessionProperty(), session -> {
|
||||
if (session == null) {
|
||||
mainPane.getChildren().setAll(ComponentList.createComponentListTitle(i18n("multiplayer.off")),
|
||||
offPane);
|
||||
} else {
|
||||
mainPane.getChildren().setAll(ComponentList.createComponentListTitle(i18n("multiplayer.on")),
|
||||
onPane);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ComponentList natDetectionPane = new ComponentList();
|
||||
{
|
||||
GridPane pane = new GridPane();
|
||||
ColumnConstraints title = new ColumnConstraints();
|
||||
ColumnConstraints value = new ColumnConstraints();
|
||||
pane.getColumnConstraints().setAll(title, value);
|
||||
value.setFillWidth(true);
|
||||
value.setHgrow(Priority.ALWAYS);
|
||||
pane.setHgap(16);
|
||||
pane.setVgap(8);
|
||||
|
||||
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
|
||||
hintPane.setText(i18n("multiplayer.nat.hint"));
|
||||
GridPane.setColumnSpan(hintPane, 2);
|
||||
pane.addRow(0, hintPane);
|
||||
|
||||
Label natResult = new Label();
|
||||
natResult.textProperty().bind(BindingMapping.of(getSkinnable().natStateProperty())
|
||||
.map(MultiplayerPageSkin::getNATType));
|
||||
pane.addRow(1, new Label(i18n("multiplayer.nat.type")), natResult);
|
||||
|
||||
natDetectionPane.getContent().add(pane);
|
||||
}
|
||||
|
||||
ComponentList thanksPane = new ComponentList();
|
||||
{
|
||||
GridPane gridPane = new GridPane();
|
||||
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing(), new ColumnConstraints());
|
||||
gridPane.setVgap(8);
|
||||
gridPane.setHgap(16);
|
||||
|
||||
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
|
||||
JFXPasswordField tokenField = new JFXPasswordField();
|
||||
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
|
||||
tokenField.setPromptText(i18n("multiplayer.session.create.token.prompt"));
|
||||
|
||||
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.session.create.token.apply"));
|
||||
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
|
||||
|
||||
gridPane.addRow(0, new Label(i18n("multiplayer.session.create.token")), tokenField, applyLink);
|
||||
|
||||
OptionToggleButton relay = new OptionToggleButton();
|
||||
relay.disableProperty().bind(tokenField.textProperty().isEmpty());
|
||||
relay.selectedProperty().bindBidirectional(globalConfig().multiplayerRelayProperty());
|
||||
relay.setTitle(i18n("multiplayer.relay"));
|
||||
relay.setSubtitle(i18n("multiplayer.relay.hint"));
|
||||
|
||||
HBox pane = new HBox();
|
||||
pane.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
@@ -278,64 +292,20 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
|
||||
HBox.setHgrow(placeholder, Priority.ALWAYS);
|
||||
|
||||
pane.getChildren().setAll(
|
||||
new Label("Based on Cato"),
|
||||
new Label("Based on HiPer"),
|
||||
aboutLink,
|
||||
placeholder,
|
||||
FXUtils.segmentToTextFlow(i18n("multiplayer.powered_by"), Controllers::onHyperlinkAction));
|
||||
|
||||
thanksPane.getContent().addAll(gridPane, relay, pane);
|
||||
thanksPane.getContent().addAll(pane);
|
||||
}
|
||||
|
||||
content.getChildren().setAll(
|
||||
ComponentList.createComponentListTitle(i18n("multiplayer.session")),
|
||||
roomPane,
|
||||
ComponentList.createComponentListTitle(i18n("multiplayer.nat")),
|
||||
natDetectionPane,
|
||||
ComponentList.createComponentListTitle(i18n("settings")),
|
||||
mainPane,
|
||||
ComponentList.createComponentListTitle(i18n("about")),
|
||||
thanksPane
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getNATType(@Nullable Result<DiscoveryInfo> info) {
|
||||
if (info == null) {
|
||||
return i18n("multiplayer.nat.testing");
|
||||
} else if (info.isError()) {
|
||||
return i18n("multiplayer.nat.failed");
|
||||
} else if (info.get().isBlockedUDP()) {
|
||||
return i18n("multiplayer.nat.type.blocked_udp");
|
||||
} else if (info.get().isFullCone()) {
|
||||
return i18n("multiplayer.nat.type.full_cone");
|
||||
} else if (info.get().isOpenAccess()) {
|
||||
return i18n("multiplayer.nat.type.open_access");
|
||||
} else if (info.get().isPortRestrictedCone()) {
|
||||
return i18n("multiplayer.nat.type.port_restricted_cone");
|
||||
} else if (info.get().isRestrictedCone()) {
|
||||
return i18n("multiplayer.nat.type.restricted_cone");
|
||||
} else if (info.get().isSymmetric()) {
|
||||
return i18n("multiplayer.nat.type.symmetric");
|
||||
} else if (info.get().isSymmetricUDPFirewall()) {
|
||||
return i18n("multiplayer.nat.type.symmetric_udp_firewall");
|
||||
} else {
|
||||
return i18n("multiplayer.nat.type.unknown");
|
||||
}
|
||||
}
|
||||
|
||||
private class ClientItem extends StackPane {
|
||||
ClientItem(MultiplayerChannel.CatoClient client) {
|
||||
BorderPane pane = new BorderPane();
|
||||
pane.setPadding(new Insets(8));
|
||||
pane.setLeft(new Label(client.getUsername()));
|
||||
|
||||
JFXButton kickButton = new JFXButton();
|
||||
kickButton.setGraphic(SVG.close(Theme.blackFillBinding(), 16, 16));
|
||||
kickButton.getStyleClass().add("toggle-icon-tiny");
|
||||
kickButton.setOnAction(e -> getSkinnable().kickPlayer(client));
|
||||
pane.setRight(kickButton);
|
||||
|
||||
RipplerContainer container = new RipplerContainer(pane);
|
||||
getChildren().setAll(container);
|
||||
getStyleClass().add("md-list-cell");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.FutureCallback;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
|
||||
public class MultiplayerServer extends Thread {
|
||||
private ServerSocket socket;
|
||||
private final String sessionName;
|
||||
private final int gamePort;
|
||||
private final boolean allowAllJoinRequests;
|
||||
|
||||
private FutureCallback<CatoClient> onClientAdding;
|
||||
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
|
||||
private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>();
|
||||
private final EventManager<Event> onKeepAlive = new EventManager<>();
|
||||
private final EventManager<Event> onHandshake = new EventManager<>();
|
||||
|
||||
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
|
||||
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
|
||||
|
||||
public MultiplayerServer(String sessionName, int gamePort, boolean allowAllJoinRequests) {
|
||||
this.sessionName = sessionName;
|
||||
this.gamePort = gamePort;
|
||||
this.allowAllJoinRequests = allowAllJoinRequests;
|
||||
|
||||
setName("MultiplayerServer");
|
||||
setDaemon(true);
|
||||
}
|
||||
|
||||
public void setOnClientAdding(FutureCallback<CatoClient> callback) {
|
||||
onClientAdding = callback;
|
||||
}
|
||||
|
||||
public EventManager<MultiplayerChannel.CatoClient> onClientAdded() {
|
||||
return onClientAdded;
|
||||
}
|
||||
|
||||
public EventManager<MultiplayerChannel.CatoClient> onClientDisconnected() {
|
||||
return onClientDisconnected;
|
||||
}
|
||||
|
||||
public EventManager<Event> onKeepAlive() {
|
||||
return onKeepAlive;
|
||||
}
|
||||
|
||||
public EventManager<Event> onHandshake() {
|
||||
return onHandshake;
|
||||
}
|
||||
|
||||
public void startServer() throws IOException {
|
||||
startServer(0);
|
||||
}
|
||||
|
||||
public void startServer(int port) throws IOException {
|
||||
if (socket != null) {
|
||||
throw new IllegalStateException("MultiplayerServer already started");
|
||||
}
|
||||
socket = new ServerSocket(port);
|
||||
|
||||
start();
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
if (socket == null) {
|
||||
throw new IllegalStateException("MultiplayerServer not started");
|
||||
}
|
||||
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
LOG.info("Multiplayer Server listening 127.0.0.1:" + socket.getLocalPort());
|
||||
try {
|
||||
while (!isInterrupted()) {
|
||||
Socket clientSocket = socket.accept();
|
||||
clientSocket.setSoTimeout(10000);
|
||||
Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void kickPlayer(CatoClient player) {
|
||||
Endpoint client = nameClientMap.get(player.getUsername());
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
if (client.socket.isConnected()) {
|
||||
client.write(new KickResponse(KickResponse.KICKED));
|
||||
client.socket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to kick player " + player.getUsername() + ". Maybe already disconnected?", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClient(Socket targetSocket) {
|
||||
String address = targetSocket.getRemoteSocketAddress().toString();
|
||||
String clientName = null;
|
||||
LOG.info("Accepted client " + address);
|
||||
try (Socket clientSocket = targetSocket;
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8));
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8))) {
|
||||
clientSocket.setKeepAlive(true);
|
||||
Endpoint endpoint = new Endpoint(clientSocket, writer);
|
||||
clients.put(address, endpoint);
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.fine("Message from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
|
||||
MultiplayerChannel.Request request = JsonUtils.fromNonNullJson(line, MultiplayerChannel.Request.class);
|
||||
|
||||
if (request instanceof JoinRequest) {
|
||||
JoinRequest joinRequest = (JoinRequest) request;
|
||||
LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername());
|
||||
clientName = joinRequest.getUsername();
|
||||
|
||||
if (!Objects.equals(MultiplayerManager.CATO_VERSION, joinRequest.getClientVersion())) {
|
||||
try {
|
||||
endpoint.write(new KickResponse(KickResponse.VERSION_NOT_MATCHED));
|
||||
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to send kick response.", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
CatoClient catoClient = new CatoClient(this, clientName);
|
||||
nameClientMap.put(clientName, endpoint);
|
||||
onClientAdded.fireEvent(catoClient);
|
||||
|
||||
if (onClientAdding != null && !allowAllJoinRequests) {
|
||||
onClientAdding.call(catoClient, () -> {
|
||||
try {
|
||||
endpoint.write(new JoinResponse(sessionName, gamePort));
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to send join response.", e);
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ioException) {
|
||||
LOG.log(Level.WARNING, "Failed to close socket caused by join response sending failure.", e);
|
||||
this.interrupt();
|
||||
}
|
||||
}
|
||||
}, msg -> {
|
||||
try {
|
||||
endpoint.write(new KickResponse(msg));
|
||||
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to send kick response.", e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Allow all join requests.
|
||||
endpoint.write(new JoinResponse(sessionName, gamePort));
|
||||
}
|
||||
} else if (request instanceof KeepAliveRequest) {
|
||||
endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));
|
||||
|
||||
onKeepAlive.fireEvent(new Event(this));
|
||||
} else if (request instanceof HandshakeRequest) {
|
||||
endpoint.write(new HandshakeResponse());
|
||||
|
||||
onHandshake.fireEvent(new Event(this));
|
||||
} else {
|
||||
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Failed to handle client socket.", e);
|
||||
} catch (JsonParseException e) {
|
||||
LOG.log(Level.SEVERE, "Failed to parse client request. This should not happen.", e);
|
||||
} finally {
|
||||
if (clientName != null) {
|
||||
onClientDisconnected.fireEvent(new CatoClient(this, clientName));
|
||||
}
|
||||
|
||||
clients.remove(address);
|
||||
if (clientName != null) nameClientMap.remove(clientName);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Endpoint {
|
||||
public final Socket socket;
|
||||
public final BufferedWriter writer;
|
||||
|
||||
public Endpoint(Socket socket, BufferedWriter writer) {
|
||||
this.socket = socket;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
public synchronized void write(Object object) throws IOException {
|
||||
writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(object)));
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.util;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||
|
||||
/**
|
||||
* Utility for Adding JavaFX to module path.
|
||||
*
|
||||
@@ -30,6 +32,6 @@ public final class JavaFXPatcher {
|
||||
}
|
||||
|
||||
public static void patch(Set<String> modules, Path... jarPaths) {
|
||||
// Nothing to do with Java 8
|
||||
LOG.info("No need to patch JavaFX with Java 8");
|
||||
}
|
||||
}
|
||||
@@ -885,7 +885,7 @@
|
||||
-fx-background-radius: 4;
|
||||
-fx-padding: 8px;
|
||||
|
||||
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
|
||||
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 5, 0.06, -0.5, 1);
|
||||
}
|
||||
|
||||
.card:selected {
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
<SpinnerPane fx:id="spinnerPane" styleClass="large-spinner-pane">
|
||||
<VBox fx:id="borderPane" alignment="CENTER" FXUtils.limitWidth="500">
|
||||
<ComponentList>
|
||||
<BorderPane><left><Label text="%modpack.task.install.will" /></left><right><Label fx:id="lblModpackLocation" /></right></BorderPane>
|
||||
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><right><JFXTextField fx:id="txtModpackName" StackPane.margin="$insets" /></right></BorderPane>
|
||||
<BorderPane><left><Label text="%modpack.task.install.will" /></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblModpackLocation" /></center></BorderPane>
|
||||
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><center><JFXTextField BorderPane.alignment="CENTER_RIGHT" fx:id="txtModpackName" StackPane.margin="$insets" /></center></BorderPane>
|
||||
|
||||
<BorderPane><left><Label text="%modpack.name"/></left><right><Label fx:id="lblName" /></right></BorderPane>
|
||||
<BorderPane><left><Label text="%archive.version"/></left><right><Label fx:id="lblVersion" /></right></BorderPane>
|
||||
<BorderPane><left><Label text="%archive.author"/></left><right><Label fx:id="lblAuthor" /></right></BorderPane>
|
||||
<BorderPane><left><Label text="%modpack.name"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblName" /></center></BorderPane>
|
||||
<BorderPane><left><Label text="%archive.version"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblVersion" /></center></BorderPane>
|
||||
<BorderPane><left><Label text="%archive.author"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblAuthor" /></center></BorderPane>
|
||||
<BorderPane>
|
||||
<left><JFXButton fx:id="btnDescription" onMouseClicked="#onDescribe" text="%modpack.description" styleClass="jfx-button-border" /></left>
|
||||
<right><JFXButton buttonType="RAISED" fx:id="btnInstall" onMouseClicked="#onInstall" text="%button.install" styleClass="jfx-button-raised" /></right>
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.jfoenix.controls.JFXButton?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import java.lang.String?>
|
||||
<?import javafx.geometry.Insets?>
|
||||
<fx:root xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
type="StackPane">
|
||||
<HBox spacing="8" styleClass="jfx-dialog-layout">
|
||||
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40" maxHeight="40" />
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<StackPane>
|
||||
<styleClass>
|
||||
<String fx:value="jfx-layout-heading" />
|
||||
<String fx:value="title" />
|
||||
</styleClass>
|
||||
<Label fx:id="title" text="%message.info" />
|
||||
</StackPane>
|
||||
<StackPane fx:id="content" styleClass="jfx-layout-body">
|
||||
</StackPane>
|
||||
<BorderPane styleClass="jfx-dialog-layout">
|
||||
<left>
|
||||
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40" maxHeight="40" />
|
||||
</left>
|
||||
<center>
|
||||
<VBox>
|
||||
<BorderPane.margin>
|
||||
<Insets left="8" />
|
||||
</BorderPane.margin>
|
||||
<HBox spacing="8">
|
||||
<StackPane>
|
||||
<styleClass>
|
||||
<String fx:value="jfx-layout-heading" />
|
||||
<String fx:value="title" />
|
||||
</styleClass>
|
||||
<Label fx:id="title" text="%message.info" />
|
||||
</StackPane>
|
||||
</HBox>
|
||||
<ScrollPane VBox.vgrow="ALWAYS" fitToWidth="true" fitToHeight="true">
|
||||
<StackPane fx:id="content" styleClass="jfx-layout-body" />
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</center>
|
||||
<bottom>
|
||||
<HBox fx:id="actions" styleClass="jfx-layout-actions" />
|
||||
</VBox>
|
||||
</HBox>
|
||||
</bottom>
|
||||
</BorderPane>
|
||||
</fx:root>
|
||||
@@ -62,7 +62,7 @@ account.create.yggdrasil=Add a Mojang account
|
||||
account.create.offline=Add an offline account
|
||||
account.create.authlibInjector=Create an authlib-injector account
|
||||
account.email=Email
|
||||
account.failed=Error code\: %d
|
||||
account.failed=Failed to login
|
||||
account.failed.character_deleted=The character has already been deleted.
|
||||
account.failed.connect_authentication_server=Unable to contact authentication servers, your Internet connection may be down.
|
||||
account.failed.connect_injector_server=Unable to connect to the authentication server. Please check your network and make sure you entered the correct URL.
|
||||
@@ -72,10 +72,12 @@ account.failed.invalid_password=Invalid password
|
||||
account.failed.invalid_token=Please try to re-login again.
|
||||
account.failed.migration=Your account needs to be migrated to a Microsoft account. If you already did, you should re-login to your migrated Microsoft account instead.
|
||||
account.failed.no_character=There are no characters linked to this account.
|
||||
account.failed.server_disconnected=Cannot access authentication server. You can log in offline or try to re-login.
|
||||
account.failed.server_response_malformed=Invalid server response, the authentication server may not be working.
|
||||
account.failed.wrong_account=You have logged in to the wrong account.
|
||||
account.hmcl.hint=You need to click on "Login" and complete the process in the opened tab in your browser.
|
||||
account.injector.add=Add an Authentication Server
|
||||
# avoid too long sequence.
|
||||
account.injector.add=New Auth Server
|
||||
account.injector.empty=None (You can click on the plus button on the right to add one)
|
||||
account.injector.http=Warning\: This server uses the unsafe HTTP protocol, anyone between your connection will be able to see your credentials in cleartext.
|
||||
account.injector.link.homepage=Homepage
|
||||
@@ -85,7 +87,10 @@ account.injector.server_url=Server URL
|
||||
account.injector.server_name=Server Name
|
||||
account.login=Login
|
||||
account.login.hint=We will not store your password.
|
||||
account.login.offline=Login offline
|
||||
account.login.refresh=Re-login
|
||||
account.login.refresh.microsoft.hint=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nPlease click on the "OK" button, and copy the code shown here later to finish the login process in the opened browser window.
|
||||
account.login.refresh.microsoft.manual=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nYour device code is <b>%1$s</b>, please click here to copy. After clicking on the "OK" button, you should finish the login process in the opened browser window. If it did not show, you can go to %2$s manually.
|
||||
account.logout=Logout
|
||||
account.register=Register
|
||||
account.manage=Account List
|
||||
@@ -102,7 +107,7 @@ account.methods.microsoft.error.add_family=Since you are not yet 18 years old, a
|
||||
account.methods.microsoft.error.add_family_probably=Please check if the age indicated in your account settings is at least 18 years old. If not and you believe this is an error, you can click on the above link to change it.
|
||||
account.methods.microsoft.error.country_unavailable=Xbox Live is not available in your current country/region.
|
||||
account.methods.microsoft.error.missing_xbox_account=Your Microsoft account does not have a linked Xbox account yet. Please create one before continuing.
|
||||
account.methods.microsoft.error.no_character=Your account does not own the Minecraft Java Edition.
|
||||
account.methods.microsoft.error.no_character=Your account does not own the Minecraft Java Edition.\nThe game profile may not have been created,\nplease click the link above to create it.
|
||||
account.methods.microsoft.error.unknown=Failed to log in, error\: %d.
|
||||
account.methods.microsoft.logging_in=Logging in...
|
||||
account.methods.microsoft.hint=Please click on the "login" button, and copy the code shown here later to finish the login process in the opened browser window. \n\
|
||||
@@ -111,6 +116,7 @@ If the token used to log in to the Microsoft account is leaked, you can click on
|
||||
account.methods.microsoft.manual=Your device code is <b>%1$s</b>, please click here to copy. After clicking on the "Login" button, you should finish the login process in the opened browser window. If it did not show, you can go to %2$s manually.\n\
|
||||
\n\
|
||||
If the token used to log in to the Microsoft account is leaked, you can click on "Unauthorize the account" to deauthorize it.
|
||||
account.methods.microsoft.makegameidsettings=Create Profile
|
||||
account.methods.microsoft.profile=Account Profile
|
||||
account.methods.microsoft.snapshot=HMCL Snapshot version does not support Microsoft login, please use the stable version or beta version to log in.
|
||||
account.methods.microsoft.waiting_browser=Waiting for login to complete in the opened window...
|
||||
@@ -169,6 +175,7 @@ button.ok=OK
|
||||
button.refresh=Refresh
|
||||
button.remove=Remove
|
||||
button.remove.confirm=Are you sure you want to permanently remove it? This action cannot be undone\!
|
||||
button.retry=Retry
|
||||
button.save=Save
|
||||
button.save_as=Save As
|
||||
button.select_all=Select All
|
||||
@@ -361,6 +368,8 @@ fatal.unsupported_platform=Minecraft is not yet fully supported for your platfor
|
||||
\n\
|
||||
If you can't start Minecraft 1.17 and above, you can try turning on the "Use OpenGL software renderer" option\n\
|
||||
in instance settings to use CPU rendering for better compatibility.
|
||||
fatal.unsupported_platform.osx_arm64=HMCL has provided support for Apple Silicon platform, using native ARM java to launch games to get a smoother game experience.\nIf you encounter problems in the game, starting the game with Java based on x86-64 architecture may have better compatibility.
|
||||
fatal.unsupported_platform.windows_arm64=HMCL has provided native support for the Windows on ARM platform. If you encounter problems when playing game, please try to start the game with Java based on x86 architecture.\n\nIf you are using the <b>Qualcomm</b> platform, you may need to install the <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL Compatibility Pack</a> before playing games.\nClick the link to go to the Microsoft Store and install the compatibility pack.
|
||||
|
||||
feedback=Feedback
|
||||
feedback.add=Add a Feedback
|
||||
@@ -548,9 +557,7 @@ game.crash.reason.too_old_java=The game crashed because you are using a historic
|
||||
You need to switch to a newer version (%1$s) of Java in the game settings and then relaunch the game. You can download Java from <a href\="https\://docs.microsoft.com/en-US/java/openjdk/download">here</a>.
|
||||
game.crash.reason.unknown=We are not able to figure out why the game crashed, please refer to the game logs.\n\
|
||||
\n\
|
||||
<b>When asking someone else for help, please share the full game log and related crash report file with them\!</b>\n\
|
||||
\n\
|
||||
Is this crash caused by a common issue that almost everyone gets? You can provide your logs in a new GitHub issue to improve the launcher.
|
||||
<b>When asking someone else for help, please share the full game log and related crash report file with them\!</b>\n
|
||||
game.crash.reason.unsatisfied_link_error=Unable to launch Minecraft due to missing libraries\: %1$s.\n\
|
||||
\n\
|
||||
If you have modified native library settings, please make sure these libraries do exist. Or, please try to launch again after reverting it back to default.\n\
|
||||
@@ -569,7 +576,7 @@ input.number=The input must be a number.
|
||||
input.not_empty=This is a required field.
|
||||
input.url=The input must be a valid URL.
|
||||
|
||||
install=Add a New Instance
|
||||
install=New Instance
|
||||
install.change_version=Change Version
|
||||
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
|
||||
install.failed=Installation Failed
|
||||
@@ -733,7 +740,7 @@ modpack.files.scripts=MineTweaker Configuration File
|
||||
modpack.files.servers_dat=Server List File
|
||||
modpack.install=Install Modpack %s
|
||||
modpack.installing=Installing Modpack
|
||||
modpack.introduction=CurseForge, MultiMC, and HMCL modpacks are currently supported.
|
||||
modpack.introduction=CurseForge, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
|
||||
modpack.invalid=Invalid modpack, you can try to redownload it.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a %s type, but the provided one is %s type.
|
||||
modpack.name=Modpack Name
|
||||
@@ -744,7 +751,7 @@ modpack.origin.mcbbs=MCBBS
|
||||
modpack.origin.mcbbs.prompt=Post ID
|
||||
modpack.scan=Parsing Modpack Index
|
||||
modpack.task.install=Install Modpack
|
||||
modpack.task.install.error=Unable to identify this modpack. We currently only support Curse, Modrinth, MultiMC, and HMCL modpacks.
|
||||
modpack.task.install.error=Unable to identify this modpack. We currently only support Curse, Modrinth, MultiMC, and MCBBS modpacks.
|
||||
modpack.task.install.will=You are going to install modpack\:
|
||||
modpack.type.curse=Curse
|
||||
modpack.type.curse.tolerable_error=Unable to download dependencies, you can try to continue downloading by launching this game instance.
|
||||
@@ -759,7 +766,7 @@ modpack.type.multimc.export=Can be imported by Hello Minecraft\! Launcher and Mu
|
||||
modpack.type.server=Auto-Update Modpack from Server
|
||||
modpack.type.server.export=Allows server owner to update the game instance remotely
|
||||
modpack.type.server.malformed=Invalid modpack manifest, please refer to the server modpack maker to fix this issue.
|
||||
modpack.unsupported=Unsupported format, we currently only support HMCL, MultiMC, and CurseForge formats.
|
||||
modpack.unsupported=Unsupported format, we currently only support MCBBS, Modrinth, MultiMC, and CurseForge formats.
|
||||
modpack.update=Updating modpack
|
||||
modpack.wizard=Modpack Export Wizard
|
||||
modpack.wizard.step.1=Basic Settings
|
||||
@@ -832,98 +839,45 @@ mods.not_modded=You must install a mod loader (Fabric, Forge or LiteLoader) firs
|
||||
mods.restore=Rollback
|
||||
mods.url=Official Page
|
||||
|
||||
multiplayer=Multiplayer
|
||||
multiplayer.agreement.prompt=Before using the multiplayer service, you must agree to the terms of services from the service provider, ioi, first.\n\
|
||||
By using this service, you agree to these terms of service.
|
||||
multiplayer.download=Downloading Dependencies
|
||||
multiplayer.download.failed=Failed to initialize multiplayer feature, some files cannot be downloaded.
|
||||
multiplayer.download.success=Dependencies initialization completed
|
||||
multiplayer.download.unsupported=The current operating system or architecture is unsupported.
|
||||
multiplayer.exit.after_ready=The multiplayer session exited abnormally, cato exitcode %d
|
||||
multiplayer.exit.before_ready=Unable to create a multiplayer session, cato exitcode %d
|
||||
multiplayer.exit.timeout.static_token=Failed to connect to the multiplayer provider. Your token may not be working, you can try to clear it and then try again.\n\
|
||||
Or, you can wait for a few minutes. If the feature still won't work, please refer to the administrators for more help.
|
||||
multiplayer.exit.timeout.dynamic_token=Unable to connect to the multiplayer service, authentication servers may have too many requests at the time. Please wait until the next half past to allow the servers to clear some sessions.\n\
|
||||
\n\
|
||||
You can also buy some multiplayer tokens to get a stable multiplayer experience.
|
||||
multiplayer.hint=The multiplayer feature is currently in the alpha stage. Please provide your feedback at mcer.cn.
|
||||
multiplayer.nat=Network Detection
|
||||
multiplayer.nat.failed=Unable to detect the network type.
|
||||
multiplayer.nat.hint=Network type detection will check if your network environment supports the multiplayer functionality.\n\
|
||||
\n\
|
||||
If the result is 'Bad' or 'Failed', you may not be able to use this feature.\n\
|
||||
|
||||
multiplayer.nat.latency=Latency
|
||||
multiplayer.nat.not_yet_tested=Not Yet Tested
|
||||
multiplayer.nat.packet_loss_ratio=Packet Loss Rate
|
||||
multiplayer.nat.testing=Testing
|
||||
multiplayer.nat.type=NAT Type
|
||||
multiplayer.nat.type.blocked_udp=Very Bad (UDP is blocked)
|
||||
multiplayer.nat.type.full_cone=Best (Full Cone)
|
||||
multiplayer.nat.type.open_access=Best (Public Network Access)
|
||||
multiplayer.nat.type.port_restricted_cone=Good (Port Restricted Cone)
|
||||
multiplayer.nat.type.restricted_cone=Good (Restricted Cone)
|
||||
multiplayer.nat.type.symmetric=Bad (Symmetric)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=Bad (Symmetric with UDP Firewall)
|
||||
multiplayer.nat.type.unknown=Unknown
|
||||
multiplayer.powered_by=Multiplayer service is provided by <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Terms of Service</a>
|
||||
multiplayer.relay=Relay Mode
|
||||
multiplayer.relay.hint=The relay mode provides a smooth multiplayer experience for users with a poor Internet connection.\n\
|
||||
\n\
|
||||
You can enable this option after getting some tokens on mcer.cn.
|
||||
multiplayer.report=Report
|
||||
multiplayer.session=Session
|
||||
multiplayer.session.name.format=%1$s's Session
|
||||
multiplayer.session.name.motd=HMCL Multiplayer Session - %s
|
||||
multiplayer.session.close=End Session
|
||||
multiplayer.session.close.warning=All other players in this session will be disconnected. Continue?
|
||||
multiplayer.session.copy_room_code=Copy Invitation Code
|
||||
multiplayer.session.create=Create Session
|
||||
multiplayer.session.create.error.dynamic_token=Unable to create a multiplayer session. Please try again later.
|
||||
multiplayer.session.create.error.static_token=Unable to create a multiplayer session. You can clear your token and then try again later.
|
||||
multiplayer.session.create.hint=Before creating a multiplayer session, you must click on "Open to LAN" first in the game's pause menu. Then enter the port number below.
|
||||
multiplayer.session.create.join=Join Request
|
||||
multiplayer.session.create.join.allow=Automatically accepts all connection requests
|
||||
multiplayer.session.create.join.prompt=Player %s requests to join the multiplayer session. Do you want to accept?
|
||||
multiplayer.session.create.members=Players
|
||||
multiplayer.session.create.members.kick=Ban
|
||||
multiplayer.session.create.members.kick.prompt=Do you want to ban this player?
|
||||
multiplayer.session.create.name=Name
|
||||
multiplayer.session.create.port=Port
|
||||
multiplayer.session.create.port.error=Unable to detect the game port, you must click on "Open to Lan" in the game's pause menu to allow others to join your world.
|
||||
multiplayer.session.create.token=Token
|
||||
multiplayer.session.create.token.apply=Apply for a Static Token
|
||||
multiplayer.session.create.token.prompt=Random token as default, you can click on 'Apply for a Static Token' for more information.
|
||||
multiplayer.session.error.already_started=Cato service is already running, please check if there are any other HMCL instances running, or terminate cato process before continuing.
|
||||
multiplayer.session.error.file_not_found=Unable to find cato executable. This program should already be downloaded after your enter this page, please reopen the launcher and try again.\n\
|
||||
Also, please check if your anti-virus software marked it as malware. If so, please release it from your anti-virus software's quarantine.
|
||||
multiplayer.session.expired=The multiplayer session has exceeded the maximum duration of 3 hours. Please recreate or rejoin a session.
|
||||
multiplayer.session.join=Join
|
||||
multiplayer.session.join.error=Unable to join the multiplayer session. You can try to use the relay mode instead.
|
||||
multiplayer.session.join.error.connection=Unable to join the multiplayer session. Please make sure you are using the same HMCL version as the session host.
|
||||
multiplayer.session.join.hint=You must first obtain the session code from the host to join it.
|
||||
multiplayer.session.join.invitation_code=Invitation Code
|
||||
multiplayer.session.join.invitation_code.error=Invalid invitation code. Please obtain one from the multiplayer session host, and make sure you are running the same HMCL version.
|
||||
multiplayer.session.join.kicked=Lost connection to session\: %s.
|
||||
multiplayer.session.join.kicked.join_acceptance_timeout=The host did not accept your request in time.
|
||||
multiplayer.session.join.kicked.kicked=You have been kicked by the host.
|
||||
multiplayer.session.join.kicked.version_not_matched=Inconsistent versions of multiplayer libraries detected, please make sure you are using the same as the session host.
|
||||
multiplayer.session.join.lost_connection=Lost connection to the multiplayer session. The host might have ended it, or the server shut it down because it exceeds the time limit, or there are some issues with your Internet connection.
|
||||
multiplayer.session.join.port.error=Unable to bind an available local network port for listening. Please make sure that HMCL has the privileges to listen on a port.
|
||||
multiplayer.session.join.rejected=The host rejected your connection request.
|
||||
multiplayer.session.join.wait=Waiting for the host to accept...
|
||||
multiplayer.session.members=Members
|
||||
multiplayer.session.quit=Leave
|
||||
multiplayer.session.quit.warning=You will be disconnected from the server if you quit, continue?
|
||||
multiplayer.session.username=Username
|
||||
multiplayer.state.connecting=Connecting
|
||||
multiplayer.state.disconnected=Not in a Session
|
||||
multiplayer.state.disconnected.hint=The host should create a session first before others can join their world.
|
||||
multiplayer.state.master=Session %1$s created, port\: %2$d
|
||||
multiplayer.state.master.hint=After creating a session, you can now share your invitation code with your peers. They can now join your session after entering the code on the HMCL multiplayer page.
|
||||
multiplayer.state.slave=Joined session %1$s, address\: %2$s
|
||||
multiplayer.state.slave.copy=Copy Address
|
||||
multiplayer.state.slave.hint=After joining a multiplayer session, you can now join the multiplayer by joining the "HMCL Multiplayer Session" server shown in the Minecraft multiplayer server list or manually connect to the below address.
|
||||
multiplayer=Multiplayer online
|
||||
multiplayer.agreement.prompt=Before using the functionality of multiplayer, you must agree to the user agreement and disclaimer of the multiplayer service provider mcer.cn. \nEspecially, you agree that any problems and disputes you encounter while using the multiplayer functionality (including its paid services) have nothing to do with HMCL and should be resolved through consultation with mcer.cn
|
||||
multiplayer.download=Downloading dependencies
|
||||
multiplayer.download.failed=Initialization failed, some files failed to download
|
||||
multiplayer.download.success=Multiplayer online initialization completed
|
||||
multiplayer.download.unsupported=Multiplayer dependencies are not supported on current system or platform
|
||||
multiplayer.error.failed_get_device=HiPer could not create a network device, maybe HiPer has already started or lacks administrator privileges.
|
||||
multiplayer.error.failed_load_config=HiPer initialization failed, maybe there is a problem with the configuration file or the certificate is invalid.
|
||||
multiplayer.error.file_not_found=The HiPer program could not be found. The program should finish downloading when entering the multiplayer page. Please restart HMCL and try again. \nPlease check if your computer's antivirus software has flagged HiPer as a virus, and if so, restore HiPer.
|
||||
multiplayer.error.session_expired=Current multiplayer session has been expired. Please fetch a new multiplayer token.
|
||||
multiplayer.exit=HiPer exited unexpectedly with exit code %d
|
||||
multiplayer.hint=The multiplayer online function is in the experimental stage, if you have any questions, please go to mcer.cn to give feedback
|
||||
multiplayer.powered_by=This service is provided under the license of (<a href="https://mcer.cn">Matrix Lab</a>)<a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement" >License Agreement</a>
|
||||
multiplayer.report=Illegal and Violation Report
|
||||
multiplayer.session.name.motd=HMCL Multiplayer Session
|
||||
multiplayer.token=Token
|
||||
multiplayer.token.apply=Apply for a token
|
||||
multiplayer.token.expired=HiPer certificate expired, please restart HMCL and try again.
|
||||
multiplayer.token.invalid=Invalid credentials.
|
||||
multiplayer.token.malformed=HiPer configuration file could not be parsed, please restart HMCL and try again.
|
||||
multiplayer.token.prompt=You need a token to use the multiplayer service. Click on the "Application Credentials" next to it to view the details
|
||||
multiplayer.off=Start HiPer
|
||||
multiplayer.off.hint=The multiplayer service is provided by HiPer, you need to start HiPer first to use the multiplayer service.
|
||||
multiplayer.off.start=Start HiPer
|
||||
multiplayer.on=HiPer
|
||||
multiplayer.master=Prompt for creator
|
||||
multiplayer.master.hint=Multiplayer online requires one player to start the game first, select the single player mode to enter a save, and select the "Open to LAN" option in the game options menu. After that you can see the port number (usually 5 digits) indicated by the game in the game chat box. Click the Generate Server Address button below, enter the port number, and you can get your server address. This address needs to be provided to other players who need to join the server to add the server.
|
||||
multiplayer.master.server_address=Generate server address
|
||||
multiplayer.master.server_address.copy=Copy
|
||||
multiplayer.master.video_tutorial=Video Tutorial
|
||||
multiplayer.master.port=Port number
|
||||
multiplayer.master.port.validate=The port number (0~65535) displayed in the game chat box,when you open the game in LAN.
|
||||
multiplayer.slave=Participant Prompt
|
||||
multiplayer.slave.hint=If you want to join another player's game save to play the game, you need to ask that player to turn on the open mode to the local area network according to the operation prompted by the creator. Then start the game, and select the multiplayer mode, select Add Server. The game will ask you to enter the server address, you only need to create a player to ask for the server address and enter it, and then enter the server.
|
||||
multiplayer.slave.server_address=Creator server address
|
||||
multiplayer.slave.server_address.start=Join
|
||||
multiplayer.slave.server_address.stop=Exit
|
||||
multiplayer.slave.video_tutorial=Video Tutorial
|
||||
multiplayer.session.expiration=Expire Time
|
||||
|
||||
datapack=Datapacks
|
||||
datapack.add=Install datapack
|
||||
|
||||
@@ -62,7 +62,7 @@ account.create.yggdrasil=Añadir una cuenta de Mojang
|
||||
account.create.offline=Añadir una cuenta offline
|
||||
account.create.authlibInjector=Crear una cuenta authlib-injector
|
||||
account.email=Correo electrónico
|
||||
account.failed=Código de error\: %d
|
||||
account.failed=error de inicio de sesion
|
||||
account.failed.character_deleted=El personaje ya ha sido eliminado.
|
||||
account.failed.connect_authentication_server=No se ha podido conectar con los servidores de autenticación, es posible que la conexión a Internet esté caída.
|
||||
account.failed.connect_injector_server=No se ha podido conectar con el servidor de autenticación. Por favor, compruebe su red y asegúrese de que ha introducido la URL correcta.
|
||||
@@ -85,6 +85,8 @@ account.injector.server_name=Nombre del servidor
|
||||
account.login=Acceder
|
||||
account.login.hint=No almacenaremos su contraseña.
|
||||
account.login.refresh=Reiniciar sesión
|
||||
account.login.refresh.microsoft.hint=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nPor favor, haz clic en el botón "Acceder", y copia el código que se muestra aquí para terminar el proceso de acceso en la ventana abierta del navegador.
|
||||
account.login.refresh.microsoft.manual=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nEl código de su dispositivo es <b>%1$s</b>, por favor, haga clic aquí para copiar. Después de hacer clic en el botón "Iniciar sesión", debería terminar el proceso de inicio de sesión en la ventana abierta del navegador. Si no aparece, puede ir a %2$s manualmente.
|
||||
account.logout=Salir
|
||||
account.register=Registrarse
|
||||
account.manage=Lista de cuentas
|
||||
@@ -96,12 +98,13 @@ account.methods.microsoft.birth=Cómo actualizar la fecha de nacimiento de tu cu
|
||||
account.methods.microsoft.close_page=La autorización de la cuenta de Microsoft ha finalizado. \n\
|
||||
\n\
|
||||
Hay algunos trabajos extra para nosotros, pero puedes cerrar esta pestaña con seguridad por ahora.
|
||||
account.methods.microsoft.makegameidsettings=Create Profile
|
||||
account.methods.microsoft.deauthorize=Desautorizar
|
||||
account.methods.microsoft.error.add_family=Como todavía no tienes 18 años, un adulto debe añadirte a una familia para que puedas jugar a Minecraft.
|
||||
account.methods.microsoft.error.add_family_probably=Por favor, comprueba si la edad indicada en la configuración de tu cuenta es de al menos 18 años. Si no es así y crees que se trata de un error, puedes hacer clic en el enlace anterior para cambiarlo.
|
||||
account.methods.microsoft.error.country_unavailable=Xbox Live no está disponible en tu país/región actual.
|
||||
account.methods.microsoft.error.missing_xbox_account=Tu cuenta Microsoft aún no tiene una cuenta Xbox vinculada. Crea una antes de continuar.
|
||||
account.methods.microsoft.error.no_character=Tu cuenta no posee la edición Java de Minecraft.
|
||||
account.methods.microsoft.error.no_character=Tu cuenta no posee la edición Java de Minecraft.\nThe game profile may not have been created,\nplease click the link above to create it.
|
||||
account.methods.microsoft.error.unknown=No se ha podido iniciar sesión, error: %d.
|
||||
account.methods.microsoft.logging_in=Inicio de sesión...
|
||||
account.methods.microsoft.hint=Por favor, haz clic en el botón "Acceder", y copia el código que se muestra aquí para terminar el proceso de acceso en la ventana abierta del navegador.\n\
|
||||
@@ -725,7 +728,7 @@ modpack.files.scripts=Archivo de configuración de MineTweaker
|
||||
modpack.files.servers_dat=Archivo de lista de servidores
|
||||
modpack.install=Instalar Modpack %s
|
||||
modpack.installing=Instalar Modpack
|
||||
modpack.introduction=Actualmente se soportan los modpacks CurseForge, MultiMC y HMCL.
|
||||
modpack.introduction=Actualmente se soportan los modpacks CurseForge, Modrinth, MultiMC y MCBBS.
|
||||
modpack.invalid=Modpack inválido, puede intentar volver a descargarlo.
|
||||
modpack.mismatched_type=Tipo de modpack erróneo, la instancia actual es del tipo %s, pero la proporcionada es del tipo %s.
|
||||
modpack.name=Nombre del modpack
|
||||
@@ -736,7 +739,7 @@ modpack.origin.mcbbs=MCBBS
|
||||
modpack.origin.mcbbs.prompt=ID de publicación
|
||||
modpack.scan=Análisis del índice de modpacks
|
||||
modpack.task.install=Instalar Modpack
|
||||
modpack.task.install.error=No se ha podido identificar este modpack. Actualmente sólo soportamos los modpacks Curse, Modrinth, MultiMC y HMCL.
|
||||
modpack.task.install.error=No se ha podido identificar este modpack. Actualmente sólo soportamos los modpacks Curse, Modrinth, MultiMC y MCBBS.
|
||||
modpack.task.install.will=Vas a instalar el modpack\:
|
||||
modpack.type.curse=Curse
|
||||
modpack.type.curse.tolerable_error=No se han podido descargar las dependencias, puedes intentar continuar la descarga ejecutando esta instancia del juego.
|
||||
@@ -751,7 +754,7 @@ modpack.type.multimc=MultiMC
|
||||
modpack.type.server=Actualización automática del modpack desde el servidor
|
||||
modpack.type.server.export=Permite al propietario del servidor actualizar la instancia del juego de forma remota
|
||||
modpack.type.server.malformed=Manifiesto de modpack inválido, por favor, consulte al creador del modpack del servidor para solucionar este problema.
|
||||
modpack.unsupported=Formato no soportado, actualmente sólo soportamos los formatos HMCL, MultiMC y CurseForge.
|
||||
modpack.unsupported=Formato no soportado, actualmente sólo soportamos los formatos MCBBS, Modrinth, MultiMC y CurseForge.
|
||||
modpack.update=Actualización del modpack
|
||||
modpack.wizard=Asistente de exportación de modpack
|
||||
modpack.wizard.step.1=Configuración básica
|
||||
@@ -858,7 +861,7 @@ multiplayer.nat.type.restricted_cone=Bueno (Cono Restringido)
|
||||
multiplayer.nat.type.symmetric=Malo (Simétrico)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=Malo (Simétrico con cortafuegos UDP)
|
||||
multiplayer.nat.type.unknown=Desconocido
|
||||
multiplayer.powered_by=El servicio multijugador es proporcionado por <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Términos de servicio</a>
|
||||
multiplayer.powered_by=El servicio multijugador es proporcionado por <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Licence</a>
|
||||
multiplayer.relay=Modo de retransmisión
|
||||
multiplayer.relay.hint=El modo de retransmisión proporciona una experiencia multijugador sin problemas para los usuarios con una mala conexión a Internet.\n\
|
||||
\n\
|
||||
|
||||
@@ -60,7 +60,7 @@ account.create.yggdrasil=Mojang Login
|
||||
account.create.offline=Offline
|
||||
account.create.authlibInjector=サードパーティーアカウントを追加
|
||||
account.email=メールアドレス
|
||||
account.failed=エラーコード:%d
|
||||
account.failed=ログインに失敗しました
|
||||
account.failed.character_deleted=キャラクターが削除されました。
|
||||
account.failed.connect_authentication_server=認証サーバーに接続できません。インターネット接続を確認してください。
|
||||
account.failed.connect_injector_server=認証サーバーに接続できません。ネットワークをチェックして、URLが正しいことを確認してください。
|
||||
@@ -83,6 +83,8 @@ account.injector.server_name=サーバー名
|
||||
account.login=ログイン
|
||||
account.login.hint=パスワードは保存されません。
|
||||
account.login.refresh=再ログイン
|
||||
account.login.refresh.microsoft.hint=<b>アカウント認証が無効なため、Microsoft アカウントを再度追加する必要があります</b>\n「OK」ボタンをクリックして、新しく開いたブラウザウィンドウでログインプロセスを続行する必要があります。
|
||||
account.login.refresh.microsoft.manual=<b>アカウント認証が無効なため、Microsoft アカウントを再度追加する必要があります</b>\n「OK」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。
|
||||
account.logout=ログアウト
|
||||
account.register=登録
|
||||
account.manage=アカウントリスト
|
||||
@@ -97,9 +99,10 @@ account.methods.microsoft.error.add_family=まだ18歳ではないため、Minec
|
||||
account.methods.microsoft.error.add_family_probably=すでに18歳であるかどうかにかかわらず、アカウント設定を確認してください。そうでない場合は、アカウントを家族に追加する必要があります。
|
||||
account.methods.microsoft.error.country_unavailable=XBoxLiveはお住まいの国/地域ではご利用いただけません。
|
||||
account.methods.microsoft.error.missing_xbox_account=MicrosoftアカウントがXboxアカウントに接続されていません。続行する前に作成してください。
|
||||
account.methods.microsoft.error.no_character=アカウントにMinecraftJavaプロファイルがありません。 Microsoftアカウントは有効ですが、ゲームを所有していません。
|
||||
account.methods.microsoft.error.no_character=アカウントにMinecraftJavaプロファイルがありません。\nゲームファイルが作成されていない可能性があります\n上記のリンクをクリックして作成してください
|
||||
account.methods.microsoft.error.unknown=ログインに失敗しました。Microsoftはエラーコード %d で応答します。
|
||||
account.methods.microsoft.logging_in=ログイン...
|
||||
account.methods.microsoft.makegameidsettings=プロファイルを作成
|
||||
account.methods.microsoft.hint=「ログイン」ボタンをクリックして、新しく開いたブラウザウィンドウでログインプロセスを続行する必要があります。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。
|
||||
account.methods.microsoft.manual=「ログイン」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。
|
||||
account.methods.microsoft.profile=アカウントプロファイル..
|
||||
@@ -569,7 +572,7 @@ modpack.files.scripts=MineTweaker構成
|
||||
modpack.files.servers_dat=サーバーリスト
|
||||
modpack.install=%s modpackをインストールします
|
||||
modpack.installing=modpackのインストール
|
||||
modpack.introduction=Curse、MultiMC、HMCLmodpackがサポートされています。
|
||||
modpack.introduction= Curse, Modrinth, MultiMC, MCBBS Modpackがサポートされています。
|
||||
modpack.invalid=無効なmodpackファイル。
|
||||
modpack.mismatched_type=不適切なmodpackタイプ、現在のゲームは %s modpackですが、更新ファイルは %s modpackです。
|
||||
modpack.name=Modpack名
|
||||
@@ -595,7 +598,7 @@ modpack.type.multimc.export=Hello Minecraftでインポートできます!ラ
|
||||
modpack.type.server=サーバー自動更新Modpack
|
||||
modpack.type.server.export=サーバーマネージャーがゲームクライアントをリモートで更新できるようにする
|
||||
modpack.type.server.malformed=不正なサーバーのmodpackマニフェスト
|
||||
modpack.unsupported=サポートされていないmodpack、HMCL、MultiMC、およびCursemodpackのみがサポートされています。
|
||||
modpack.unsupported=サポートされていないmodpack、Curse, Modrinth, MultiMC, MCBBSおよびMultiMCのみがサポートされています。
|
||||
modpack.update=ゲームアップデート
|
||||
modpack.wizard=modpackウィザードのエクスポート
|
||||
modpack.wizard.step.1=基本オプション
|
||||
@@ -682,7 +685,7 @@ multiplayer.nat.type.restricted_cone=Medium(制限付きコーン)
|
||||
multiplayer.nat.type.symmetric=Bad(対称)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=Bad(UDPファイアウォールと対称)
|
||||
multiplayer.nat.type.unknown=不明`
|
||||
multiplayer.powered_by=マルチプレイヤーサービスは<a href="https://mcer.cn">mcer.cn</a>によって提供されます。 <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">EULA</a>
|
||||
multiplayer.powered_by=マルチプレイヤーサービスは<a href="https://mcer.cn">mcer.cn</a>によって提供されます。 <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Licence/a>
|
||||
multiplayer.report=レポート
|
||||
multiplayer.relay=リレーモード
|
||||
multiplayer.relay.hint=静的トークンを持ち、テストされたNATタイプが不良対称であるユーザーの場合
|
||||
|
||||
@@ -60,7 +60,7 @@ account.create.yggdrasil=Добавление аккаунта Mojang
|
||||
account.create.offline=Добавление офлайн аккаунта
|
||||
account.create.authlibInjector=Добавление аккаунта authlib-injector
|
||||
account.email=Электронная почта
|
||||
account.failed=Код ошибки: %d
|
||||
account.failed=Авторизация не удалась
|
||||
account.failed.character_deleted=Персонаж был удалён.
|
||||
account.failed.connect_authentication_server=Невозможно подключиться к серверу авторизации. Проверьте сетевое подключение.
|
||||
account.failed.connect_injector_server=Невозможно подключиться к серверу авторизации. Проверьте сетевое подключение и убедитесь, что ссылка указана правильно.
|
||||
@@ -83,6 +83,8 @@ account.injector.server_name=Имя сервера
|
||||
account.login=Войти
|
||||
account.login.hint=Мы не храним ваш пароль.
|
||||
account.login.refresh=Повторный вход
|
||||
account.login.refresh.microsoft.hint=<b>Поскольку авторизация учетной записи недействительна, вам необходимо повторно добавить свою учетную запись Microsoft</b>\nВам необходимо нажать кнопку «OK» и продолжить процесс авторизации во вновь открывшемся окне браузера.
|
||||
account.login.refresh.microsoft.manual=<b>Поскольку авторизация учетной записи недействительна, вам необходимо повторно добавить свою учетную запись Microsoft</b>\nПосле нажатия кнопки «OK», вы должны завершить авторизацию во вновь открывшемся окне браузера. Если окно браузера не открылось, вы можете щёлкнуть здесь, чтобы скопировать ссылку и вручную открыть её в браузере.
|
||||
account.logout=Выйти
|
||||
account.register=Регистрация
|
||||
account.manage=Список аккаунтов
|
||||
@@ -97,9 +99,10 @@ account.methods.microsoft.error.add_family=Поскольку тебе ещё н
|
||||
account.methods.microsoft.error.add_family_probably=Проверьте в настройках своего аккаунта, исполнилось ли вам уже 18 лет или нет. Если нет, необходимо добавить семейный аккаунт.
|
||||
account.methods.microsoft.error.country_unavailable=Xbox Live недоступен в вашей стране/регионе.
|
||||
account.methods.microsoft.error.missing_xbox_account=Ваш аккаунт Microsoft не подключена к Xbox. Создайте его, прежде чем продолжить.
|
||||
account.methods.microsoft.error.no_character=На аккаунте отсутствует профиль Minecraft Java. Хотя аккаунт Microsoft действителен, на ней не приобретено игры.
|
||||
account.methods.microsoft.error.no_character=На аккаунте отсутствует профиль Minecraft Java. \nФайл игры не может быть создан, пожалуйста,\nнажмите на ссылку выше, чтобы создать его
|
||||
account.methods.microsoft.error.unknown=Не удалось войти. Microsoft отвечает с кодом ошибки %d.
|
||||
account.methods.microsoft.logging_in=Авторизация...
|
||||
account.methods.microsoft.makegameidsettings=Создать архив
|
||||
account.methods.microsoft.hint=Вам необходимо нажать кнопку «Войти» и продолжить процесс авторизации во вновь открывшемся окне браузера.\nЕсли токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию.
|
||||
account.methods.microsoft.manual=После нажатия кнопки «Войти», вы должны завершить авторизацию во вновь открывшемся окне браузера. Если окно браузера не открылось, вы можете щёлкнуть здесь, чтобы скопировать ссылку и вручную открыть её в браузере.\nЕсли токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию.
|
||||
account.methods.microsoft.profile=Профиль аккаунта...
|
||||
@@ -573,7 +576,7 @@ modpack.files.scripts=Конфигурация MineTweaker
|
||||
modpack.files.servers_dat=Список серверов
|
||||
modpack.install=Установить модпак %s
|
||||
modpack.installing=Установка модпака
|
||||
modpack.introduction=Поддерживаются модпаки Curse, MultiMC, HMCL.
|
||||
modpack.introduction=Поддерживаются модпаки Curse, Modrinth, MultiMC, MCBBS.
|
||||
modpack.invalid=Неверный файл модпака.
|
||||
modpack.mismatched_type=Неправильный тип модпака, ваша текущая игра - это модпак %s, но ваш файл обновления - это модпак %s.
|
||||
modpack.name=Название модпака
|
||||
@@ -599,7 +602,7 @@ modpack.type.multimc.export=Может быть импортирован Hello M
|
||||
modpack.type.server=Сервер автообновления модпака
|
||||
modpack.type.server.export=Разрешить менеджеру сервера удаленно обновлять клиент игры
|
||||
modpack.type.server.malformed=Некорректный манифест модпака сервера
|
||||
modpack.unsupported=Неподдерживаемый модпак, поддерживаются только модпаки HMCL, MultiMC и Curse.
|
||||
modpack.unsupported=Неподдерживаемый модпак, поддерживаются только модпаки Curse, Modrinth, MultiMC, MCBBS.
|
||||
modpack.update=Обновить игру
|
||||
modpack.wizard=Мастер экспорта модпака
|
||||
modpack.wizard.step.1=Основные параметры
|
||||
@@ -670,22 +673,7 @@ multiplayer.exit.before_ready=Не удалось создать сессию с
|
||||
multiplayer.exit.timeout.static_token=Не удалось подключиться к серверу сетевой игры. Переключитесь на динамический токен и попробуйте снова.
|
||||
multiplayer.exit.timeout.dynamic_token=Не удалось подключиться к серверу сетевой игры.
|
||||
multiplayer.hint=Функция сетевой игры экспериментальная. Оставьте свой отзыв.
|
||||
multiplayer.nat=Обнаружение типа сети
|
||||
multiplayer.nat.hint=Определение типа сети позволит понять, соответствует ли ваша сеть нашим требованиям для сетевой игры.
|
||||
multiplayer.nat.latency=Задержка
|
||||
multiplayer.nat.not_yet_tested=Ещё не протестировано
|
||||
multiplayer.nat.packet_loss_ratio=Коэффициент потери пакетов
|
||||
multiplayer.nat.testing=Тестирование
|
||||
multiplayer.nat.type=Тип NAT
|
||||
multiplayer.nat.type.blocked_udp=Очень плохой (заблокированный UDP)
|
||||
multiplayer.nat.type.full_cone=Хороший (полный конус)
|
||||
multiplayer.nat.type.open_access=Хороший (открытый доступ)
|
||||
multiplayer.nat.type.port_restricted_cone=Средний (конус с ограничением порта)
|
||||
multiplayer.nat.type.restricted_cone=Средний (ограниченный конус)
|
||||
multiplayer.nat.type.symmetric=Плохой (симметричный)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=Плохо (симметричный с брандмауэром UDP)
|
||||
multiplayer.nat.type.unknown=Неизвестно
|
||||
multiplayer.powered_by=Сетевая игра предоставлена <a href="https://mcer.cn">mcer.cn</a>. <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">EULA</a>
|
||||
multiplayer.powered_by=Сетевая игра предоставлена <a href="https://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Licence</a>
|
||||
multiplayer.report=Отчёт
|
||||
multiplayer.relay=Режим реле
|
||||
multiplayer.relay.hint=Для пользователей, у которых есть статический токен, и чей проверенный тип NAT - плохо-семитричный
|
||||
|
||||
@@ -60,7 +60,7 @@ account.create.yggdrasil=添加 Mojang 帳戶
|
||||
account.create.offline=添加離線模式帳戶
|
||||
account.create.authlibInjector=添加外置登入帳戶 (authlib-injector)
|
||||
account.email=電子信箱
|
||||
account.failed=錯誤碼: %d
|
||||
account.failed=登錄失敗
|
||||
account.failed.character_deleted=已刪除此角色
|
||||
account.failed.connect_authentication_server=無法連線至認證伺服器,可能是網路問題,請檢查設備能否正常上網或使用代理服務
|
||||
account.failed.connect_injector_server=無法連線至認證伺服器,可能是網路故障,請檢查設備能否正常上網、檢查網址是否輸入錯誤或使用代理服務
|
||||
@@ -70,6 +70,7 @@ account.failed.invalid_password=密碼無效
|
||||
account.failed.invalid_token=請嘗試登出並重新輸入密碼登入
|
||||
account.failed.migration=你的帳號需要被遷移至微軟帳號。如果你已經遷移,你需要使用微軟登錄方式登錄遷移後的微軟帳號。
|
||||
account.failed.no_character=該帳戶沒有角色
|
||||
account.failed.server_disconnected=無法訪問登錄伺服器。是否以離線模式繼續登錄(進入遊戲後無法進入有正版驗證的伺服器)?或嘗試重新登入?
|
||||
account.failed.server_response_malformed=無法解析認證伺服器回應,可能是伺服器故障
|
||||
account.failed.wrong_account=登錄了錯誤的帳號
|
||||
account.hmcl.hint=你需要點擊“登入”按鈕,並在打開的網頁中完成登入
|
||||
@@ -83,7 +84,10 @@ account.injector.server_url=伺服器位址
|
||||
account.injector.server_name=伺服器名稱
|
||||
account.login=登入
|
||||
account.login.hint=我們不會保存你的密碼
|
||||
account.login.offline=以離線模式登錄
|
||||
account.login.refresh=重新登錄
|
||||
account.login.refresh.microsoft.hint=<b>因為賬戶授權失效,需要重新添加你的微軟賬戶</b>\n你需要按照以下步驟添加:\n \n1.點擊“確定”按鈕\n2.在網頁瀏覽器顯示的網站中輸入 HMCL 顯示的代碼(自動拷貝,直接粘貼即可),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n \n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>
|
||||
account.login.refresh.microsoft.manual=<b>因為賬戶授權失效,需要重新添加你的微軟賬戶</b>\n你需要按照以下步驟添加:\n \n1.點擊“確定”按鈕\n2.在網頁瀏覽器顯示的網站中輸入該代碼:<b>%1$s</b>(已自動拷貝,請點此再次拷貝),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n \n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n-若網站未能打開,請手動在網頁瀏覽器中打開:%2$s\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>
|
||||
account.logout=登出
|
||||
account.register=註冊
|
||||
account.manage=帳戶列表
|
||||
@@ -98,9 +102,10 @@ account.methods.microsoft.error.add_family=由於你未滿 18 歲,你的帳戶
|
||||
account.methods.microsoft.error.add_family_probably=請檢查你的帳戶設置,如果你未滿 18 歲,你的帳戶必須被加入到家庭中才能登錄遊戲。你也可以點擊上方連結更改你的帳戶的出生日期,使年齡滿 18 歲以上以繼續登錄。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 XBox Live 的支援。
|
||||
account.methods.microsoft.error.missing_xbox_account=你的微軟帳號尚未關聯 XBox 帳號,你必須先創建 XBox 帳號,才能登入遊戲。
|
||||
account.methods.microsoft.error.no_character=該帳號沒有包含 Minecraft Java 版購買記錄,
|
||||
account.methods.microsoft.error.no_character=該帳戶未包含 Minecraft Java 版購買記錄\n可能未創建遊戲檔案,請點擊上方鏈接創建
|
||||
account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d
|
||||
account.methods.microsoft.logging_in=登入中...
|
||||
account.methods.microsoft.makegameidsettings=創建檔案
|
||||
account.methods.microsoft.hint=你需要按照以下步驟添加:\n1.點擊“登入”按鈕\n2.在網頁瀏覽器顯示的網站中輸入 HMCL 顯示的代碼(自動拷貝,直接粘貼即可),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>\n-若登入微軟賬號的 Token 洩露,可點擊下方“解除帳戶授權”解除登入授權,然後等待 Token 過期
|
||||
account.methods.microsoft.manual=你需要按照以下步驟添加:\n1.點擊“登入”按鈕\n2.在網頁瀏覽器顯示的網站中輸入該代碼:<b>%1$s</b>(已自動拷貝,請點此再次拷貝),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可\n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n-若網站未能打開,請手動在網頁瀏覽器中打開:%2$s\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>\n-若登入微軟賬號的 Token 洩露,可點擊下方“解除帳戶授權”解除登入授權,然後等待 Token 過期
|
||||
account.methods.microsoft.profile=帳戶設置頁
|
||||
@@ -157,6 +162,7 @@ button.ok=確定
|
||||
button.refresh=重新整理
|
||||
button.remove=刪除
|
||||
button.remove.confirm=您確認要刪除嗎?該操作無法撤銷!
|
||||
button.retry=重試
|
||||
button.save=儲存
|
||||
button.save_as=另存為
|
||||
button.select_all=全選
|
||||
@@ -327,7 +333,9 @@ fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即將升級完
|
||||
fatal.apply_update_failure=我們很抱歉 Hello Minecraft! Launcher 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 Hello Minecraft! Launcher 來完成升級。\n請考慮向我們回報該問題。
|
||||
fatal.samba=如果您正在通過 Samba 共亯的資料夾中運行 HMCL,HMCL 可能無法正常工作,請嘗試更新您的 Java 或在本地資料夾內運行 HMCL。
|
||||
fatal.illegal_char=您的用戶資料夾路徑中存在非法字元‘=’。HMCL 能够運行,但一些功能無法正常使用。 \n您將無法使用外置登入帳戶以及離線登入更換皮膚功能,並且在進行多人遊戲時可能存在更大的安全隱患。
|
||||
fatal.unsupported_platform=Minecraft 尚未你您的平臺提供完善支持,所以可能影響遊戲體驗或無法啟動遊戲。\n若無法啟動 Minecraft 1.17 及以上版本,可以嘗試在版本設定中打開“使用 OpenGL 軟渲染器”選項,\n使用 CPU 渲染以獲得更好的相容性。
|
||||
fatal.unsupported_platform=Minecraft 尚未你您的平臺提供完善支持,所以可能影響遊戲體驗或無法啟動遊戲。\n若無法啟動 Minecraft 1.17 及以上版本,可以嘗試在版本設定中打開“使用 OpenGL 軟渲染器”選項,使用 CPU 渲染以獲得更好的相容性。
|
||||
fatal.unsupported_platform.osx_arm64=HMCL 已為 Apple Silicon 平臺提供支援,使用 ARM 原生 Java 啟動遊戲以獲得更流暢的遊戲體驗。\n如果你在遊戲中遭遇問題,使用 x86-64 架構的 Java 啟動遊戲可能有更好的相容性。
|
||||
fatal.unsupported_platform.windows_arm64=HMCL 已為 Windows on Arm 平臺提供原生支持。如果你在遊戲中遭遇問題,請嘗試使用 x86 架構的 Java 啟動遊戲。\n\n如果你正在使用<b>高通</b>平臺,你可能需要安裝 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 相容包</a>後才能進行遊戲。點擊連結前往 Microsoft Store 安裝相容包。
|
||||
|
||||
feedback=回饋
|
||||
feedback.add=新增回饋
|
||||
@@ -366,7 +374,7 @@ folder.saves=遊戲存檔資料夾
|
||||
folder.screenshots=截圖資料夾
|
||||
|
||||
game=遊戲
|
||||
game.crash.feedback=查看了日誌發現問題很常見?在回饋頁面提交回饋豐富崩潰分析系統!
|
||||
game.crash.feedback=查看了日誌發現問題很常見?在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>提交回饋豐富崩潰分析系統!
|
||||
game.crash.info=遊戲訊息
|
||||
game.crash.reason=崩潰原因
|
||||
game.crash.reason.analyzing=分析中...
|
||||
@@ -409,7 +417,7 @@ game.crash.reason.out_of_memory=當前遊戲因為記憶體不足,無法繼續
|
||||
game.crash.reason.resolution_too_high=當前遊戲因為材質包解析度過高,無法繼續運行\n你可以更換一個解析度更低的材質,或者更換一個視訊記憶體更大的顯示卡。
|
||||
game.crash.reason.stacktrace=原因未知,請點擊日誌按鈕查看詳細訊息。\n下面是一些關鍵字,其中可能包含 Mod 名稱,你可以透過搜索的方式查找有關訊息。\n%s
|
||||
game.crash.reason.too_old_java=當前遊戲因為 Java 虛擬機版本過低,無法繼續運行。\n你需要在遊戲設置中更換 %1$s 或更新版本的 Java 虛擬機,並重新啟動遊戲。如果沒有下載安裝,你可以點擊 <a href="https://docs.microsoft.com/zh-TW/java/openjdk/download">此處</a> 下載微軟 JDK。
|
||||
game.crash.reason.unknown=原因未知,請點擊日誌按鈕查看詳細訊息。\n<b>不要將本界面截圖給他人!</b>如果你要求助他人,請你點擊左下角導出遊戲崩潰訊息後將導出的文件發送給他人以供分析!\n你可以向我們提供遊戲崩潰日誌以提升 HMCL 的日誌分析能力。
|
||||
game.crash.reason.unknown=原因未知,請點擊日誌按鈕查看詳細訊息。\n<b>不要將本界面截圖給他人!</b>如果你要求助他人,請你點擊左下角導出遊戲崩潰訊息後將導出的文件發送給他人以供分析!
|
||||
game.crash.reason.unsatisfied_link_error=當前遊戲因為缺少本地庫,無法繼續運行。\n這些本地庫缺失:%1$s。\n如果你在遊戲設置中修改了本地庫路徑選項,請你修改回預設模式。\n如果已經在預設模式下,請檢查本地庫缺失是否是 Mod 引起的,或由 HMCL 引起的。如果你確定是 HMCL 引起的,建議你向我們回饋。\n如果你確實需要自訂本地庫路徑,你需要保證其中包含缺失的本地庫。
|
||||
game.crash.title=遊戲意外退出
|
||||
game.directory=遊戲路徑
|
||||
@@ -582,7 +590,7 @@ modpack.files.scripts=MineTweaker 設定
|
||||
modpack.files.servers_dat=多人遊戲伺服器列表
|
||||
modpack.install=安裝 %s 模組包
|
||||
modpack.installing=正在安裝模組包
|
||||
modpack.introduction=支援 Curse、MultiMC、HMCL 模組包。
|
||||
modpack.introduction=支援 Curse、Modrinth、MultiMC、MCBBS 模組包。
|
||||
modpack.invalid=無效的模組包升級檔案,可能是下載時出現問題。
|
||||
modpack.mismatched_type=模組包類型不符,目前遊戲是 %s 模組包,但是提供的模組包更新檔案是 %s 模組包。
|
||||
modpack.name=模組包名稱
|
||||
@@ -593,7 +601,7 @@ modpack.origin.mcbbs=MCBBS
|
||||
modpack.origin.mcbbs.prompt=貼子 id
|
||||
modpack.scan=解析模組包
|
||||
modpack.task.install=匯入模組包
|
||||
modpack.task.install.error=無法識別該模組包,目前僅支援匯入 Curse、Modrinth、MultiMC、HMCL 模組包。
|
||||
modpack.task.install.error=無法識別該模組包,目前僅支援匯入 Curse、Modrinth、MultiMC、MCBBS 模組包。
|
||||
modpack.task.install.will=將會安裝模組包:
|
||||
modpack.type.curse=Curse
|
||||
modpack.type.curse.tolerable_error=但未能完成該模組包檔案的下載,您可以在啟動該遊戲版本時繼續該模組包檔案的下載。由於網路問題,您可能需要重試多次……
|
||||
@@ -608,7 +616,7 @@ modpack.type.multimc.export=可以被 Hello Minecraft! Launcher (HMCL) 和 Multi
|
||||
modpack.type.server=伺服器自動更新模組包
|
||||
modpack.type.server.export=允許伺服器管理員遠端更新遊戲用戶端
|
||||
modpack.type.server.malformed=伺服器模組包配置格式錯誤,請聯絡伺服器管理員解決此問題
|
||||
modpack.unsupported=不支援該模組包。僅 HMCL、MultiMC、Curse 模組包受支援。
|
||||
modpack.unsupported=不支援該模組包。僅 Curse、Modrinth、MultiMC、MCBBS 模組包受支援。
|
||||
modpack.update=正在升級模組包
|
||||
modpack.wizard=匯出模組包引導
|
||||
modpack.wizard.step.1=基本設定
|
||||
@@ -680,87 +688,43 @@ mods.restore=回退
|
||||
mods.url=官方頁面
|
||||
|
||||
multiplayer=多人聯機
|
||||
multiplayer.agreement.prompt=使用多人聯機功能前,你需要先同意多人聯機服務提供方 ioi 系列作品的用戶協議與免責聲明。\n用戶使用聯機服務的行為,將被視為無條件同意該用戶協議與免責聲明。
|
||||
multiplayer.agreement.prompt=多人聯機功能由 速聚 提供。使用前,你需要先同意多人聯機服務提供方 速聚 的用戶協議與免責聲明。\n你需要了解,HMCL 僅為 速聚 提供多人聯機服務入口,使用中遇到的任何問題由 速聚 負責處理。您在使用多人聯機服務過程中所遇到的任何問題與糾紛(包括其付費業務)均與 HMCL 無關,應與 速聚 協商解決。
|
||||
multiplayer.download=正在下載相依元件
|
||||
multiplayer.download.failed=初始化失敗,部分檔案未能完成下載
|
||||
multiplayer.download.success=多人聯機初始化完成
|
||||
multiplayer.download.unsupported=多人聯機依賴不支持當前系統或平台
|
||||
multiplayer.exit.after_ready=多人聯機會話意外退出,退出碼 %d
|
||||
multiplayer.exit.before_ready=多人聯機房間創建失敗,cato 退出碼 %d
|
||||
multiplayer.exit.timeout.static_token=無法連接多人聯機服務,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作,或者耐心等待十分鍾。
|
||||
multiplayer.exit.timeout.dynamic_token=無法連接多人聯機服務,你可以在多人聯機頁面的回饋中回饋問題。
|
||||
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請回饋。
|
||||
multiplayer.nat=網路檢測
|
||||
multiplayer.nat.failed=檢測失敗,但你仍然可以繼續使用聯機功能。
|
||||
multiplayer.nat.hint=執行網路檢測可以讓你更清楚你的網路狀況是否符合聯機功能的需求。不符合聯機功能運行條件的網路狀況將可能導致聯機失敗。
|
||||
multiplayer.nat.latency=延遲
|
||||
multiplayer.nat.not_yet_tested=尚未檢測
|
||||
multiplayer.nat.packet_loss_ratio=丟包率
|
||||
multiplayer.nat.testing=檢測中
|
||||
multiplayer.nat.type=NAT 類型
|
||||
multiplayer.nat.type.blocked_udp=極差(網路禁止 UDP 協議)
|
||||
multiplayer.nat.type.full_cone=好(完全圓錐型)
|
||||
multiplayer.nat.type.open_access=好(公網開放型)
|
||||
multiplayer.nat.type.port_restricted_cone=中(埠受限圓錐型)
|
||||
multiplayer.nat.type.restricted_cone=中(受限圓錐型)
|
||||
multiplayer.nat.type.symmetric=差(對稱型)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=差(對稱型+防火牆)
|
||||
multiplayer.nat.type.unknown=未知
|
||||
multiplayer.powered_by=多人聯機服務由(<a href="https://mcer.cn">mcer.cn</a>) 提供。<a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用戶協議與免責聲明</a>
|
||||
multiplayer.error.failed_get_device=HiPer 無法創建網絡設備,可能是HiPer已經啟動或缺少管理員權限。
|
||||
multiplayer.error.failed_load_config=HiPer 初始化失敗,可能是配置文件存在問題或證書已失效。
|
||||
multiplayer.error.file_not_found=找不到 HiPer 程式。該程式應該在進入多人聯機頁面時完成下載。請重啟 HMCL 再試。\n請檢查你電腦的防毒軟體是否將 HiPer 標記為病毒,如果是,請恢復 HiPer。
|
||||
multiplayer.error.session_expired=本次使用時間已結束,若要繼續使用,請更新憑證
|
||||
multiplayer.exit=HiPer 意外退出,退出碼 %d
|
||||
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請前往 mcer.cn 回饋
|
||||
multiplayer.powered_by=本服務由 (<a href="https://mcer.cn">速聚</a>) 提供。<a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">授權協議</a>
|
||||
multiplayer.report=違法違規檢舉
|
||||
multiplayer.relay=橋接服務
|
||||
multiplayer.relay.hint=Cato 面向較差網路提供的網路質量增強最佳化解決方案|若無法聯機請到 mcer.cn 的個人中心兌換憑證後啟用
|
||||
multiplayer.session=房間
|
||||
multiplayer.session.name.format=%1$s 的房間
|
||||
multiplayer.session.name.motd=HMCL 多人聯機房間 - %s
|
||||
multiplayer.session.close=關閉房間
|
||||
multiplayer.session.close.warning=關閉房間後,已經加入聯機房間的玩家將會斷開連接,是否繼續?
|
||||
multiplayer.session.copy_room_code=複製邀請碼
|
||||
multiplayer.session.create=創建房間
|
||||
multiplayer.session.create.error.dynamic_token=創建聯機房間失敗,請稍後再試。
|
||||
multiplayer.session.create.error.static_token=創建聯機房間失敗,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作,或者耐心等待十分鍾。
|
||||
multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字)
|
||||
multiplayer.session.create.join=連接申請
|
||||
multiplayer.session.create.join.allow=自動接受所有連接申請(不啟用此選項時,你需要手動同意申請,以避免不相關人士誤連你的伺服器)
|
||||
multiplayer.session.create.join.prompt=玩家 %s 申請加入多人聯機房間,是否接受?
|
||||
multiplayer.session.create.members=成員
|
||||
multiplayer.session.create.members.kick=踢出房間
|
||||
multiplayer.session.create.members.kick.prompt=是否踢出該玩家?踢出後該玩家不能再參與該聯機房間。
|
||||
multiplayer.session.create.name=房間名稱
|
||||
multiplayer.session.create.port=埠號
|
||||
multiplayer.session.create.port.error=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。
|
||||
multiplayer.session.create.token=憑證
|
||||
multiplayer.session.create.token.apply=申請憑證
|
||||
multiplayer.session.create.token.prompt=預設為無憑證。你可以點擊旁邊的“申請憑證”獲得詳情
|
||||
multiplayer.session.error.already_started=本地已經開啟 cato 服務,請檢查是否有其他 HMCL 正在運行聯機服務。或者你可以在任務管理器裡殺掉 cato 進程以繼續。
|
||||
multiplayer.session.error.file_not_found=找不到 cato 程序。該程序應該在進入多人聯機頁面時完成下載。請檢查你電腦的防毒軟體是否將 cato 標記為病毒,如果是,請恢復 cato。
|
||||
multiplayer.session.expired=聯機會話連續使用時間超過了 3 小時,你需要重新創建/加入房間以繼續聯機。
|
||||
multiplayer.session.join=加入房間
|
||||
multiplayer.session.join.error=加入房間失敗
|
||||
multiplayer.session.join.error.connection=加入房間失敗。無法與對方建立連接。如果你或對方的網路類型是差(對稱型),可能無法使用聯機功能。
|
||||
multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要邀請碼以便加入多人聯機房間
|
||||
multiplayer.session.join.invitation_code=邀請碼
|
||||
multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼
|
||||
multiplayer.session.join.kicked=你與房間失去連接:%s。
|
||||
multiplayer.session.join.kicked.join_acceptance_timeout=對方未能即時同意你的加入申請
|
||||
multiplayer.session.join.kicked.kicked=你被房主踢出房間。
|
||||
multiplayer.session.join.kicked.version_not_matched=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。
|
||||
multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。
|
||||
multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。
|
||||
multiplayer.session.join.rejected=你被房主拒絕連接。
|
||||
multiplayer.session.join.wait=等待對方同意加入申請。
|
||||
multiplayer.session.members=房間成員
|
||||
multiplayer.session.quit=退出房間
|
||||
multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續?
|
||||
multiplayer.session.username=使用者名稱
|
||||
multiplayer.state.connecting=連接中
|
||||
multiplayer.state.disconnected=未創建/加入房間
|
||||
multiplayer.state.disconnected.hint=多人聯機功能需要先有一位玩家創建房間後,其他玩家加入房間後繼續遊戲。
|
||||
multiplayer.state.master=你已創建房間:%1$s,埠號 %2$d
|
||||
multiplayer.state.master.hint=創建房間後,你可以點擊複製邀請碼,並發送給你希望加入聯機的玩家。這些玩家在進入 HMCL 多人聯機頁面後,點擊加入房間並黏貼邀請碼即可加入聯機房間。
|
||||
multiplayer.state.slave=你已加入房間: %1$s,位址為 %2$s
|
||||
multiplayer.state.slave.copy=拷貝位址
|
||||
multiplayer.state.slave.hint=加入房間後,你需要在 Minecraft 的多人遊戲頁面選擇 HMCL 多人聯機房間伺服器,或者手動添加下方的地址的伺服器。
|
||||
multiplayer.token=憑證
|
||||
multiplayer.token.apply=申請憑證
|
||||
multiplayer.token.invalid=憑證不正確。
|
||||
multiplayer.token.expired=HiPer 證書過期,請重新啟動 HMCL 再試。
|
||||
multiplayer.token.malformed=HiPer 配置檔案無法解析,請重新啟動 HMCL 再試。
|
||||
multiplayer.token.prompt=你需要憑證才能使用多人聯機服務。點擊旁邊的“申請憑證”查看詳情
|
||||
multiplayer.off=啟動 HiPer
|
||||
multiplayer.off.hint=多人聯機服務由 HiPer 提供,你需要先使用管理員權限啟動 HMCL(具體操作方法詳見左側的 幫助),然後輸入憑證啟動 HiPer 以使用多人聯機服務。
|
||||
multiplayer.off.start=啟動 HiPer
|
||||
multiplayer.on=HiPer
|
||||
multiplayer.master=创建方提示
|
||||
multiplayer.master.hint=1.啟動遊戲\n2.選擇單人遊戲模式進入一個存檔\n3.在遊戲選項菜單內選擇 對局域網開放 選項,之後你可以在遊戲聊天框內看到遊戲提示的端口號(通常是 5 位數字)\n4.點擊下方的生成伺服器地址按鈕,輸入端口號,你就可以獲得你的伺服器地址了\n5.該地址需要提供給其他需要加入伺服器的玩家用於添加伺服器。
|
||||
multiplayer.master.server_address=生成伺服器地址
|
||||
multiplayer.master.server_address.copy=拷貝
|
||||
multiplayer.master.video_tutorial=教學影片
|
||||
multiplayer.master.port=埠號
|
||||
multiplayer.master.port.validate=在遊戲聊天框中出現的埠號 (0~65535)
|
||||
multiplayer.slave=參與者提示
|
||||
multiplayer.slave.hint=1.要求創建方按照上方的 創建方提示 操作\n2.啟動遊戲\n3.選擇多人遊戲模式,選擇添加伺服器\n4.遊戲會要求你輸入伺服器地址,你只需要向創建方索要伺服器地址並輸入,並進入伺服器即可。 \n- 注意:\n1.一般情況下,參與者的遊戲賬戶必須是 微軟賬戶 或 外置登錄賬戶(如 Little Skin),否則你需要將伺服器地址輸入至下方的輸入框中並點擊廣播,在遊戲中選擇多人遊戲模式,進入局域網世界方可加入,具體操作方法詳見左側的 幫助 \n2.一般情況下,參與者的遊戲版本、模組要必須與創建方的一致,否則加入失敗。
|
||||
multiplayer.slave.server_address=創建方服務器地址
|
||||
multiplayer.slave.server_address.start=加入
|
||||
multiplayer.slave.server_address.stop=退出
|
||||
multiplayer.slave.video_tutorial=教學影片
|
||||
multiplayer.session.expiration=本次使用截止時間
|
||||
|
||||
datapack=資料包
|
||||
datapack.add=加入資料包
|
||||
@@ -799,7 +763,7 @@ world.info.generate_features=生成建築
|
||||
world.info.player=玩家資訊
|
||||
world.info.player.food_level=饑餓值
|
||||
world.info.player.game_type=遊戲模式
|
||||
world.info.player.game_type.adventure=極限
|
||||
world.info.player.game_type.adventure=冒險
|
||||
world.info.player.game_type.creative=創造
|
||||
world.info.player.game_type.spectator=旁觀
|
||||
world.info.player.game_type.survival=生存
|
||||
@@ -996,13 +960,13 @@ update.changelog=更新日誌
|
||||
update.channel.dev=測試版
|
||||
update.channel.dev.hint=你正在使用測試版。測試版包含一些未在正式版中包含的測試性功能,僅用於體驗新功能。\n\
|
||||
測試版功能未受充分驗證,使用起來可能不穩定!\n\
|
||||
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK</a>群以回饋問題。\n\n\
|
||||
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>以回饋問題。\n\n\
|
||||
你需要 <a href="https://hmcl.huangyuhui.net/api/redirect/sponsor">贊助</a> 或在回饋過程中作出貢獻以隱藏本提示。
|
||||
update.channel.dev.title=測試版提示
|
||||
update.channel.nightly=預覽版
|
||||
update.channel.nightly.hint=你正在使用預覽版。預覽版可能會每天更新一次,包含一些未在正式版和測試版中包含的測試性功能,僅用於體驗新功能\n\
|
||||
測試版功能未受充分驗證,使用起來可能不穩定!\n\
|
||||
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK</a>群以回饋問題。\n\n\
|
||||
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>以回饋問題。\n\n\
|
||||
你需要 <a href="https://hmcl.huangyuhui.net/api/redirect/sponsor">贊助</a> 或在回饋過程中作出貢獻以隱藏本提示。
|
||||
update.channel.nightly.title=預覽版提示
|
||||
update.channel.stable=建議版本
|
||||
|
||||
@@ -60,7 +60,7 @@ account.create.yggdrasil=添加 Mojang 帐户
|
||||
account.create.offline=添加离线模式帐户
|
||||
account.create.authlibInjector=添加外置登录帐户 (authlib-injector)
|
||||
account.email=邮箱
|
||||
account.failed=错误码: %d
|
||||
account.failed=登录失败
|
||||
account.failed.character_deleted=此角色已被删除
|
||||
account.failed.connect_authentication_server=无法连接认证服务器,可能是网络问题,请检查设备能否正常上网或使用代理服务
|
||||
account.failed.connect_injector_server=无法连接认证服务器,可能是网络问题,请检查设备是否能正常上网,检查 URL 是否输入错误,或使用代理服务
|
||||
@@ -70,6 +70,7 @@ account.failed.invalid_password=无效的密码
|
||||
account.failed.invalid_token=请尝试登出并重新输入密码登录
|
||||
account.failed.migration=你的帐号需要被迁移至微软帐号。如果你已经迁移,你需要使用微软登录方式登录迁移后的微软帐号。
|
||||
account.failed.no_character=该帐号没有角色
|
||||
account.failed.server_disconnected=无法访问登录服务器。是否以离线模式继续登录(进入游戏后无法进入有正版验证的服务器)?或尝试重新登录?
|
||||
account.failed.server_response_malformed=无法解析认证服务器响应,可能是服务器故障
|
||||
account.failed.wrong_account=登录了错误的帐号
|
||||
account.hmcl.hint=你需要点击“登录”按钮,并在打开的网页中完成登录
|
||||
@@ -83,7 +84,10 @@ account.injector.server_url=服务器地址
|
||||
account.injector.server_name=服务器名称
|
||||
account.login=登录
|
||||
account.login.hint=我们不会保存你的密码
|
||||
account.login.offline=以离线模式登录
|
||||
account.login.refresh=重新登录
|
||||
account.login.refresh.microsoft.hint=<b>因为账户授权失效,需要重新添加你的微软账户</b>\n你需要按照以下步骤添加:\n \n1.点击“确定”按钮\n2.在网页浏览器显示的网站中输入 HMCL 显示的代码(自动拷贝,直接粘贴即可),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n \n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
|
||||
account.login.refresh.microsoft.manual=<b>因为账户授权失效,需要重新添加你的微软账户</b>\n你需要按照以下步骤添加:\n \n1.点击“确定”按钮\n2.在网页浏览器显示的网站中输入:<b>%1$s</b>(已自动拷贝,点此再次拷贝),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n \n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n-若网站未能显示,请手动在网页浏览器中打开:%2$s\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
|
||||
account.logout=登出
|
||||
account.register=注册
|
||||
account.manage=帐户列表
|
||||
@@ -98,9 +102,10 @@ account.methods.microsoft.error.add_family=由于你未满 18 岁,你的帐户
|
||||
account.methods.microsoft.error.add_family_probably=请检查你的帐户设置,如果年龄未满 18 岁,你的帐户必须被加入到家庭中才能登录游戏。你也可以点击上方链接更改你的帐户的出生日期,使年龄满 18 岁以上以继续登录。
|
||||
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 XBox Live 的支持。
|
||||
account.methods.microsoft.error.missing_xbox_account=你的微软帐户尚未关联 XBox 帐户,你必须先创建 XBox 帐户,才能登录游戏。
|
||||
account.methods.microsoft.error.no_character=该帐户没有包含 Minecraft Java 版购买记录
|
||||
account.methods.microsoft.error.no_character=该帐户未包含 Minecraft Java 版购买记录\n可能未创建游戏档案,请点击上方链接创建
|
||||
account.methods.microsoft.error.unknown=登录失败,错误码:%d
|
||||
account.methods.microsoft.logging_in=登录中……
|
||||
account.methods.microsoft.makegameidsettings=创建档案
|
||||
account.methods.microsoft.hint=你需要按照以下步骤添加:\n1.点击“登录”按钮\n2.在网页浏览器显示的网站中输入 HMCL 显示的代码(自动拷贝,直接粘贴即可),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
|
||||
account.methods.microsoft.manual=你需要按照以下步骤添加:\n1.点击“登录”按钮\n2.在网页浏览器显示的网站中输入:<b>%1$s</b>(已自动拷贝,点此再次拷贝),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可\n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n-若网站未能显示,请手动在网页浏览器中打开:%2$s\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
|
||||
account.methods.microsoft.profile=帐户设置页
|
||||
@@ -157,6 +162,7 @@ button.ok=确定
|
||||
button.refresh=刷新
|
||||
button.remove=删除
|
||||
button.remove.confirm=您确定要删除吗?此操作无法撤销!
|
||||
button.retry=重试
|
||||
button.save=保存
|
||||
button.save_as=另存为
|
||||
button.select_all=全选
|
||||
@@ -327,7 +333,9 @@ fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即将完成升
|
||||
fatal.apply_update_failure=我们很抱歉 Hello Minecraft! Launcher 无法自动完成升级,因为出现了一些问题……\n但你依可以从 %s 处手动下载 Hello Minecraft! Launcher 来完成升级\n请考虑向我们反馈该问题!
|
||||
fatal.samba=如果您正在通过 Samba 共享的文件夹中运行 HMCL,HMCL 可能无法正常工作,请尝试更新您的 Java 或在本地文件夹内运行 HMCL。
|
||||
fatal.illegal_char=您的用户文件夹路径中存在非法字符‘=’。HMCL 能够运行,但可能某些功能无法正常使用!\n你将无法使用外置登录账户以及离线登录更换皮肤功能,并且在进行多人游戏时可能存在更大的安全隐患!
|
||||
fatal.unsupported_platform=Minecraft 尚未为您的平台提供完善支持,所以可能影响游戏体验或无法启动游戏。\n若无法启动 Minecraft 1.17 及以上版本,可以尝试在版本设置中打开“使用 OpenGL 软渲染器”选项,\n使用 CPU 渲染以获得更好的兼容性。
|
||||
fatal.unsupported_platform=Minecraft 尚未为您的平台提供完善支持,所以可能影响游戏体验或无法启动游戏。\n若无法启动 Minecraft 1.17 及以上版本,可以尝试在版本设置中打开“使用 OpenGL 软渲染器”选项,使用 CPU 渲染以获得更好的兼容性。
|
||||
fatal.unsupported_platform.osx_arm64=HMCL 已为 Apple Silicon 平台提供支持,使用 ARM 原生 Java 启动游戏以获得更流畅的游戏体验。\n如果你在游戏中遭遇问题,使用 x86-64 架构的 Java 启动游戏可能有更好的兼容性。
|
||||
fatal.unsupported_platform.windows_arm64=HMCL 已为 Windows on Arm 平台提供原生支持。如果你在游戏中遭遇问题,请尝试使用 x86 架构的 Java 启动游戏。\n\n如果你正在使用<b>高通</b>平台,你可能需要安装 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 兼容包</a>后才能进行游戏。点击链接前往 Microsoft Store 安装兼容包。
|
||||
|
||||
feedback=反馈
|
||||
feedback.add=新增反馈
|
||||
@@ -366,11 +374,11 @@ folder.saves=存档文件夹
|
||||
folder.screenshots=截图文件夹
|
||||
|
||||
game=游戏
|
||||
game.crash.feedback=查看了日志发现该问题很常见?在反馈页面或 Github Issues 提交反馈,可丰富崩溃分析系统!
|
||||
game.crash.feedback=查看了日志发现该问题很常见?在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>提交反馈,以丰富崩溃分析系统!
|
||||
game.crash.info=游戏信息
|
||||
game.crash.reason=崩溃原因
|
||||
game.crash.reason.analyzing=分析中……
|
||||
game.crash.reason.block=当前游戏因为某个方块不能正常工作,无法继续运行。\n你可以尝试通过 MCEdit(podshot.github.io/MCEdit-Unified)工具编辑存档删除该方块,或者直接删除相应的 Mod。\n方块类型:%1$s\n方块坐标:%2$s
|
||||
game.crash.reason.block=当前游戏因为某个方块不能正常工作,无法继续运行。\n你可以尝试通过 <a href="https://podshot.github.io/MCEdit-Unified">MCEdit</a> 工具编辑存档删除该方块,或者直接删除相应的 Mod。\n方块类型:%1$s\n方块坐标:%2$s
|
||||
game.crash.reason.bootstrap_failed=当前游戏因为模组 %1$s 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
|
||||
game.crash.reason.config=当前游戏因为无法解析模组配置文件,无法继续运行\n模组 %1$s 的配置文件 %2$s 无法被解析。
|
||||
game.crash.reason.debug_crash=当前游戏因为手动触发崩溃,无法继续运行。\n事实上游戏并没有问题,问题都是你造成的!
|
||||
@@ -385,10 +393,10 @@ game.crash.reason.graphics_driver=当前游戏因为你的显卡驱动存在问
|
||||
如果你的电脑存在独立显卡,你需要检查游戏是否使用集成/核芯显卡启动。如果是,请尝试使用独立显卡启动 HMCL 与游戏。如果仍有问题,你可能需要考虑换一个新显卡或新电脑……\n\
|
||||
如果你确实需要使用核芯显卡,请检查你的电脑的 CPU 是否是 Intel(R) Core(TM) 3000 系列或更旧的处理器,如果是,对于 Minecraft 1.16.5 及更旧版本,请你将游戏所使用的 Java 版本降级至 1.8.0_51 及以下版本 <a href="https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html">Java 1.8.0 历史版本</a> ,否则你需要更换独立显卡或新电脑。\n\
|
||||
在版本设置中打开“使用 OpenGL 软渲染器”选项也可以解决此问题,但打开此选项后在 CPU 性能不足的情况下帧数会显著降低,仅推荐以调试为目的或应急时开启。\n\
|
||||
详情请点击 <a href="https://hmcl.huangyuhui.net/help/game/graphics-card">此处</a> 查看
|
||||
可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
|
||||
game.crash.reason.illegal_access_error=当前游戏因为某些 Mod 的问题,无法继续运行。\n如果你认识:%1$s,你可以更新或删除对应 Mod 再试……
|
||||
game.crash.reason.jdk_9=当前游戏因为 Java 版本过高,无法继续运行。\n你需要下载安装 Java 8,并在游戏设置中将 Java 设置为 1.8 的版本。
|
||||
game.crash.reason.jvm_32bit=当前游戏因为内存分配过大,超过了 32 位 Java 内存限制,无法继续运行。\n如果你的电脑是 64 位系统,请下载安装并更换 64 位 Java。如果你的电脑是 32 位系统,你或许可以重新安装 64 位系统,或换一台新电脑。\n或者,你可以关闭游戏内存的自动分配,并且把内存限制调节为 1024 MB 或以下。
|
||||
game.crash.reason.jvm_32bit=当前游戏因为内存分配过大,超过了 32 位 Java 内存限制,无法继续运行。\n如果你的电脑是 64 位系统,请下载安装并更换 64 位 Java。如果你的电脑是 32 位系统,你或许可以重新安装 64 位系统,或换一台新电脑。\n或者,你可以关闭游戏内存的自动分配,并且把内存限制调节为 1024 MB 或以下。可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
|
||||
game.crash.reason.loading_crashed_forge=当前游戏因为模组 %1$s (%2$s) 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
|
||||
game.crash.reason.loading_crashed_fabric=当前游戏因为模组 %1$s 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
|
||||
game.crash.reason.memory_exceeded=当前游戏因为分配的内存过大,无法继续运行。\n该问题是由于系统页面文件太小导致的。\n你需要在游戏设置中关闭游戏内存的自动分配,并将游戏内存调低至游戏能正常启动为止。\n你还可以尝试调大系统的页面大小。
|
||||
@@ -400,7 +408,7 @@ game.crash.reason.mod_resolution_missing=当前游戏因为缺少 Mod 前置,
|
||||
game.crash.reason.mod_resolution_missing_minecraft=当前游戏因为 Mod 和 Minecraft 游戏版本不匹配,无法继续运行。\n%1$s 需要 Minecraft %2$s 才能运行。\n如果你要继续使用你已经安装的 Mod,你可以选择安装对应的 Minecraft 版本;如果你要继续使用当前 Minecraft 版本,你需要安装对应版本的 Mod。
|
||||
game.crash.reason.mod_resolution_mod_version=%1$s (版本号 %2$s)
|
||||
game.crash.reason.mod_resolution_mod_version.any=%1$s (任意版本)
|
||||
game.crash.reason.modlauncher_8=当前游戏因为您所使用的 Forge 版本与当前使用的 Java 冲突崩溃。请尝试更新 Forge,或使用 Java 8u312/11.0.13/17.0.1 及更早版本启动\n8u312 及更早版本下载:\noracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html\n11.0.13 及更早版本下载:\noracle.com/java/technologies/javase/jdk11-archive-downloads.html\n17.0.1 及更早版本下载:\noracle.com/java/technologies/javase/jdk17-archive-downloads.html
|
||||
game.crash.reason.modlauncher_8=当前游戏因为您所使用的 Forge 版本与当前使用的 Java 冲突崩溃。请尝试更新 Forge,或使用 Java 8u312/11.0.13/17.0.1 及更早版本启动\n <a href="https://oracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html">8u312 及更早版本下载</a> <a href="https://oracle.com/java/technologies/javase/jdk11-archive-downloads.html">11.0.13 及更早版本下载</a> <a href="https://oracle.com/java/technologies/javase/jdk17-archive-downloads.html">17.0.1 及更早版本下载</a>\n可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
|
||||
game.crash.reason.no_class_def_found_error=当前游戏因为代码不完整,无法继续运行。\n你的游戏可能缺失了某个 Mod,或者某些 Mod 文件不完整,或者 Mod 与游戏的版本不匹配。\n你可能需要重新安装游戏和 Mod,或请求他人帮助。\n缺失:%1$s
|
||||
game.crash.reason.no_such_method_error=当前游戏因为代码不完整,无法继续运行。\n你的游戏可能缺失了某个 Mod,或者某些 Mod 文件不完整,或者 Mod 与游戏的版本不匹配。\n你可能需要重新安装游戏和 Mod,或请求他人帮助。
|
||||
game.crash.reason.opengl_not_supported=当前游戏因为你的显卡驱动存在问题,无法继续运行。\n原因是 OpenGL 不受支持,你现在是否在远程桌面或者串流模式下?如果是,请直接使用原电脑启动游戏。\n或者尝试升级你的显卡驱动到最新版本后再尝试启动游戏。如果你的电脑存在独立显卡,你需要检查游戏是否使用集成/核心显卡启动,如果是,请尝试使用独立显卡启动 HMCL 与游戏。如果仍有问题,你可能需要考虑换一个新显卡或新电脑。
|
||||
@@ -409,7 +417,7 @@ game.crash.reason.out_of_memory=当前游戏因为内存不足,无法继续运
|
||||
game.crash.reason.resolution_too_high=当前游戏因为材质包分辨率过高,无法继续运行\n你可以更换一个分辨率更低的材质,或者更换一个显存更大的显卡。
|
||||
game.crash.reason.stacktrace=原因未知,请点击日志按钮查看详细信息。\n下面是一些关键词,其中可能包含 Mod 名称,你可以通过搜索的方式查找有关信息。\n%s
|
||||
game.crash.reason.too_old_java=当前游戏因为 Java 虚拟机版本过低,无法继续运行。\n你需要在游戏设置中更换 Java %1$s 或更新版本的 Java 虚拟机,并重新启动游戏。如果没有下载安装,你可以点击 <a href="https://docs.microsoft.com/java/openjdk/download">此处</a> 下载微软 JDK。
|
||||
game.crash.reason.unknown=原因未知,请点击日志按钮查看详细信息!\n<b>!!!请不要将本界面[截图]给他人!!!</b>如果你要求助他人,请你点击左下角导出游戏崩溃信息后将导出的文件发送给他人以供分析!!!\n查看了日志发现该问题很常见?你可以向我们提供游戏崩溃日志在反馈页面或 Github Issues 提交反馈以提升 HMCL 的日志分析能力
|
||||
game.crash.reason.unknown=原因未知,请点击日志按钮查看详细信息!\n<b>!!!请不要将本界面【截图】给他人!!!</b>如果你要求助他人,请你点击左下角导出游戏崩溃信息后将导出的文件发送给他人以供分析!!!
|
||||
game.crash.reason.unsatisfied_link_error=当前游戏因为缺少本地库,无法继续运行。\n这些本地库缺失:%1$s。\n如果你在游戏设置中修改了本地库路径选项,请你修改回预设模式。\n如果已经在预设模式下,请检查本地库缺失是否是 Mod 引起的,或由 HMCL 引起的。如果你确定是 HMCL 引起的,建议你向我们反馈。\n如果你确实需要自定义本地库路径,你需要保证其中包含缺失的本地库!
|
||||
game.crash.title=游戏意外退出
|
||||
game.directory=游戏路径
|
||||
@@ -582,7 +590,7 @@ modpack.files.scripts=MineTweaker 配置
|
||||
modpack.files.servers_dat=多人游戏服务器列表
|
||||
modpack.install=安装 %s 整合包
|
||||
modpack.installing=正在安装整合包
|
||||
modpack.introduction=支持 Curse、MultiMC、HMCL 整合包。
|
||||
modpack.introduction=支持 Curse、Modrinth、MultiMC、MCBBS 整合包。
|
||||
modpack.invalid=无效的整合包升级文件,可能是下载时出现问题……
|
||||
modpack.mismatched_type=整合包类型不匹配,当前游戏是 %s 整合包,但是提供的整合包更新文件是 %s 整合包。
|
||||
modpack.name=整合包名称
|
||||
@@ -593,7 +601,7 @@ modpack.origin.mcbbs=MCBBS
|
||||
modpack.origin.mcbbs.prompt=贴子 id
|
||||
modpack.scan=解析整合包
|
||||
modpack.task.install=导入整合包
|
||||
modpack.task.install.error=无法识别该整合包,目前仅支持导入 Curse、Modrinth、MultiMC、HMCL 整合包。
|
||||
modpack.task.install.error=无法识别该整合包,目前仅支持导入 Curse、Modrinth、MultiMC、MCBBS 整合包。
|
||||
modpack.task.install.will=将会安装整合包:
|
||||
modpack.type.curse=Curse
|
||||
modpack.type.curse.tolerable_error=但未能完成该整合包所需的依赖下载,您可以在启动该游戏版本时继续该整合包文件的下载。由于网络问题,您可能需要重试多次……
|
||||
@@ -608,7 +616,7 @@ modpack.type.multimc.export=可以被 Hello Minecraft! Launcher (HMCL) 和 Multi
|
||||
modpack.type.server=服务器自动更新整合包
|
||||
modpack.type.server.export=允许服务器管理员远程更新游戏客户端
|
||||
modpack.type.server.malformed=服务器整合包配置格式错误,请联系服务器管理员解决此问题
|
||||
modpack.unsupported=该整合包不被支持。仅 HMCL、MultiMC、Curse 整合包受支持!
|
||||
modpack.unsupported=该整合包不被支持。仅 Curse、Modrinth、MultiMC、MCBBS 整合包受支持!
|
||||
modpack.update=正在升级整合包
|
||||
modpack.wizard=导出整合包向导
|
||||
modpack.wizard.step.1=基本设置
|
||||
@@ -680,87 +688,43 @@ mods.restore=回退
|
||||
mods.url=官方页面
|
||||
|
||||
multiplayer=多人联机
|
||||
multiplayer.agreement.prompt=使用多人联机功能前,你需要先同意多人联机服务提供方 ioi 系列作品的用户协议与免责声明。\n用户使用联机服务的行为,将被视为无条件同意该用户协议与免责声明。
|
||||
multiplayer.agreement.prompt=多人联机功能由 速聚 提供。使用前,你需要先同意多人联机服务提供方 速聚 的用户协议与免责声明。\n你需要了解,HMCL 仅为 速聚 提供多人联机服务入口,使用中遇到的任何问题由 速聚 负责处理。您在使用多人联机服务过程中所遇到的任何问题与纠纷(包括其付费业务)均与 HMCL 无关,应与 速聚 协商解决。
|
||||
multiplayer.download=正在下载依赖
|
||||
multiplayer.download.failed=初始化失败,部分文件未能完成下载
|
||||
multiplayer.download.success=多人联机初始化完成
|
||||
multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台
|
||||
multiplayer.exit.after_ready=多人联机会话意外退出,退出码 %d
|
||||
multiplayer.exit.before_ready=多人联机房间创建失败,cato 退出码 %d
|
||||
multiplayer.exit.timeout.static_token=无法连接多人联机服务,你的凭证可能无法正常工作,你可以清空凭证再试。\n你可以联系在线管理员询问鉴权服务器是否正常工作,或者耐心等待十分钟。
|
||||
multiplayer.exit.timeout.dynamic_token=无法连接多人联机服务,可能是鉴权服务器被人占满,请等待每个半点!鉴权服务器会自动刷新一次\n没有耐心且有闲钱的可以考虑购买联机凭证,不受鉴权服务器影响
|
||||
multiplayer.error.failed_get_device=HiPer 无法创建网络设备,可能是HiPer已经启动或缺少管理员权限。
|
||||
multiplayer.error.failed_load_config=HiPer 初始化失败,可能是配置文件存在问题或证书已失效。
|
||||
multiplayer.error.file_not_found=找不到 HiPer 程序。该程序应该在进入多人联机页面时完成下载。请重启 HMCL 再试。\n请检查你电脑的杀毒软件是否将 HiPer 标记为病毒,如果是,请恢复 HiPer。
|
||||
multiplayer.error.session_expired=本次使用时间已结束,若要继续使用,请更新凭证
|
||||
multiplayer.exit=HiPer 意外退出,退出码 %d
|
||||
multiplayer.hint=多人联机功能处于实验阶段,如果有问题请前往 mcer.cn 反馈
|
||||
multiplayer.nat=网络检测
|
||||
multiplayer.nat.failed=检测失败
|
||||
multiplayer.nat.hint=执行网络检测可以让你更清楚你的网络状况是否符合联机功能的需求\n若检测结果为 差 或 检测失败 的网络可能导致联机失败!
|
||||
multiplayer.nat.latency=延迟
|
||||
multiplayer.nat.not_yet_tested=尚未检测
|
||||
multiplayer.nat.packet_loss_ratio=丢包率
|
||||
multiplayer.nat.testing=检测中
|
||||
multiplayer.nat.type=NAT 类型
|
||||
multiplayer.nat.type.blocked_udp=极差(网络禁止 UDP 协议)
|
||||
multiplayer.nat.type.full_cone=好(完全圆锥型)
|
||||
multiplayer.nat.type.open_access=好(公网开放型)
|
||||
multiplayer.nat.type.port_restricted_cone=中(端口受限圆锥型)
|
||||
multiplayer.nat.type.restricted_cone=中(受限圆锥型)
|
||||
multiplayer.nat.type.symmetric=差(对称型)
|
||||
multiplayer.nat.type.symmetric_udp_firewall=差(对称型+防火墙)
|
||||
multiplayer.nat.type.unknown=未知
|
||||
multiplayer.powered_by=多人联机服务由 (<a href="https://mcer.cn">mcer.cn</a>) 提供 <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用户协议与免责声明</a>
|
||||
multiplayer.relay=桥接服务
|
||||
multiplayer.relay.hint=Cato 面向较差网络提供的联机质量优化解决方案|无法联机时可在 mcer.cn 兑换凭证后启用
|
||||
multiplayer.powered_by=本服务由 (<a href="https://mcer.cn">速聚</a>) 授权提供 <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">授权协议</a>
|
||||
multiplayer.report=违法违规举报
|
||||
multiplayer.session=房间
|
||||
multiplayer.session.name.format=%1$s 的房间
|
||||
multiplayer.session.name.motd=HMCL 多人联机房间 - %s
|
||||
multiplayer.session.close=关闭房间
|
||||
multiplayer.session.close.warning=关闭房间后,已经加入联机房间的玩家将会断开连接,是否继续?
|
||||
multiplayer.session.copy_room_code=拷贝邀请码
|
||||
multiplayer.session.create=创建房间
|
||||
multiplayer.session.create.error.dynamic_token=创建联机房间失败,可能是服务器出现问题,请稍后再试……
|
||||
multiplayer.session.create.error.static_token=创建联机房间失败,你的凭证可能无法正常工作,你可以清空凭证再试……\n你可以联系在线管理员询问鉴权服务器是否正常工作,或者耐心等待十分钟。
|
||||
multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字)
|
||||
multiplayer.session.create.join=连接申请
|
||||
multiplayer.session.create.join.allow=自动接受所有连接申请(不启用时,需要手动同意申请,以免无关人士误连服务器)
|
||||
multiplayer.session.create.join.prompt=玩家 %s 申请加入多人联机房间,是否接受?
|
||||
multiplayer.session.create.members=成员
|
||||
multiplayer.session.create.members.kick=踢出房间
|
||||
multiplayer.session.create.members.kick.prompt=是否踢出该玩家?踢出后该玩家不能再参与该联机房间。
|
||||
multiplayer.session.create.name=房间名称
|
||||
multiplayer.session.create.port=端口号
|
||||
multiplayer.session.create.port.error=无法检测游戏端口号,你须启动游戏并在游戏内 打开对局域网 开放选项方能启动联机!
|
||||
multiplayer.session.create.token=凭证
|
||||
multiplayer.session.create.token.apply=申请凭证
|
||||
multiplayer.session.create.token.prompt=默认为无凭证。你可以点击旁边的“申请凭证”获得详情
|
||||
multiplayer.session.error.already_started=本地已经开启 cato 服务,请检查是否有其他 HMCL 正在运行联机服务。或者你可以在任务管理器里杀掉 cato 进程以继续。
|
||||
multiplayer.session.error.file_not_found=找不到 cato 程序。该程序应该在进入多人联机页面时完成下载。请重启HMCL再试。\n请检查你电脑的杀毒软件是否将 cato 标记为病毒,如果是,请恢复 cato。
|
||||
multiplayer.session.expired=联机会话连续使用时间超过了 3 小时,你需要重新创建/加入房间以继续联机!
|
||||
multiplayer.session.join=加入房间
|
||||
multiplayer.session.join.error=加入房间失败。若你或对方网络类型是 差(对称型)或 检测失败,可能无法使用联机功能,你可以使用凭证开启桥接模式后再试……
|
||||
multiplayer.session.join.error.connection=加入房间失败。无法与对方建立连接。请检查你和对方的HMCL版本是否一致。\n如果你或对方的网络类型是差(对称型),可能无法使用联机功能。
|
||||
multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要邀请码以便加入多人联机房间
|
||||
multiplayer.session.join.invitation_code=邀请码
|
||||
multiplayer.session.join.invitation_code.error=邀请码格式不合法!请向房主获取邀请码,且检查你和对方使用的 HMCL 版本是否一致!
|
||||
multiplayer.session.join.kicked=你与房间失去连接:%s。
|
||||
multiplayer.session.join.kicked.join_acceptance_timeout=对方未能即时同意你的加入申请
|
||||
multiplayer.session.join.kicked.kicked=你被房主踢出房间。
|
||||
multiplayer.session.join.kicked.version_not_matched=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。
|
||||
multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间(非桥接服务的房间每个半点服务器自动刷新),或者你无法连接至房间……
|
||||
multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限!
|
||||
multiplayer.session.join.rejected=你被房主拒绝连接,或房主忘记同意……
|
||||
multiplayer.session.join.wait=等待对方同意加入申请……
|
||||
multiplayer.session.members=房间成员
|
||||
multiplayer.session.quit=退出房间
|
||||
multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?
|
||||
multiplayer.session.username=用户名
|
||||
multiplayer.state.connecting=连接中
|
||||
multiplayer.state.disconnected=未创建/加入房间
|
||||
multiplayer.state.disconnected.hint=多人联机功能需要先有一位玩家创建房间后,其他玩家加入房间后继续游戏。
|
||||
multiplayer.state.master=你已创建房间:%1$s,端口号 %2$d
|
||||
multiplayer.state.master.hint=创建房间后,你可以点击拷贝邀请码,并发送给你希望加入联机的玩家。这些玩家在进入 HMCL 多人联机页面后,点击加入房间并粘贴邀请码即可加入联机房间。
|
||||
multiplayer.state.slave=你已加入房间: %1$s,地址为 %2$s
|
||||
multiplayer.state.slave.copy=拷贝地址
|
||||
multiplayer.state.slave.hint=加入房间后,你需要在 Minecraft 的多人游戏页面选择 HMCL 多人联机房间服务器,或者手动添加下方的地址的服务器。
|
||||
multiplayer.token=凭证
|
||||
multiplayer.token.apply=申请凭证
|
||||
multiplayer.token.expired=HiPer 证书过期,请重新启动 HMCL 再试。
|
||||
multiplayer.token.invalid=凭证不正确。
|
||||
multiplayer.token.malformed=HiPer 配置文件无法解析,请重新启动 HMCL 再试。
|
||||
multiplayer.token.prompt=你需要凭证才能使用多人联机服务。点击旁边的“申请凭证”查看详情
|
||||
multiplayer.off=启动 HiPer
|
||||
multiplayer.off.hint=多人联机服务由 HiPer 提供,你需要先使用管理员权限启动 HMCL(具体操作方法详见左侧的 帮助),然后输入凭证启动 HiPer 以使用多人联机服务。
|
||||
multiplayer.off.start=启动 HiPer
|
||||
multiplayer.on=HiPer
|
||||
multiplayer.master=创建方提示
|
||||
multiplayer.master.hint=1.启动游戏\n2.选择单人游戏模式进入一个存档\n3.在游戏选项菜单内选择 对局域网开放 选项,之后你可以在游戏聊天框内看到游戏提示的端口号(通常是 5 位数字)\n4.点击下方的生成服务器地址按钮,输入端口号,你就可以获得你的服务器地址了\n5.该地址需要提供给其他需要加入服务器的玩家用于添加服务器。
|
||||
multiplayer.master.server_address=生成服务器地址
|
||||
multiplayer.master.server_address.copy=拷贝
|
||||
multiplayer.master.video_tutorial=教学视频
|
||||
multiplayer.master.port=端口号
|
||||
multiplayer.master.port.validate=在游戏聊天框中出现的端口号 (0~65535)
|
||||
multiplayer.slave=参与者提示
|
||||
multiplayer.slave.hint=1.要求创建方按照上方的 创建方提示 操作\n2.启动游戏\n3.选择多人游戏模式,选择添加服务器\n4.游戏会要求你输入服务器地址,你只需要向创建方索要服务器地址并输入,并进入服务器即可。\n- 注意:\n1.一般情况下,参与者的游戏账户必须是 微软账户 或 外置登录账户(如 Little Skin),否则你需要将服务器地址输入至下方的输入框中并点击广播,在游戏中选择多人游戏模式,进入局域网世界方可加入,具体操作方法详见左侧的 帮助 \n2.一般情况下,参与者的游戏版本、模组要必须与创建方的一致,否则加入失败。
|
||||
multiplayer.slave.server_address=创建方服务器地址
|
||||
multiplayer.slave.server_address.start=加入
|
||||
multiplayer.slave.server_address.stop=退出
|
||||
multiplayer.slave.video_tutorial=视频教程
|
||||
multiplayer.session.expiration=本次使用截止时间
|
||||
|
||||
datapack=数据包
|
||||
datapack.add=添加数据包
|
||||
@@ -800,7 +764,7 @@ world.info.generate_features=生成建筑
|
||||
world.info.player=玩家信息
|
||||
world.info.player.food_level=饥饿值
|
||||
world.info.player.game_type=游戏模式
|
||||
world.info.player.game_type.adventure=极限
|
||||
world.info.player.game_type.adventure=冒险
|
||||
world.info.player.game_type.creative=创造
|
||||
world.info.player.game_type.spectator=旁观
|
||||
world.info.player.game_type.survival=生存
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 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.multiplayer;
|
||||
|
||||
import org.junit.Ignore;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class LocalServerBroadcastTest {
|
||||
|
||||
@Test
|
||||
@Ignore("for manually testing")
|
||||
public void test() {
|
||||
int port = 12345;
|
||||
DatagramSocket socket;
|
||||
InetAddress broadcastAddress;
|
||||
try {
|
||||
socket = new DatagramSocket();
|
||||
broadcastAddress = InetAddress.getByName("224.0.2.60");
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", "Test server"), port).getBytes(StandardCharsets.UTF_8);
|
||||
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
|
||||
socket.send(packet);
|
||||
System.out.println("Broadcast server 127.0.0.1:" + port);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException ignored) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore
|
||||
public void printLocalAddress() throws IOException {
|
||||
DatagramSocket socket = new DatagramSocket(new InetSocketAddress((InetAddress) null, 4444));
|
||||
System.out.println(socket.getLocalAddress());
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.Logging;
|
||||
import org.junit.Ignore;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.net.SocketException;
|
||||
|
||||
public class LocalServerDetectorTest {
|
||||
|
||||
@Test
|
||||
@Ignore("for manually testing")
|
||||
public void test() {
|
||||
try {
|
||||
for (NetworkInterface networkInterface : Lang.toIterable(NetworkInterface.getNetworkInterfaces())) {
|
||||
System.out.println(networkInterface.getName());
|
||||
for (InetAddress address : Lang.toIterable(networkInterface.getInetAddresses())) {
|
||||
System.out.println(address);
|
||||
}
|
||||
}
|
||||
} catch (SocketException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
Logging.initForTest();
|
||||
LocalServerDetector detector = new LocalServerDetector(3);
|
||||
detector.run();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.multiplayer;
|
||||
|
||||
import org.jackhuang.hmcl.util.Logging;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Ignore;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class MultiplayerClientServerTest {
|
||||
|
||||
@Test
|
||||
@Ignore
|
||||
public void startServer() throws Exception {
|
||||
Logging.initForTest();
|
||||
int localPort = MultiplayerManager.findAvailablePort();
|
||||
MultiplayerServer server = new MultiplayerServer("SessionName", 1000, true);
|
||||
server.startServer(localPort);
|
||||
|
||||
MultiplayerClient client = new MultiplayerClient("username", localPort);
|
||||
client.start();
|
||||
|
||||
AtomicBoolean handshakeReceived = new AtomicBoolean(false);
|
||||
|
||||
server.onHandshake().register(event -> {
|
||||
handshakeReceived.set(true);
|
||||
});
|
||||
|
||||
server.onKeepAlive().register(event -> {
|
||||
client.interrupt();
|
||||
server.interrupt();
|
||||
});
|
||||
|
||||
server.join();
|
||||
|
||||
Assert.assertTrue(handshakeReceived.get());
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public abstract class Account implements Observable {
|
||||
* Play offline.
|
||||
* @return the specific offline player's info.
|
||||
*/
|
||||
public abstract Optional<AuthInfo> playOffline() throws AuthenticationException;
|
||||
public abstract AuthInfo playOffline() throws AuthenticationException;
|
||||
|
||||
public abstract Map<Object, Object> toStorage();
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 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.auth;
|
||||
|
||||
public class NotLoggedInException extends AuthenticationException {
|
||||
}
|
||||
@@ -89,6 +89,7 @@ public class OAuth {
|
||||
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);
|
||||
handleErrorResponse(response);
|
||||
return new Result(response.accessToken, response.refreshToken);
|
||||
@@ -98,6 +99,7 @@ public class OAuth {
|
||||
DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL)
|
||||
.form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(DeviceTokenResponse.class);
|
||||
handleErrorResponse(deviceTokenResponse);
|
||||
|
||||
@@ -124,6 +126,7 @@ public class OAuth {
|
||||
pair("code", deviceTokenResponse.deviceCode),
|
||||
pair("client_id", options.callback.getClientId()))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(TokenResponse.class);
|
||||
|
||||
if ("authorization_pending".equals(tokenResponse.error)) {
|
||||
@@ -158,6 +161,7 @@ public class OAuth {
|
||||
.form(query)
|
||||
.accept("application/json")
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(RefreshResponse.class);
|
||||
|
||||
handleErrorResponse(response);
|
||||
@@ -177,7 +181,7 @@ public class OAuth {
|
||||
|
||||
switch (response.error) {
|
||||
case "invalid_grant":
|
||||
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
|
||||
if (response.errorDescription.contains("AADSTS70000")) {
|
||||
throw new CredentialExpiredException();
|
||||
}
|
||||
break;
|
||||
|
||||
+6
-9
@@ -17,10 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.auth.authlibinjector;
|
||||
|
||||
import org.jackhuang.hmcl.auth.AuthInfo;
|
||||
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||
import org.jackhuang.hmcl.auth.ServerDisconnectException;
|
||||
import org.jackhuang.hmcl.auth.*;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
|
||||
@@ -66,15 +63,15 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() {
|
||||
Optional<AuthInfo> auth = super.playOffline();
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
AuthInfo auth = super.playOffline();
|
||||
Optional<AuthlibInjectorArtifactInfo> artifact = downloader.getArtifactInfoImmediately();
|
||||
Optional<String> prefetchedMeta = server.getMetadataResponse();
|
||||
|
||||
if (auth.isPresent() && artifact.isPresent() && prefetchedMeta.isPresent()) {
|
||||
return Optional.of(new AuthlibInjectorAuthInfo(auth.get(), artifact.get(), server, prefetchedMeta.get()));
|
||||
if (artifact.isPresent() && prefetchedMeta.isPresent()) {
|
||||
return new AuthlibInjectorAuthInfo(auth, artifact.get(), server, prefetchedMeta.get());
|
||||
} else {
|
||||
return Optional.empty();
|
||||
throw new NotLoggedInException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ public class MicrosoftAccount extends OAuthAccount {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() {
|
||||
return Optional.of(session.toAuthInfo());
|
||||
public AuthInfo playOffline() {
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -119,6 +119,7 @@ public class MicrosoftService {
|
||||
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
|
||||
pair("RpsTicket", "d=" + liveAccessToken))),
|
||||
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
|
||||
.retry(5)
|
||||
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
|
||||
|
||||
String uhs = getUhs(xboxResponse, null);
|
||||
@@ -132,6 +133,7 @@ public class MicrosoftService {
|
||||
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
|
||||
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
|
||||
.ignoreHttpErrorCode(401)
|
||||
.retry(5)
|
||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
||||
|
||||
getUhs(minecraftXstsResponse, uhs);
|
||||
@@ -140,6 +142,7 @@ public class MicrosoftService {
|
||||
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
|
||||
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
|
||||
.retry(5)
|
||||
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
|
||||
|
||||
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
|
||||
|
||||
@@ -180,8 +180,8 @@ public class OfflineAccount extends Account {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() throws AuthenticationException {
|
||||
return Optional.of(logIn());
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
return logIn();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -161,8 +161,8 @@ public class YggdrasilAccount extends ClassicAccount {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() {
|
||||
return Optional.of(session.toAuthInfo());
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -274,5 +274,5 @@ public class YggdrasilService {
|
||||
|
||||
public static final String PROFILE_URL = "https://aka.ms/MinecraftMigration";
|
||||
public static final String MIGRATION_FAQ_URL = "https://help.minecraft.net/hc/en-us/articles/360050865492-JAVA-Account-Migration-FAQ";
|
||||
public static final String PURCHASE_URL = "https://www.minecraft.net/zh-hans/store/minecraft-java-edition";
|
||||
public static final String PURCHASE_URL = "https://www.minecraft.net/store/minecraft-java-bedrock-edition-pc";
|
||||
}
|
||||
@@ -66,6 +66,8 @@ public final class JavaRepository {
|
||||
}
|
||||
|
||||
public static Stream<Path> findJavaHomeInMinecraftRuntimeDir(Path runtimeDir) {
|
||||
if (!Files.isDirectory(runtimeDir))
|
||||
return Stream.empty();
|
||||
// Examples:
|
||||
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
|
||||
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
|
||||
|
||||
@@ -116,7 +116,7 @@ public final class QuiltInstallTask extends Task<Version> {
|
||||
}
|
||||
}
|
||||
|
||||
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.hashed.maven), getMavenRepositoryByGroup(quiltInfo.hashed.maven), null));
|
||||
// libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.hashed.maven), getMavenRepositoryByGroup(quiltInfo.hashed.maven), null));
|
||||
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.intermediary.maven), getMavenRepositoryByGroup(quiltInfo.intermediary.maven), null));
|
||||
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.loader.maven), getMavenRepositoryByGroup(quiltInfo.loader.maven), null));
|
||||
|
||||
|
||||
@@ -488,6 +488,7 @@ public class DefaultGameRepository implements GameRepository {
|
||||
}
|
||||
|
||||
public Task<Version> saveAsync(Version version) {
|
||||
this.gameVersions.remove(getVersionJar(version));
|
||||
if (version.isResolvedPreservingPatches()) {
|
||||
return new VersionJsonSaveTask(this, MaintainTask.maintainPreservingPatches(this, version));
|
||||
} else {
|
||||
|
||||
@@ -120,8 +120,13 @@ public enum JavaVersionConstraint {
|
||||
@Override
|
||||
protected boolean appliesToVersionImpl(VersionNumber gameVersionNumber, @Nullable Version version,
|
||||
@Nullable JavaVersion javaVersion) {
|
||||
return javaVersion != null && !javaVersion.getArchitecture().isX86()
|
||||
&& (OperatingSystem.CURRENT_OS != OperatingSystem.OSX || gameVersionNumber.compareTo(VersionNumber.asVersion("1.19")) < 0);
|
||||
if (javaVersion == null || javaVersion.getArchitecture() != Architecture.ARM64)
|
||||
return false;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
|
||||
return gameVersionNumber.compareTo(VersionNumber.asVersion("1.6")) < 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -237,15 +242,15 @@ public enum JavaVersionConstraint {
|
||||
public static JavaVersion findSuitableJavaVersion(VersionNumber gameVersion, Version version) throws InterruptedException {
|
||||
VersionRanges range = findSuitableJavaVersionRange(gameVersion, version);
|
||||
|
||||
boolean skipCheckArchitecture = Architecture.CURRENT_ARCH.isX86()
|
||||
|| (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && gameVersion.compareTo(VersionNumber.asVersion("1.19")) >= 0)
|
||||
|| OperatingSystem.CURRENT_OS == OperatingSystem.LINUX;
|
||||
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
|
||||
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
|
||||
&& gameVersion.compareTo(VersionNumber.asVersion("1.6")) < 0;
|
||||
|
||||
JavaVersion mandatory = null;
|
||||
JavaVersion suggested = null;
|
||||
for (JavaVersion javaVersion : JavaVersion.getJavas()) {
|
||||
// select the latest x86 java that this version accepts.
|
||||
if (!skipCheckArchitecture && !javaVersion.getArchitecture().isX86())
|
||||
if (forceX86 && !javaVersion.getArchitecture().isX86())
|
||||
continue;
|
||||
|
||||
VersionNumber javaVersionNumber = javaVersion.getVersionNumber();
|
||||
|
||||
@@ -27,11 +27,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.Unzipper;
|
||||
import org.jackhuang.hmcl.util.platform.CommandBuilder;
|
||||
import org.jackhuang.hmcl.util.platform.JavaVersion;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.Bits;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
|
||||
import java.io.*;
|
||||
@@ -112,17 +108,28 @@ public class DefaultLauncher extends Launcher {
|
||||
|
||||
res.addAllWithoutParsing(options.getJavaArguments());
|
||||
|
||||
Charset encoding = OperatingSystem.NATIVE_CHARSET;
|
||||
|
||||
// After Java 17, file.encoding does not affect console encoding
|
||||
if (options.getJava().getParsedVersion() <= JavaVersion.JAVA_17) {
|
||||
Charset encoding = StandardCharsets.UTF_8;
|
||||
if (options.getJava().getParsedVersion() < JavaVersion.JAVA_8) {
|
||||
try {
|
||||
String fileEncoding = res.addDefault("-Dfile.encoding=", encoding.name());
|
||||
if (fileEncoding != null)
|
||||
encoding = Charset.forName(fileEncoding.substring("-Dfile.encoding=".length()));
|
||||
} catch (Throwable ex) {
|
||||
encoding = OperatingSystem.NATIVE_CHARSET;
|
||||
LOG.log(Level.WARNING, "Bad file encoding", ex);
|
||||
}
|
||||
} else {
|
||||
res.addDefault("-Dfile.encoding=", "UTF-8");
|
||||
try {
|
||||
String stdoutEncoding = res.addDefault("-Dsun.stdout.encoding=", encoding.name());
|
||||
if (stdoutEncoding != null)
|
||||
encoding = Charset.forName(stdoutEncoding.substring("-Dsun.stdout.encoding=".length()));
|
||||
} catch (Throwable ex) {
|
||||
encoding = OperatingSystem.NATIVE_CHARSET;
|
||||
LOG.log(Level.WARNING, "Bad stdout encoding", ex);
|
||||
}
|
||||
|
||||
res.addDefault("-Dsun.stderr.encoding=", encoding.name());
|
||||
}
|
||||
|
||||
// JVM Args
|
||||
@@ -237,7 +244,7 @@ public class DefaultLauncher extends Launcher {
|
||||
// Here is a workaround for this issue: https://github.com/huanghongxun/HMCL/issues/1141.
|
||||
String nativeFolderPath = nativeFolder.getAbsolutePath();
|
||||
Path tempNativeFolder = null;
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX
|
||||
if ((OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
|
||||
&& !StringUtils.isASCII(nativeFolderPath)) {
|
||||
tempNativeFolder = Paths.get("/", "tmp", "hmcl-natives-" + UUID.randomUUID());
|
||||
nativeFolderPath = tempNativeFolder + File.pathSeparator + nativeFolderPath;
|
||||
@@ -448,7 +455,7 @@ public class DefaultLauncher extends Launcher {
|
||||
if (StringUtils.isNotBlank(options.getPreLaunchCommand())) {
|
||||
ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPreLaunchCommand())).directory(runDirectory);
|
||||
builder.environment().putAll(getEnvVars());
|
||||
builder.start().waitFor();
|
||||
SystemUtils.callExternalProcess(builder);
|
||||
}
|
||||
|
||||
Process process;
|
||||
@@ -530,9 +537,6 @@ public class DefaultLauncher extends Launcher {
|
||||
throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh' or 'ps1' in macOS/Linux");
|
||||
}
|
||||
|
||||
if (!FileUtils.makeFile(scriptFile))
|
||||
throw new IOException("Script file: " + scriptFile + " cannot be created.");
|
||||
|
||||
final Command commandLine = generateCommandLine(nativeFolder);
|
||||
final String command = usePowerShell ? null : commandLine.commandLine.toString();
|
||||
|
||||
@@ -542,6 +546,9 @@ public class DefaultLauncher extends Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
if (!FileUtils.makeFile(scriptFile))
|
||||
throw new IOException("Script file: " + scriptFile + " cannot be created.");
|
||||
|
||||
OutputStream outputStream = new FileOutputStream(scriptFile);
|
||||
Charset charset = StandardCharsets.UTF_8;
|
||||
|
||||
@@ -590,7 +597,7 @@ public class DefaultLauncher extends Launcher {
|
||||
writer.write("set APPDATA=" + options.getGameDir().getAbsoluteFile().getParent());
|
||||
writer.newLine();
|
||||
for (Map.Entry<String, String> entry : getEnvVars().entrySet()) {
|
||||
writer.write("set " + entry.getKey() + "=" + entry.getValue());
|
||||
writer.write("set " + entry.getKey() + "=" + CommandBuilder.toBatchStringLiteral(entry.getValue()));
|
||||
writer.newLine();
|
||||
}
|
||||
writer.newLine();
|
||||
@@ -599,7 +606,7 @@ public class DefaultLauncher extends Launcher {
|
||||
writer.write("#!/usr/bin/env bash");
|
||||
writer.newLine();
|
||||
for (Map.Entry<String, String> entry : getEnvVars().entrySet()) {
|
||||
writer.write("export " + entry.getKey() + "=" + entry.getValue());
|
||||
writer.write("export " + entry.getKey() + "=" + CommandBuilder.toShellStringLiteral(entry.getValue()));
|
||||
writer.newLine();
|
||||
}
|
||||
if (commandLine.tempNativeFolder != null) {
|
||||
|
||||
@@ -26,7 +26,9 @@ import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -58,38 +60,23 @@ final class ExitWaiter implements Runnable {
|
||||
for (Thread thread : joins)
|
||||
thread.join();
|
||||
|
||||
ProcessListener.ExitType exitType = null;
|
||||
for (String line : process.getLines()) {
|
||||
Log4jLevel level = Log4jLevel.guessLevel(line);
|
||||
if (Log4jLevel.isError(level)) {
|
||||
// LaunchWrapper will catch the exception logged and will exit normally.
|
||||
if (exitCode != 0 && StringUtils.containsOne(line,
|
||||
"Could not create the Java Virtual Machine.",
|
||||
"Error occurred during initialization of VM",
|
||||
"A fatal exception has occurred. Program will exit.")) {
|
||||
EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(this, process));
|
||||
exitType = ProcessListener.ExitType.JVM_ERROR;
|
||||
break;
|
||||
}
|
||||
List<String> errorLines = process.getLines().stream()
|
||||
.filter(Log4jLevel::guessLogLineError).collect(Collectors.toList());
|
||||
ProcessListener.ExitType exitType;
|
||||
|
||||
if (exitCode != 0 || StringUtils.containsOne(line, "Unable to launch")) {
|
||||
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
|
||||
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (level == Log4jLevel.WARN) {
|
||||
if (StringUtils.containsOne(line, "Failed to create window")) {
|
||||
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
|
||||
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exitType == null)
|
||||
// LaunchWrapper will catch the exception logged and will exit normally.
|
||||
if (exitCode != 0 && StringUtils.containsOne(errorLines,
|
||||
"Could not create the Java Virtual Machine.",
|
||||
"Error occurred during initialization of VM",
|
||||
"A fatal exception has occurred. Program will exit.")) {
|
||||
EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(this, process));
|
||||
exitType = ProcessListener.ExitType.JVM_ERROR;
|
||||
} else if (exitCode != 0 || StringUtils.containsOne(errorLines, "Unable to launch")) {
|
||||
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
|
||||
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
|
||||
} else {
|
||||
exitType = ProcessListener.ExitType.NORMAL;
|
||||
}
|
||||
|
||||
EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(this, process));
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ public class ModrinthCompletionTask extends Task<Void> {
|
||||
Path runDirectory = repository.getRunDirectory(version).toPath();
|
||||
|
||||
for (ModrinthManifest.File file : manifest.getFiles()) {
|
||||
if (file.getEnv() != null && file.getEnv().getOrDefault("client", "required").equals("unsupported"))
|
||||
continue;
|
||||
Path filePath = runDirectory.resolve(file.getPath());
|
||||
if (!Files.exists(filePath) && !file.getDownloads().isEmpty()) {
|
||||
FileDownloadTask task = new FileDownloadTask(file.getDownloads().get(0), filePath.toFile());
|
||||
|
||||
@@ -72,7 +72,8 @@ public class ModrinthInstallTask extends Task<Void> {
|
||||
builder.version("fabric", modLoader.getValue());
|
||||
break;
|
||||
case "quilt-loader":
|
||||
throw new IllegalStateException("Quilt Modloader is not supported");
|
||||
builder.version("quilt", modLoader.getValue());
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey());
|
||||
}
|
||||
|
||||
@@ -141,6 +141,21 @@ public final class FileUtils {
|
||||
writeText(file, text, UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write plain text to file. Characters are encoded into bytes using UTF-8.
|
||||
* <p>
|
||||
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
|
||||
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
|
||||
* All characters in text will be written into the file in binary format. Existing data will be erased.
|
||||
*
|
||||
* @param file the path to the file
|
||||
* @param text the text being written to file
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public static void writeText(Path file, String text) throws IOException {
|
||||
writeText(file, text, UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write plain text to file.
|
||||
* <p>
|
||||
@@ -157,18 +172,47 @@ public final class FileUtils {
|
||||
writeBytes(file, text.getBytes(charset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write plain text to file.
|
||||
* <p>
|
||||
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
|
||||
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
|
||||
* All characters in text will be written into the file in binary format. Existing data will be erased.
|
||||
*
|
||||
* @param file the path to the file
|
||||
* @param text the text being written to file
|
||||
* @param charset the charset to use for encoding
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public static void writeText(Path file, String text, Charset charset) throws IOException {
|
||||
writeBytes(file, text.getBytes(charset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write byte array to file.
|
||||
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
|
||||
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
|
||||
*
|
||||
* @param file the path to the file
|
||||
* @param array the data being written to file
|
||||
* @param data the data being written to file
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public static void writeBytes(File file, byte[] array) throws IOException {
|
||||
Files.createDirectories(file.toPath().getParent());
|
||||
Files.write(file.toPath(), array);
|
||||
public static void writeBytes(File file, byte[] data) throws IOException {
|
||||
writeBytes(file.toPath(), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write byte array to file.
|
||||
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
|
||||
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
|
||||
*
|
||||
* @param file the path to the file
|
||||
* @param data the data being written to file
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public static void writeBytes(Path file, byte[] data) throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, data);
|
||||
}
|
||||
|
||||
public static void deleteDirectory(File directory)
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.Pair;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -28,6 +29,7 @@ import java.io.OutputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
@@ -49,6 +51,7 @@ public abstract class HttpRequest {
|
||||
protected final Map<String, String> headers = new HashMap<>();
|
||||
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
|
||||
protected final Set<Integer> toleratedHttpCodes = new HashSet<>();
|
||||
protected int retryTimes = 1;
|
||||
protected boolean ignoreHttpCode;
|
||||
|
||||
private HttpRequest(String url, String method) {
|
||||
@@ -82,6 +85,14 @@ public abstract class HttpRequest {
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequest retry(int retryTimes) {
|
||||
if (retryTimes < 1) {
|
||||
throw new IllegalArgumentException("retryTimes >= 1");
|
||||
}
|
||||
this.retryTimes = retryTimes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public abstract String getString() throws IOException;
|
||||
|
||||
public CompletableFuture<String> getStringAsync() {
|
||||
@@ -129,9 +140,11 @@ public abstract class HttpRequest {
|
||||
}
|
||||
|
||||
public String getString() throws IOException {
|
||||
HttpURLConnection con = createConnection();
|
||||
con = resolveConnection(con);
|
||||
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
|
||||
return getStringWithRetry(() -> {
|
||||
HttpURLConnection con = createConnection();
|
||||
con = resolveConnection(con);
|
||||
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
|
||||
}, retryTimes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,25 +181,27 @@ public abstract class HttpRequest {
|
||||
}
|
||||
|
||||
public String getString() throws IOException {
|
||||
HttpURLConnection con = createConnection();
|
||||
con.setDoOutput(true);
|
||||
return getStringWithRetry(() -> {
|
||||
HttpURLConnection con = createConnection();
|
||||
con.setDoOutput(true);
|
||||
|
||||
try (OutputStream os = con.getOutputStream()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
try (OutputStream os = con.getOutputStream()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
|
||||
if (responseCodeTester != null) {
|
||||
responseCodeTester.accept(new URL(url), con.getResponseCode());
|
||||
} else {
|
||||
if (con.getResponseCode() / 100 != 2) {
|
||||
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
|
||||
String data = NetworkUtils.readData(con);
|
||||
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
|
||||
if (responseCodeTester != null) {
|
||||
responseCodeTester.accept(new URL(url), con.getResponseCode());
|
||||
} else {
|
||||
if (con.getResponseCode() / 100 != 2) {
|
||||
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
|
||||
String data = NetworkUtils.readData(con);
|
||||
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NetworkUtils.readData(con);
|
||||
return NetworkUtils.readData(con);
|
||||
}, retryTimes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +218,20 @@ public abstract class HttpRequest {
|
||||
return new HttpPostRequest(url);
|
||||
}
|
||||
|
||||
private static String getStringWithRetry(ExceptionalSupplier<String, IOException> supplier, int retryTimes) throws IOException {
|
||||
SocketTimeoutException exception = null;
|
||||
for (int i = 0; i < retryTimes; i++) {
|
||||
try {
|
||||
return supplier.get();
|
||||
} catch (SocketTimeoutException e) {
|
||||
exception = e;
|
||||
}
|
||||
}
|
||||
if (exception != null)
|
||||
throw exception;
|
||||
throw new IOException("retry 0");
|
||||
}
|
||||
|
||||
public interface Authorization {
|
||||
String getTokenType();
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ public final class CommandBuilder {
|
||||
|
||||
private String parse(String s) {
|
||||
if (OperatingSystem.WINDOWS == os) {
|
||||
return parseBatch(s);
|
||||
return toBatchStringLiteral(s);
|
||||
} else {
|
||||
return parseShell(s);
|
||||
return toShellStringLiteral(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ public final class CommandBuilder {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return parse ? (OperatingSystem.WINDOWS == OperatingSystem.CURRENT_OS ? parseBatch(arg) : parseShell(arg)) : arg;
|
||||
return parse ? (OperatingSystem.WINDOWS == OperatingSystem.CURRENT_OS ? toBatchStringLiteral(arg) : toShellStringLiteral(arg)) : arg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ public final class CommandBuilder {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String parseBatch(String s) {
|
||||
public static String toBatchStringLiteral(String s) {
|
||||
String escape = " \t\"^&<>|";
|
||||
if (StringUtils.containsOne(s, escape.toCharArray()))
|
||||
// The argument has not been quoted, add quotes.
|
||||
@@ -219,7 +219,7 @@ public final class CommandBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseShell(String s) {
|
||||
public static String toShellStringLiteral(String s) {
|
||||
String escaping = " \t\"!#$&'()*,;<=>?[\\]^`{|}~";
|
||||
String escaped = "\"$&`";
|
||||
if (s.indexOf(' ') >= 0 || s.indexOf('\t') >= 0 || StringUtils.containsOne(s, escaping.toCharArray())) {
|
||||
|
||||
@@ -6,27 +6,19 @@ public final class Platform {
|
||||
public static final Platform UNKNOWN = new Platform(OperatingSystem.UNKNOWN, Architecture.UNKNOWN);
|
||||
|
||||
public static final Platform WINDOWS_X86_64 = new Platform(OperatingSystem.WINDOWS, Architecture.X86_64);
|
||||
public static final Platform OSX_X86_64 = new Platform(OperatingSystem.OSX, Architecture.X86_64);
|
||||
public static final Platform LINUX_X86_64 = new Platform(OperatingSystem.LINUX, Architecture.X86_64);
|
||||
public static final Platform WINDOWS_ARM64 = new Platform(OperatingSystem.WINDOWS, Architecture.ARM64);
|
||||
|
||||
public static final Platform LINUX_X86_64 = new Platform(OperatingSystem.LINUX, Architecture.X86_64);
|
||||
public static final Platform LINUX_ARM64 = new Platform(OperatingSystem.LINUX, Architecture.ARM64);
|
||||
|
||||
public static final Platform OSX_X86_64 = new Platform(OperatingSystem.OSX, Architecture.X86_64);
|
||||
public static final Platform OSX_ARM64 = new Platform(OperatingSystem.OSX, Architecture.ARM64);
|
||||
|
||||
public static final Platform CURRENT_PLATFORM = Platform.getPlatform(OperatingSystem.CURRENT_OS, Architecture.CURRENT_ARCH);
|
||||
public static final Platform SYSTEM_PLATFORM = Platform.getPlatform(OperatingSystem.CURRENT_OS, Architecture.SYSTEM_ARCH);
|
||||
|
||||
public static boolean isCompatibleWithX86Java() {
|
||||
if (Architecture.CURRENT_ARCH.isX86())
|
||||
return true;
|
||||
|
||||
// Rosetta 2 is available for Mac computers with Apple silicon
|
||||
if (CURRENT_PLATFORM == OSX_ARM64)
|
||||
return true;
|
||||
|
||||
// Windows on ARM introduced translation support for x86-64 after 10.0.21277.
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
return Architecture.SYSTEM_ARCH.isX86() || SYSTEM_PLATFORM == OSX_ARM64 || SYSTEM_PLATFORM == WINDOWS_ARM64;
|
||||
}
|
||||
|
||||
private final OperatingSystem os;
|
||||
@@ -55,8 +47,15 @@ public final class Platform {
|
||||
case LINUX:
|
||||
return LINUX_X86_64;
|
||||
}
|
||||
} else if (arch == Architecture.ARM64 && OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
|
||||
return OSX_ARM64;
|
||||
} else if (arch == Architecture.ARM64) {
|
||||
switch (os) {
|
||||
case WINDOWS:
|
||||
return WINDOWS_ARM64;
|
||||
case OSX:
|
||||
return OSX_ARM64;
|
||||
case LINUX:
|
||||
return LINUX_ARM64;
|
||||
}
|
||||
}
|
||||
|
||||
return new Platform(os, arch);
|
||||
|
||||
@@ -31,7 +31,11 @@ public final class SystemUtils {
|
||||
}
|
||||
|
||||
public static int callExternalProcess(List<String> command) throws IOException, InterruptedException {
|
||||
ManagedProcess managedProcess = new ManagedProcess(new ProcessBuilder(command));
|
||||
return callExternalProcess(new ProcessBuilder(command));
|
||||
}
|
||||
|
||||
public static int callExternalProcess(ProcessBuilder processBuilder) throws IOException, InterruptedException {
|
||||
ManagedProcess managedProcess = new ManagedProcess(processBuilder);
|
||||
managedProcess.pumpInputStream(SystemUtils::onLogLine);
|
||||
managedProcess.pumpErrorStream(SystemUtils::onLogLine);
|
||||
return managedProcess.getProcess().waitFor();
|
||||
|
||||
Binary file not shown.
@@ -78,6 +78,7 @@ bool FindJavaInRegistry(std::wstring& path) {
|
||||
}
|
||||
|
||||
bool FindJava(std::wstring& path) {
|
||||
return FindJavaInRegistry(path) ||
|
||||
ERROR_SUCCESS == MyGetEnvironmentVariable(L"JAVA_HOME", path);
|
||||
return ERROR_SUCCESS == MyGetEnvironmentVariable(L"HMCL_JAVA_HOME", path) ||
|
||||
ERROR_SUCCESS == MyGetEnvironmentVariable(L"JAVA_HOME", path) ||
|
||||
FindJavaInRegistry(path);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#define ERROR_PROMPT L"Java installation cannot be found in this computer,\n"\
|
||||
L"please download it from %s.\n"\
|
||||
L"Click 'OK' to go to the download page."
|
||||
#define ERROR_TITLE L"Java not found"
|
||||
|
||||
#define ERROR_PROMPT_ZH L"未能在这台电脑上找到 Java,请从 %s 下载安装 Java。\n"\
|
||||
L"点击“确定”立即前往下载页面。"
|
||||
#define ERROR_TITLE_ZH L"未找到 Java"
|
||||
|
||||
#define ERROR_PROMPT L"The Java runtime environment is required to run HMCL and Minecraft,\n"\
|
||||
L"Click 'OK' to start downloading java.\n"\
|
||||
L"Please restart HMCL after installing Java."
|
||||
|
||||
#define ERROR_PROMPT_ZH L"运行 HMCL 以及 Minecraft 需要 Java 运行时环境,点击“确定”开始下载。\n"\
|
||||
L"请在安装 Java 完成后重新启动 HMCL。"
|
||||
+35
-74
@@ -16,26 +16,23 @@ LPCWSTR VENDOR_DIRS[] = {
|
||||
};
|
||||
|
||||
void RawLaunchJVM(const std::wstring &javaPath, const std::wstring &workdir,
|
||||
const std::wstring &jarPath) {
|
||||
if (MyCreateProcess(
|
||||
L"\"" + javaPath +
|
||||
L"\" -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=15 -jar \"" +
|
||||
jarPath + L"\"",
|
||||
workdir))
|
||||
const std::wstring &jarPath, const std::wstring &jvmOptions) {
|
||||
if (MyCreateProcess(L"\"" + javaPath + L"\" " + jvmOptions + L" -jar \"" + jarPath + L"\"", workdir))
|
||||
exit(EXIT_SUCCESS);
|
||||
}
|
||||
|
||||
void LaunchJVM(const std::wstring &javaPath, const std::wstring &workdir,
|
||||
const std::wstring &jarPath) {
|
||||
const std::wstring &jarPath, const std::wstring &jvmOptions) {
|
||||
Version javaVersion(L"");
|
||||
if (!MyGetFileVersionInfo(javaPath, javaVersion)) return;
|
||||
|
||||
if (J8 <= javaVersion) {
|
||||
RawLaunchJVM(javaPath, workdir, jarPath);
|
||||
RawLaunchJVM(javaPath, workdir, jarPath, jvmOptions);
|
||||
}
|
||||
}
|
||||
|
||||
void FindJavaInDirAndLaunchJVM(const std::wstring &baseDir, const std::wstring &workdir, const std::wstring &jarPath) {
|
||||
void FindJavaInDirAndLaunchJVM(const std::wstring &baseDir, const std::wstring &workdir,
|
||||
const std::wstring &jarPath, const std::wstring &jvmOptions) {
|
||||
std::wstring pattern = baseDir + L"*";
|
||||
|
||||
WIN32_FIND_DATA data;
|
||||
@@ -45,7 +42,7 @@ void FindJavaInDirAndLaunchJVM(const std::wstring &baseDir, const std::wstring &
|
||||
do {
|
||||
std::wstring javaw = baseDir + data.cFileName + std::wstring(L"\\bin\\javaw.exe");
|
||||
if (FindFirstFileExists(javaw.c_str(), 0)) {
|
||||
LaunchJVM(javaw, workdir, jarPath);
|
||||
LaunchJVM(javaw, workdir, jarPath, jvmOptions);
|
||||
}
|
||||
} while (FindNextFile(hFind, &data));
|
||||
FindClose(hFind);
|
||||
@@ -54,7 +51,7 @@ void FindJavaInDirAndLaunchJVM(const std::wstring &baseDir, const std::wstring &
|
||||
|
||||
int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
LPWSTR lpCmdLine, int nCmdShow) {
|
||||
std::wstring path, exeName;
|
||||
std::wstring path, exeName, jvmOptions;
|
||||
|
||||
// Since Jar file is appended to this executable, we should first get the
|
||||
// location of JAR file.
|
||||
@@ -67,6 +64,10 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
exeName = exeName.substr(last_slash + 1);
|
||||
}
|
||||
|
||||
if (ERROR_SUCCESS != MyGetEnvironmentVariable(L"HMCL_JAVA_OPTS", jvmOptions)) {
|
||||
jvmOptions = L"-XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=15"; // Default Options
|
||||
}
|
||||
|
||||
bool useChinese = GetUserDefaultUILanguage() == 2052; // zh-CN
|
||||
|
||||
OSVERSIONINFOEX osvi;
|
||||
@@ -94,14 +95,14 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
bool isARM64 = (systemInfo.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_ARM64);
|
||||
|
||||
if (isARM64) {
|
||||
RawLaunchJVM(L"jre-arm64\\bin\\javaw.exe", workdir, exeName);
|
||||
RawLaunchJVM(L"jre-arm64\\bin\\javaw.exe", workdir, exeName, jvmOptions);
|
||||
}
|
||||
if (isX64) {
|
||||
RawLaunchJVM(L"jre-x64\\bin\\javaw.exe", workdir, exeName);
|
||||
RawLaunchJVM(L"jre-x64\\bin\\javaw.exe", workdir, exeName, jvmOptions);
|
||||
}
|
||||
RawLaunchJVM(L"jre-x86\\bin\\javaw.exe", workdir, exeName);
|
||||
RawLaunchJVM(L"jre-x86\\bin\\javaw.exe", workdir, exeName, jvmOptions);
|
||||
|
||||
if (FindJava(path)) LaunchJVM(path + L"\\bin\\javaw.exe", workdir, exeName);
|
||||
if (FindJava(path)) LaunchJVM(path + L"\\bin\\javaw.exe", workdir, exeName, jvmOptions);
|
||||
|
||||
std::wstring programFiles;
|
||||
|
||||
@@ -112,7 +113,7 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
MyPathAppend(dir, vendorDir);
|
||||
MyPathAddBackslash(dir);
|
||||
|
||||
FindJavaInDirAndLaunchJVM(dir, workdir, exeName);
|
||||
FindJavaInDirAndLaunchJVM(dir, workdir, exeName, jvmOptions);
|
||||
}
|
||||
|
||||
// Consider C:\Program Files (x86)
|
||||
@@ -122,11 +123,11 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
MyPathAppend(dir, vendorDir);
|
||||
MyPathAddBackslash(dir);
|
||||
|
||||
FindJavaInDirAndLaunchJVM(dir, workdir, exeName);
|
||||
FindJavaInDirAndLaunchJVM(dir, workdir, exeName, jvmOptions);
|
||||
}
|
||||
|
||||
// Try java in PATH
|
||||
RawLaunchJVM(L"javaw", workdir, exeName);
|
||||
RawLaunchJVM(L"javaw", workdir, exeName, jvmOptions);
|
||||
|
||||
std::wstring hmclJavaDir;
|
||||
{
|
||||
@@ -134,7 +135,9 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
if (SUCCEEDED(MySHGetFolderPath(CSIDL_APPDATA, buffer)) || SUCCEEDED(MySHGetFolderPath(CSIDL_PROFILE, buffer))) {
|
||||
MyPathAppend(buffer, L".hmcl");
|
||||
MyPathAppend(buffer, L"java");
|
||||
if (isX64) {
|
||||
if (isARM64) {
|
||||
MyPathAppend(buffer, L"windows-arm64");
|
||||
} else if (isX64) {
|
||||
MyPathAppend(buffer, L"windows-x86_64");
|
||||
} else {
|
||||
MyPathAppend(buffer, L"windows-x86");
|
||||
@@ -145,67 +148,25 @@ int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
|
||||
}
|
||||
|
||||
if (!hmclJavaDir.empty()) {
|
||||
FindJavaInDirAndLaunchJVM(hmclJavaDir, workdir, exeName);
|
||||
if (isWin7OrLater) {
|
||||
HRSRC scriptFileResource = FindResource(NULL, MAKEINTRESOURCE(ID_SCRIPT_DOWNLOAD_JAVA), RT_RCDATA);
|
||||
if (!scriptFileResource) goto error;
|
||||
|
||||
HGLOBAL scriptHandle = LoadResource(NULL, scriptFileResource);
|
||||
DWORD dataSize = SizeofResource(NULL, scriptFileResource);
|
||||
void *data = LockResource(scriptHandle);
|
||||
|
||||
std::wstring tempScriptPath;
|
||||
if (ERROR_SUCCESS != MyGetTempFile(L"hmcl-download-java-", L"ps1", tempScriptPath)) goto error;
|
||||
|
||||
HANDLE hFile;
|
||||
DWORD dwBytesWritten = 0;
|
||||
BOOL bErrorFlag = FALSE;
|
||||
|
||||
hFile = CreateFile(tempScriptPath.c_str(), GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if (hFile == INVALID_HANDLE_VALUE) goto error;
|
||||
|
||||
bErrorFlag = WriteFile(hFile, data, dataSize, &dwBytesWritten, NULL);
|
||||
if (FALSE == bErrorFlag || dwBytesWritten != dataSize) goto error;
|
||||
|
||||
CloseHandle(hFile);
|
||||
|
||||
std::wstring commandLineBuffer;
|
||||
|
||||
commandLineBuffer += L"powershell.exe -WindowStyle Hidden -ExecutionPolicy Bypass -File ";
|
||||
MyAppendPathToCommandLine(commandLineBuffer, tempScriptPath);
|
||||
commandLineBuffer += L" -JavaDir ";
|
||||
MyAppendPathToCommandLine(commandLineBuffer, hmclJavaDir);
|
||||
commandLineBuffer += L" -Arch ";
|
||||
if (isX64) {
|
||||
commandLineBuffer += L"x86-64";
|
||||
} else {
|
||||
commandLineBuffer += L"x86";
|
||||
}
|
||||
|
||||
STARTUPINFO si;
|
||||
PROCESS_INFORMATION pi;
|
||||
si.cb = sizeof(si);
|
||||
ZeroMemory(&si, sizeof(si));
|
||||
ZeroMemory(&pi, sizeof(pi));
|
||||
if (!CreateProcess(NULL, &commandLineBuffer[0], NULL, NULL, false, NORMAL_PRIORITY_CLASS, NULL, NULL, &si, &pi)) goto error;
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
DeleteFile(tempScriptPath.c_str());
|
||||
|
||||
// Try starting again after installing Java
|
||||
FindJavaInDirAndLaunchJVM(hmclJavaDir, workdir, exeName);
|
||||
}
|
||||
FindJavaInDirAndLaunchJVM(hmclJavaDir, workdir, exeName, jvmOptions);
|
||||
}
|
||||
|
||||
error:
|
||||
LPCWSTR downloadLink = isWin7OrLater && (isX64 || isARM64) ? L"https://www.microsoft.com/openjdk" : L"https://java.com";
|
||||
LPCWSTR downloadLink;
|
||||
|
||||
WCHAR errorPrompt[180];
|
||||
{
|
||||
LPCWSTR errorPromptFormat = useChinese ? ERROR_PROMPT_ZH : ERROR_PROMPT;
|
||||
wsprintf(errorPrompt, errorPromptFormat, downloadLink);
|
||||
if (isWin7OrLater) {
|
||||
if (isARM64) {
|
||||
downloadLink = L"https://aka.ms/download-jdk/microsoft-jdk-17-windows-aarch64.msi";
|
||||
} if (isX64) {
|
||||
downloadLink = L"https://aka.ms/download-jdk/microsoft-jdk-17-windows-x64.msi";
|
||||
} else {
|
||||
downloadLink = L"https://download.bell-sw.com/java/17.0.4.1+1/bellsoft-jre17.0.4.1+1-windows-i586-full.msi";
|
||||
}
|
||||
} else {
|
||||
downloadLink = L"https://www.java.com";
|
||||
}
|
||||
|
||||
if (IDOK == MessageBox(NULL, errorPrompt, L"Error", MB_ICONERROR | MB_OKCANCEL)) {
|
||||
if (IDOK == MessageBox(NULL, useChinese ? ERROR_PROMPT_ZH : ERROR_PROMPT, useChinese ? ERROR_TITLE_ZH : ERROR_TITLE, MB_ICONWARNING | MB_OKCANCEL)) {
|
||||
ShellExecute(0, 0, downloadLink, 0, 0, SW_SHOW);
|
||||
}
|
||||
return 1;
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Platform Support Status
|
||||
|
||||
| | Windows | Linux | Mac OS | FreeBSD |
|
||||
|----------------------------|:------------------------------------|:-----------------------------------------|:----------------------------------------------------|:--------|
|
||||
| x86-64 | ✅️ | ✅️ | ✅️ | ❔ |
|
||||
| x86 | ✅️ | ✅️ | / | / |
|
||||
| ARM64 | 👌 (1.8+)<br/>✅ (use x86 emulation) | 👌 | 👌 (1.6~1.18.2)<br/>✅ (1.19+)<br/>✅ (use Rosetta 2) | ❔ |
|
||||
| ARM32 | /️ | 👌 | / | / |
|
||||
| MIPS64el | / | 👌 | / | / |
|
||||
| LoongArch64 | / | 👌 (for Old World)<br/>❔ (for New World) | / | / |
|
||||
| PowerPC-64 (Little-Endian) | / | ❔ | / | / |
|
||||
| S390x | / | ❔ | / | / |
|
||||
| RISC-V | / | ❔ | / | / |
|
||||
|
||||
Legend:
|
||||
|
||||
* ✅: Official supported platform.
|
||||
|
||||
Fully supported by Mojang official. Problems encountered in the game should be directly reported to the Mojang.
|
||||
|
||||
* 👌: Supported platforms.
|
||||
|
||||
Support is provided by HMCL, tested to work, but may have more issues than a fully supported platform.
|
||||
Support for versions below 1.6 is not guaranteed.
|
||||
If you encounter a problem that does not exist on fully supported platforms, you can report it to HMCL.
|
||||
|
||||
* ❔: Low level supported platforms.
|
||||
|
||||
HMCL can run on this platform and has some basic support.
|
||||
However, launching the game directly is not yet available.
|
||||
If you want to start the game,
|
||||
you'll need to get the native libraries needed by Minecraft in other way and specify the native path in the instance settings.
|
||||
|
||||
* /: Not applicable.
|
||||
|
||||
We have no plans to support these platforms at this time, mainly because we don't have the equipment to test them.
|
||||
If you can help us adapt, please file a support request via issue.
|
||||
@@ -5,12 +5,17 @@
|
||||
[](https://discord.gg/jVvC7HfM6U)
|
||||
[](https://kook.top/Kx7n3t)
|
||||
|
||||
English | [中文](https://github.com/huanghongxun/HMCL/blob/javafx/README_cn.md)
|
||||
English | [中文](README_cn.md)
|
||||
|
||||
## Introduction
|
||||
HMCL is a Minecraft launcher which supports Mod Management, Game Customizing, Auto Installing (Forge, Fabric, LiteLoader and OptiFine), Modpack Creating, UI Customization, and more.
|
||||
HMCL is a cross-platform Minecraft launcher which supports Mod Management, Game Customizing, Auto Installing (Forge, Fabric, Quilt, LiteLoader and OptiFine), Modpack Creating, UI Customization, and more.
|
||||
|
||||
No plugin API is provided.
|
||||
HMCL has amazing cross-platform capabilities.
|
||||
It can not only run on different operating systems such as Windows, Linux, and macOS,
|
||||
but also supports multiple CPU architectures such as x86, arm, mips, and loongarch.
|
||||
You can easily play Minecraft on different platforms through HMCL.
|
||||
|
||||
For systems and CPU architectures supported by HMCL, see [this table](PLATFORM.md).
|
||||
|
||||
## Download
|
||||
Download the latest version from [the official website](https://hmcl.huangyuhui.net/download).
|
||||
|
||||
+6
-3
@@ -5,12 +5,15 @@
|
||||
[](https://discord.gg/jVvC7HfM6U)
|
||||
[](https://kook.top/Kx7n3t)
|
||||
|
||||
[English](https://github.com/huanghongxun/HMCL/blob/javafx/README.md) | 中文
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 简介
|
||||
HMCL 是一款 Minecraft 启动器, 支持 Mod 管理, 游戏自定义, 游戏自动安装 (Forge, Fabric, LiteLoader 与 OptiFine), 模组包创建, 界面自定义等功能.
|
||||
HMCL 是一款跨平台 Minecraft 启动器, 支持 Mod 管理, 游戏自定义, 游戏自动安装 (Forge, Fabric, Quilt, LiteLoader 与 OptiFine), 模组包创建, 界面自定义等功能.
|
||||
|
||||
不提供插件 API.
|
||||
HMCL 有着强大的跨平台能力。它不仅支持 Windows、Linux、macOS 等常见的操作系统,同时也支持 x86、ARM、MIPS 和 LoongArch 等不同的 CPU 架构。
|
||||
您可以使用 HMCL 在不同平台上轻松的游玩 Minecraft。
|
||||
|
||||
如果您想要了解 HMCL 对不同平台的支持程度,请参见[此表格](PLATFORM.md)。
|
||||
|
||||
## 下载
|
||||
请从 [HMCL 官网](https://hmcl.huangyuhui.net/download)下载最新版本的 HMCL.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+3
-1
@@ -34,11 +34,13 @@ val jfxDependenciesFile = project("HMCL").buildDir.resolve("openjfx-dependencies
|
||||
val jfxPlatforms = listOf(
|
||||
Platform("windows-x86", "win-x86"),
|
||||
Platform("windows-x86_64", "win"),
|
||||
Platform("windows-arm64", "win", groupId = "org.glavo.hmcl.openjfx", version = "18.0.2+1-arm64", unsupportedModules = listOf("media", "web")),
|
||||
Platform("osx-x86_64", "mac"),
|
||||
Platform("osx-arm64", "mac-aarch64"),
|
||||
Platform("linux-x86_64", "linux"),
|
||||
Platform("linux-arm32", "linux-arm32-monocle", unsupportedModules = listOf("media", "web")),
|
||||
Platform("linux-arm64", "linux-aarch64"),
|
||||
Platform("linux-loongarch64_ow", "linux", groupId = "org.glavo.hmcl.openjfx", version = "19-ea+10-loongson64", unsupportedModules = listOf("media", "web")),
|
||||
)
|
||||
|
||||
val jfxInClasspath =
|
||||
@@ -74,7 +76,7 @@ if (!jfxInClasspath && JavaVersion.current() >= JavaVersion.VERSION_11) {
|
||||
val classifier = platform.classifier
|
||||
rootProject.subprojects {
|
||||
for (module in jfxModules) {
|
||||
dependencies.add("compileOnly", "$groupId:javafx-$module:$version:$classifier")
|
||||
dependencies.add("implementation", "$groupId:javafx-$module:$version:$classifier")
|
||||
dependencies.add("testImplementation", "$groupId:javafx-$module:$version:$classifier")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user