Terracotta | 陶瓦联机 (#4215)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
2
.github/workflows/check-codes.yml
vendored
2
.github/workflows/check-codes.yml
vendored
@@ -22,4 +22,4 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
java-package: 'jdk+fx'
|
java-package: 'jdk+fx'
|
||||||
- name: Check Codes
|
- name: Check Codes
|
||||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
|
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||||
|
|||||||
@@ -121,8 +121,25 @@ tasks.compileJava {
|
|||||||
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val addOpens = listOf(
|
||||||
|
"java.base/java.lang",
|
||||||
|
"java.base/java.lang.reflect",
|
||||||
|
"java.base/jdk.internal.loader",
|
||||||
|
"javafx.base/com.sun.javafx.binding",
|
||||||
|
"javafx.base/com.sun.javafx.event",
|
||||||
|
"javafx.base/com.sun.javafx.runtime",
|
||||||
|
"javafx.graphics/javafx.css",
|
||||||
|
"javafx.graphics/com.sun.javafx.stage",
|
||||||
|
"javafx.graphics/com.sun.prism",
|
||||||
|
"javafx.controls/com.sun.javafx.scene.control",
|
||||||
|
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||||
|
"javafx.controls/javafx.scene.control.skin",
|
||||||
|
"jdk.attach/sun.tools.attach",
|
||||||
|
)
|
||||||
|
|
||||||
val hmclProperties = buildList {
|
val hmclProperties = buildList {
|
||||||
add("hmcl.version" to project.version.toString())
|
add("hmcl.version" to project.version.toString())
|
||||||
|
add("hmcl.add-opens" to addOpens.joinToString(" "))
|
||||||
System.getenv("GITHUB_SHA")?.let {
|
System.getenv("GITHUB_SHA")?.let {
|
||||||
add("hmcl.version.hash" to it)
|
add("hmcl.version.hash" to it)
|
||||||
}
|
}
|
||||||
@@ -149,22 +166,6 @@ val createPropertiesFile by tasks.registering {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val addOpens = listOf(
|
|
||||||
"java.base/java.lang",
|
|
||||||
"java.base/java.lang.reflect",
|
|
||||||
"java.base/jdk.internal.loader",
|
|
||||||
"javafx.base/com.sun.javafx.binding",
|
|
||||||
"javafx.base/com.sun.javafx.event",
|
|
||||||
"javafx.base/com.sun.javafx.runtime",
|
|
||||||
"javafx.graphics/javafx.css",
|
|
||||||
"javafx.graphics/com.sun.javafx.stage",
|
|
||||||
"javafx.graphics/com.sun.prism",
|
|
||||||
"javafx.controls/com.sun.javafx.scene.control",
|
|
||||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
|
||||||
"javafx.controls/javafx.scene.control.skin",
|
|
||||||
"jdk.attach/sun.tools.attach",
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks.jar {
|
tasks.jar {
|
||||||
enabled = false
|
enabled = false
|
||||||
dependsOn(tasks["shadowJar"])
|
dependsOn(tasks["shadowJar"])
|
||||||
|
|||||||
@@ -915,7 +915,15 @@ public final class LauncherHelper {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||||
|
|
||||||
|
public static int countMangedProcesses() {
|
||||||
|
PROCESSES.removeIf(it -> {
|
||||||
|
ManagedProcess process = it.get();
|
||||||
|
return process == null || !process.isRunning();
|
||||||
|
});
|
||||||
|
return PROCESSES.size();
|
||||||
|
}
|
||||||
|
|
||||||
public static void stopManagedProcesses() {
|
public static void stopManagedProcesses() {
|
||||||
while (!PROCESSES.isEmpty())
|
while (!PROCESSES.isEmpty())
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
|
import org.jackhuang.hmcl.auth.Account;
|
||||||
|
import org.jackhuang.hmcl.setting.Accounts;
|
||||||
|
import org.jackhuang.hmcl.task.DownloadException;
|
||||||
|
import org.jackhuang.hmcl.task.GetTask;
|
||||||
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.util.InvocationDispatcher;
|
||||||
|
import org.jackhuang.hmcl.util.Lang;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||||
|
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||||
|
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||||
|
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public final class TerracottaManager {
|
||||||
|
private TerracottaManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
|
||||||
|
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
|
||||||
|
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
|
||||||
|
|
||||||
|
static {
|
||||||
|
Task.runAsync(() -> {
|
||||||
|
if (TerracottaMetadata.PROVIDER == null) {
|
||||||
|
setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.OS));
|
||||||
|
LOG.warning("Terracotta hasn't support your OS: " + org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM);
|
||||||
|
} else {
|
||||||
|
switch (TerracottaMetadata.PROVIDER.status()) {
|
||||||
|
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
|
||||||
|
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
|
||||||
|
case READY -> launch(setState(new TerracottaState.Launching()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).whenComplete(exception -> {
|
||||||
|
if (exception != null) {
|
||||||
|
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
|
||||||
|
return STATE.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
Lang.thread(() -> {
|
||||||
|
while (true) {
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
|
||||||
|
LockSupport.parkNanos(500_000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int port = portSpecific.port;
|
||||||
|
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
TerracottaState next;
|
||||||
|
try {
|
||||||
|
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR)
|
||||||
|
.thenApplyAsync(jsonString -> {
|
||||||
|
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
|
||||||
|
if (object.index <= index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
object.port = port;
|
||||||
|
return object;
|
||||||
|
})
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR)
|
||||||
|
.run();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warning("Cannot fetch state from Terracotta.", e);
|
||||||
|
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next != null) {
|
||||||
|
compareAndSet(state, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
LockSupport.parkNanos(500_000);
|
||||||
|
}
|
||||||
|
}, "Terracotta Background Daemon", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean validate(Path file) {
|
||||||
|
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TerracottaState.Preparing install(@Nullable Path file) {
|
||||||
|
FXUtils.checkFxUserThread();
|
||||||
|
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (!(state instanceof TerracottaState.Uninitialized ||
|
||||||
|
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||||
|
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
TerracottaState.Preparing preparing;
|
||||||
|
if (state instanceof TerracottaState.Preparing it) {
|
||||||
|
preparing = it;
|
||||||
|
} else {
|
||||||
|
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.supplyAsync(Schedulers.io(), () -> {
|
||||||
|
return file != null ? TarFileTree.open(file) : null;
|
||||||
|
}).thenComposeAsync(Schedulers.javafx(), tree -> {
|
||||||
|
return getProvider().install(preparing, tree).whenComplete(exception -> {
|
||||||
|
if (tree != null) {
|
||||||
|
tree.close();
|
||||||
|
}
|
||||||
|
if (exception != null) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).whenComplete(exception -> {
|
||||||
|
if (exception == null) {
|
||||||
|
try {
|
||||||
|
TerracottaMetadata.removeLegacyVersionFiles();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning("Unable to remove legacy terracotta files.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||||
|
if (compareAndSet(preparing, launching)) {
|
||||||
|
launch(launching);
|
||||||
|
}
|
||||||
|
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
|
||||||
|
LOG.warning("Cannot install terracotta from local package.", exception);
|
||||||
|
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||||
|
} else if (exception instanceof DownloadException) {
|
||||||
|
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
|
||||||
|
} else {
|
||||||
|
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return setState(preparing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ITerracottaProvider getProvider() {
|
||||||
|
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
|
||||||
|
if (provider == null) {
|
||||||
|
throw new AssertionError("Terracotta Provider must NOT be null.");
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TerracottaState recover(@Nullable Path file) {
|
||||||
|
FXUtils.checkFxUserThread();
|
||||||
|
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return switch (getProvider().status()) {
|
||||||
|
case NOT_EXIST, LEGACY_VERSION -> install(file);
|
||||||
|
case READY -> {
|
||||||
|
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
|
||||||
|
launch(launching);
|
||||||
|
yield launching;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (NullPointerException | IOException e) {
|
||||||
|
LOG.warning("Cannot determine Terracotta state.", e);
|
||||||
|
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launch(TerracottaState.Launching state) {
|
||||||
|
Task.supplyAsync(() -> {
|
||||||
|
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
|
||||||
|
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
|
||||||
|
process.pumpInputStream(SystemUtils::onLogLine);
|
||||||
|
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||||
|
|
||||||
|
long exitTime = -1;
|
||||||
|
while (true) {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
|
||||||
|
return object.get("port").getAsInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.isRunning()) {
|
||||||
|
if (exitTime == -1) {
|
||||||
|
exitTime = System.currentTimeMillis();
|
||||||
|
} else if (System.currentTimeMillis() - exitTime >= 10000) {
|
||||||
|
throw new IllegalStateException("Process has exited for 10s.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
|
||||||
|
TerracottaState next;
|
||||||
|
if (exception == null) {
|
||||||
|
next = new TerracottaState.Unknown(port);
|
||||||
|
} else {
|
||||||
|
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||||
|
}
|
||||||
|
compareAndSet(state, next);
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<String> exportLogs() {
|
||||||
|
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
|
||||||
|
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR);
|
||||||
|
}
|
||||||
|
return Task.completed(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TerracottaState.Waiting setWaiting() {
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||||
|
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR)
|
||||||
|
.start();
|
||||||
|
return new TerracottaState.Waiting(-1, -1, null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getPlayerName() {
|
||||||
|
Account account = Accounts.getSelectedAccount();
|
||||||
|
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TerracottaState.HostScanning setScanning() {
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||||
|
new GetTask(NetworkUtils.toURI(String.format(
|
||||||
|
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
|
||||||
|
).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||||
|
|
||||||
|
return new TerracottaState.HostScanning(-1, -1, null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
|
||||||
|
TerracottaState state = STATE_V.get();
|
||||||
|
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||||
|
return new GetTask(NetworkUtils.toURI(String.format(
|
||||||
|
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
|
||||||
|
)))
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR)
|
||||||
|
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
|
||||||
|
.setSignificance(Task.TaskSignificance.MINOR);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T extends TerracottaState> T setState(T value) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
STATE_V.set(value);
|
||||||
|
STATE_D.accept(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
|
||||||
|
if (next == null) {
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STATE_V.compareAndSet(previous, next)) {
|
||||||
|
STATE_D.accept(next);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||||
|
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||||
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||||
|
|
||||||
|
public final class TerracottaMetadata {
|
||||||
|
private TerracottaMetadata() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Link(@SerializedName("desc") LocalizedText description, String link) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Config(
|
||||||
|
@SerializedName("version_legacy") String legacy,
|
||||||
|
@SerializedName("version_recent") List<String> recent,
|
||||||
|
@SerializedName("version_latest") String latest,
|
||||||
|
|
||||||
|
@SerializedName("classifiers") Map<String, String> classifiers,
|
||||||
|
@SerializedName("downloads") List<String> downloads,
|
||||||
|
@SerializedName("links") List<Link> links
|
||||||
|
) {
|
||||||
|
private TerracottaNative of(String classifier) {
|
||||||
|
List<URI> links = new ArrayList<>(this.downloads.size());
|
||||||
|
for (String download : this.downloads) {
|
||||||
|
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
|
||||||
|
}
|
||||||
|
|
||||||
|
String hash = Objects.requireNonNull(this.classifiers.get(classifier), String.format("Classifier %s doesn't exist.", classifier));
|
||||||
|
if (!hash.startsWith("sha256:")) {
|
||||||
|
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
|
||||||
|
}
|
||||||
|
hash = hash.substring("sha256:".length());
|
||||||
|
|
||||||
|
return new TerracottaNative(
|
||||||
|
Collections.unmodifiableList(links),
|
||||||
|
Metadata.DEPENDENCIES_DIRECTORY.resolve(
|
||||||
|
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
|
||||||
|
).toAbsolutePath(),
|
||||||
|
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final ITerracottaProvider PROVIDER;
|
||||||
|
public static final String PACKAGE_NAME;
|
||||||
|
private static final List<Link> PACKAGE_LINKS;
|
||||||
|
|
||||||
|
private static final Pattern LEGACY;
|
||||||
|
private static final List<String> RECENT;
|
||||||
|
private static final String LATEST;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Config config;
|
||||||
|
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
|
||||||
|
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ExceptionInInitializerError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY = Pattern.compile(config.legacy);
|
||||||
|
RECENT = config.recent;
|
||||||
|
LATEST = config.latest;
|
||||||
|
|
||||||
|
ProviderContext context = locateProvider(config);
|
||||||
|
PROVIDER = context != null ? context.provider() : null;
|
||||||
|
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
|
||||||
|
|
||||||
|
if (context != null) {
|
||||||
|
List<Link> packageLinks = new ArrayList<>(config.links.size());
|
||||||
|
for (Link link : config.links) {
|
||||||
|
packageLinks.add(new Link(
|
||||||
|
link.description,
|
||||||
|
link.link.replace("${version}", LATEST)
|
||||||
|
.replace("${classifier}", context.branch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Collections.shuffle(packageLinks);
|
||||||
|
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
|
||||||
|
} else {
|
||||||
|
PACKAGE_LINKS = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ProviderContext(ITerracottaProvider provider, String branch) {
|
||||||
|
ProviderContext(ITerracottaProvider provider, String system, String arch) {
|
||||||
|
this(provider, system + "-" + arch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Link> getPackageLinks() {
|
||||||
|
return PACKAGE_LINKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static ProviderContext locateProvider(Config config) {
|
||||||
|
String architecture = switch (Architecture.SYSTEM_ARCH) {
|
||||||
|
case X86_64 -> "x86_64";
|
||||||
|
case ARM64 -> "arm64";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (architecture == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (OperatingSystem.CURRENT_OS) {
|
||||||
|
case WINDOWS -> {
|
||||||
|
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_8_1)) {
|
||||||
|
yield new ProviderContext(
|
||||||
|
new GeneralProvider(config.of(String.format("windows-%s.exe", architecture))),
|
||||||
|
"windows", architecture
|
||||||
|
);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
}
|
||||||
|
case LINUX -> new ProviderContext(
|
||||||
|
new GeneralProvider(config.of(String.format("linux-%s", architecture))),
|
||||||
|
"linux", architecture
|
||||||
|
);
|
||||||
|
case MACOS -> new ProviderContext(
|
||||||
|
new MacOSProvider(
|
||||||
|
config.of(String.format("macos-%s.pkg", architecture)),
|
||||||
|
config.of(String.format("macos-%s", architecture))
|
||||||
|
),
|
||||||
|
"macos", architecture
|
||||||
|
);
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void removeLegacyVersionFiles() throws IOException {
|
||||||
|
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||||
|
for (Path path : terracotta) {
|
||||||
|
String name = FileUtils.getName(path);
|
||||||
|
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileUtils.deleteDirectory(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasLegacyVersionFiles() throws IOException {
|
||||||
|
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||||
|
for (Path path : terracotta) {
|
||||||
|
String name = FileUtils.getName(path);
|
||||||
|
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta;
|
||||||
|
|
||||||
|
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||||
|
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||||
|
import org.jackhuang.hmcl.util.DigestUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.logging.Logger;
|
||||||
|
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CancellationException;
|
||||||
|
|
||||||
|
public final class TerracottaNative {
|
||||||
|
private final List<URI> links;
|
||||||
|
private final FileDownloadTask.IntegrityCheck checking;
|
||||||
|
private final Path path;
|
||||||
|
|
||||||
|
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
|
||||||
|
this.links = links;
|
||||||
|
this.path = path;
|
||||||
|
this.checking = checking;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getPath() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
|
||||||
|
if (tree == null) {
|
||||||
|
return new FileDownloadTask(links, path, checking) {
|
||||||
|
@Override
|
||||||
|
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||||
|
Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||||
|
return new Context() {
|
||||||
|
@Override
|
||||||
|
public void withResult(boolean success) {
|
||||||
|
delegate.withResult(success);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||||
|
if (!context.hasInstallFence()) {
|
||||||
|
throw new CancellationException("User has installed terracotta from local archives.");
|
||||||
|
}
|
||||||
|
delegate.write(buffer, offset, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
if (isSuccess() && !context.requestInstallFence()) {
|
||||||
|
throw new CancellationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.runAsync(() -> {
|
||||||
|
String name = FileUtils.getName(path);
|
||||||
|
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
|
||||||
|
if (entry == null) {
|
||||||
|
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.requestInstallFence()) {
|
||||||
|
throw new CancellationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||||
|
|
||||||
|
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
|
||||||
|
try (
|
||||||
|
InputStream stream = tree.getInputStream(entry);
|
||||||
|
OutputStream os = Files.newOutputStream(path)
|
||||||
|
) {
|
||||||
|
stream.transferTo(new OutputStream() {
|
||||||
|
@Override
|
||||||
|
public void write(int b) throws IOException {
|
||||||
|
os.write(b);
|
||||||
|
digest.update((byte) b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
|
||||||
|
os.write(buffer, offset, len);
|
||||||
|
digest.update(buffer, offset, len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||||
|
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
|
||||||
|
Files.delete(path);
|
||||||
|
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ITerracottaProvider.Status status() throws IOException {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
|
||||||
|
return ITerracottaProvider.Status.READY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (TerracottaMetadata.hasLegacyVersionFiles()) {
|
||||||
|
return ITerracottaProvider.Status.LEGACY_VERSION;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
|
||||||
|
}
|
||||||
|
return ITerracottaProvider.Status.NOT_EXIST;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||||
|
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonSubtype;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonType;
|
||||||
|
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||||
|
import org.jackhuang.hmcl.util.gson.Validation;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public abstract sealed class TerracottaState {
|
||||||
|
protected TerracottaState() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUIFakeState() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isForkOf(TerracottaState state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Bootstrap extends TerracottaState {
|
||||||
|
static final Bootstrap INSTANCE = new Bootstrap();
|
||||||
|
|
||||||
|
private Bootstrap() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Uninitialized extends TerracottaState {
|
||||||
|
private final boolean hasLegacy;
|
||||||
|
|
||||||
|
Uninitialized(boolean hasLegacy) {
|
||||||
|
this.hasLegacy = hasLegacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasLegacy() {
|
||||||
|
return hasLegacy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
|
||||||
|
private final ReadOnlyDoubleWrapper progress;
|
||||||
|
|
||||||
|
private final AtomicBoolean installFence = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
Preparing(ReadOnlyDoubleWrapper progress) {
|
||||||
|
this.progress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReadOnlyDoubleProperty progressProperty() {
|
||||||
|
return progress.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void bindProgress(ObservableValue<? extends Number> value) {
|
||||||
|
progress.bind(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean requestInstallFence() {
|
||||||
|
return installFence.compareAndSet(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasInstallFence() {
|
||||||
|
return !installFence.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Launching extends TerracottaState {
|
||||||
|
Launching() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static abstract sealed class PortSpecific extends TerracottaState {
|
||||||
|
transient int port;
|
||||||
|
|
||||||
|
protected PortSpecific(int port) {
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonType(
|
||||||
|
property = "state",
|
||||||
|
subtypes = {
|
||||||
|
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
|
||||||
|
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
|
||||||
|
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
|
||||||
|
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
|
||||||
|
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
|
||||||
|
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
|
||||||
|
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
|
||||||
|
@JsonSubtype(clazz = Exception.class, name = "exception"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
static abstract sealed class Ready extends PortSpecific {
|
||||||
|
@SerializedName("index")
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@SerializedName("state")
|
||||||
|
private final String state;
|
||||||
|
|
||||||
|
Ready(int port, int index, String state) {
|
||||||
|
super(port);
|
||||||
|
this.index = index;
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isUIFakeState() {
|
||||||
|
return this.index == -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Unknown extends PortSpecific {
|
||||||
|
Unknown(int port) {
|
||||||
|
super(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Waiting extends Ready {
|
||||||
|
Waiting(int port, int index, String state) {
|
||||||
|
super(port, index, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class HostScanning extends Ready {
|
||||||
|
HostScanning(int port, int index, String state) {
|
||||||
|
super(port, index, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class HostStarting extends Ready {
|
||||||
|
HostStarting(int port, int index, String state) {
|
||||||
|
super(port, index, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class HostOK extends Ready implements Validation {
|
||||||
|
@SerializedName("room")
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
@SerializedName("profile_index")
|
||||||
|
private final int profileIndex;
|
||||||
|
|
||||||
|
@SerializedName("profiles")
|
||||||
|
private final List<TerracottaProfile> profiles;
|
||||||
|
|
||||||
|
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
|
||||||
|
super(port, index, state);
|
||||||
|
this.code = code;
|
||||||
|
this.profileIndex = profileIndex;
|
||||||
|
this.profiles = profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate() throws JsonParseException, TolerableValidationException {
|
||||||
|
if (code == null) {
|
||||||
|
throw new JsonParseException("code is null");
|
||||||
|
}
|
||||||
|
if (profiles == null) {
|
||||||
|
throw new JsonParseException("profiles is null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TerracottaProfile> getProfiles() {
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isForkOf(TerracottaState state) {
|
||||||
|
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class GuestStarting extends Ready {
|
||||||
|
GuestStarting(int port, int index, String state) {
|
||||||
|
super(port, index, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class GuestOK extends Ready implements Validation {
|
||||||
|
@SerializedName("url")
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
@SerializedName("profile_index")
|
||||||
|
private final int profileIndex;
|
||||||
|
|
||||||
|
@SerializedName("profiles")
|
||||||
|
private final List<TerracottaProfile> profiles;
|
||||||
|
|
||||||
|
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
|
||||||
|
super(port, index, state);
|
||||||
|
this.url = url;
|
||||||
|
this.profileIndex = profileIndex;
|
||||||
|
this.profiles = profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate() throws JsonParseException, TolerableValidationException {
|
||||||
|
if (profiles == null) {
|
||||||
|
throw new JsonParseException("profiles is null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TerracottaProfile> getProfiles() {
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isForkOf(TerracottaState state) {
|
||||||
|
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Exception extends Ready implements Validation {
|
||||||
|
public enum Type {
|
||||||
|
PING_HOST_FAIL,
|
||||||
|
PING_HOST_RST,
|
||||||
|
GUEST_ET_CRASH,
|
||||||
|
HOST_ET_CRASH,
|
||||||
|
PING_SERVER_RST,
|
||||||
|
SCAFFOLDING_INVALID_RESPONSE
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
|
||||||
|
|
||||||
|
@SerializedName("type")
|
||||||
|
private final int type;
|
||||||
|
|
||||||
|
Exception(int port, int index, String state, int type) {
|
||||||
|
super(port, index, state);
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate() throws JsonParseException, TolerableValidationException {
|
||||||
|
if (type < 0 || type >= LOOKUP.length) {
|
||||||
|
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type getType() {
|
||||||
|
return LOOKUP[type];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Fatal extends TerracottaState {
|
||||||
|
public enum Type {
|
||||||
|
OS,
|
||||||
|
NETWORK,
|
||||||
|
INSTALL,
|
||||||
|
TERRACOTTA,
|
||||||
|
UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Type type;
|
||||||
|
|
||||||
|
public Fatal(Type type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRecoverable() {
|
||||||
|
return this.type != Type.OS && this.type != Type.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta.profile;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
public enum ProfileKind {
|
||||||
|
@SerializedName("HOST")
|
||||||
|
HOST,
|
||||||
|
@SerializedName("LOCAL")
|
||||||
|
LOCAL,
|
||||||
|
@SerializedName("GUEST")
|
||||||
|
GUEST
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta.profile;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
public final class TerracottaProfile {
|
||||||
|
@SerializedName("machine_id")
|
||||||
|
private final String machineID;
|
||||||
|
|
||||||
|
@SerializedName("name")
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
@SerializedName("vendor")
|
||||||
|
private final String vendor;
|
||||||
|
|
||||||
|
@SerializedName("kind")
|
||||||
|
private final ProfileKind type;
|
||||||
|
|
||||||
|
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
|
||||||
|
this.machineID = machineID;
|
||||||
|
this.name = name;
|
||||||
|
this.vendor = vendor;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMachineID() {
|
||||||
|
return machineID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVendor() {
|
||||||
|
return vendor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProfileKind getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta.provider;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||||
|
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||||
|
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class GeneralProvider implements ITerracottaProvider {
|
||||||
|
private final TerracottaNative target;
|
||||||
|
|
||||||
|
public GeneralProvider(TerracottaNative target) {
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Status status() throws IOException {
|
||||||
|
return target.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||||
|
Task<?> task = target.install(context, tree);
|
||||||
|
context.bindProgress(task.progressProperty());
|
||||||
|
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||||
|
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE,
|
||||||
|
PosixFilePermission.OWNER_EXECUTE,
|
||||||
|
PosixFilePermission.GROUP_READ,
|
||||||
|
PosixFilePermission.GROUP_EXECUTE,
|
||||||
|
PosixFilePermission.OTHERS_READ,
|
||||||
|
PosixFilePermission.OTHERS_EXECUTE
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> ofCommandLine(Path path) {
|
||||||
|
return List.of(target.getPath().toString(), "--hmcl", path.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta.provider;
|
||||||
|
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface ITerracottaProvider {
|
||||||
|
enum Status {
|
||||||
|
NOT_EXIST,
|
||||||
|
LEGACY_VERSION,
|
||||||
|
READY
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
void bindProgress(ObservableValue<? extends Number> value);
|
||||||
|
|
||||||
|
boolean requestInstallFence();
|
||||||
|
|
||||||
|
boolean hasInstallFence();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ProviderException extends IOException {
|
||||||
|
public ProviderException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProviderException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProviderException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ArchiveFileMissingException extends ProviderException {
|
||||||
|
public ArchiveFileMissingException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ArchiveFileMissingException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ArchiveFileMissingException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Status status() throws IOException;
|
||||||
|
|
||||||
|
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
|
||||||
|
|
||||||
|
List<String> ofCommandLine(Path path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.terracotta.provider;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||||
|
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||||
|
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||||
|
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermission;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
|
||||||
|
public final class MacOSProvider implements ITerracottaProvider {
|
||||||
|
public final TerracottaNative installer, binary;
|
||||||
|
|
||||||
|
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
|
||||||
|
this.installer = installer;
|
||||||
|
this.binary = binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Status status() throws IOException {
|
||||||
|
assert binary != null;
|
||||||
|
|
||||||
|
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
|
||||||
|
return Status.NOT_EXIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
return binary.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||||
|
assert installer != null && binary != null;
|
||||||
|
|
||||||
|
Task<?> installerTask = installer.install(context, tree);
|
||||||
|
Task<?> binaryTask = binary.install(context, tree);
|
||||||
|
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
|
||||||
|
|
||||||
|
return Task.allOf(
|
||||||
|
installerTask.thenComposeAsync(() -> {
|
||||||
|
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||||
|
"osascript",
|
||||||
|
"-e",
|
||||||
|
String.format(
|
||||||
|
"do shell script \"installer -pkg %s -target /Applications\" with prompt \"%s\" with administrator privileges",
|
||||||
|
installer.getPath(),
|
||||||
|
i18n("terracotta.sudo_installing")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
process.pumpInputStream(SystemUtils::onLogLine);
|
||||||
|
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||||
|
|
||||||
|
return Task.fromCompletableFuture(process.getProcess().onExit());
|
||||||
|
}),
|
||||||
|
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
|
||||||
|
PosixFilePermission.OWNER_READ,
|
||||||
|
PosixFilePermission.OWNER_WRITE,
|
||||||
|
PosixFilePermission.OWNER_EXECUTE,
|
||||||
|
PosixFilePermission.GROUP_READ,
|
||||||
|
PosixFilePermission.GROUP_EXECUTE,
|
||||||
|
PosixFilePermission.OTHERS_READ,
|
||||||
|
PosixFilePermission.OTHERS_EXECUTE
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> ofCommandLine(Path path) {
|
||||||
|
assert binary != null;
|
||||||
|
|
||||||
|
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import javafx.stage.StageStyle;
|
|||||||
import javafx.util.Duration;
|
import javafx.util.Duration;
|
||||||
import org.jackhuang.hmcl.Launcher;
|
import org.jackhuang.hmcl.Launcher;
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||||
import org.jackhuang.hmcl.java.JavaManager;
|
import org.jackhuang.hmcl.java.JavaManager;
|
||||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
@@ -55,8 +56,10 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
|
|||||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
||||||
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
|
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
|
||||||
import org.jackhuang.hmcl.ui.main.RootPage;
|
import org.jackhuang.hmcl.ui.main.RootPage;
|
||||||
|
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
|
||||||
import org.jackhuang.hmcl.ui.versions.GameListPage;
|
import org.jackhuang.hmcl.ui.versions.GameListPage;
|
||||||
import org.jackhuang.hmcl.ui.versions.VersionPage;
|
import org.jackhuang.hmcl.ui.versions.VersionPage;
|
||||||
|
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||||
import org.jackhuang.hmcl.util.*;
|
import org.jackhuang.hmcl.util.*;
|
||||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||||
@@ -108,6 +111,7 @@ public final class Controllers {
|
|||||||
return accountListPage;
|
return accountListPage;
|
||||||
});
|
});
|
||||||
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
|
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
|
||||||
|
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
|
||||||
|
|
||||||
private Controllers() {
|
private Controllers() {
|
||||||
}
|
}
|
||||||
@@ -150,6 +154,11 @@ public final class Controllers {
|
|||||||
return downloadPage.get();
|
return downloadPage.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FXThread
|
||||||
|
public static Node getTerracottaPage() {
|
||||||
|
return terracottaPage.get();
|
||||||
|
}
|
||||||
|
|
||||||
// FXThread
|
// FXThread
|
||||||
public static DecoratorController getDecorator() {
|
public static DecoratorController getDecorator() {
|
||||||
return decorator;
|
return decorator;
|
||||||
@@ -424,6 +433,30 @@ public final class Controllers {
|
|||||||
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
|
||||||
|
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||||
|
btnYes.getStyleClass().add("dialog-error");
|
||||||
|
btnYes.setOnAction(e -> resolve.run());
|
||||||
|
btnYes.setDisable(true);
|
||||||
|
|
||||||
|
int countdown = 10;
|
||||||
|
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||||
|
for (int i = 0; i < countdown; i++) {
|
||||||
|
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||||
|
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||||
|
}
|
||||||
|
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||||
|
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||||
|
new KeyValue(btnYes.disableProperty(), false));
|
||||||
|
|
||||||
|
Timeline timeline = new Timeline(keyFrames);
|
||||||
|
confirmAction(text, title, MessageType.WARNING, btnYes, () -> {
|
||||||
|
timeline.stop();
|
||||||
|
cancel.run();
|
||||||
|
});
|
||||||
|
timeline.play();
|
||||||
|
}
|
||||||
|
|
||||||
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
|
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
|
||||||
return prompt(title, onResult, "");
|
return prompt(title, onResult, "");
|
||||||
}
|
}
|
||||||
@@ -470,6 +503,10 @@ public final class Controllers {
|
|||||||
Controllers.getSettingsPage().showFeedback();
|
Controllers.getSettingsPage().showFeedback();
|
||||||
Controllers.navigate(Controllers.getSettingsPage());
|
Controllers.navigate(Controllers.getSettingsPage());
|
||||||
break;
|
break;
|
||||||
|
case "hmcl://game/launch":
|
||||||
|
Profile profile = Profiles.getSelectedProfile();
|
||||||
|
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FXUtils.openLink(href);
|
FXUtils.openLink(href);
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ package org.jackhuang.hmcl.ui.account;
|
|||||||
|
|
||||||
import com.jfoenix.controls.*;
|
import com.jfoenix.controls.*;
|
||||||
import com.jfoenix.validation.base.ValidatorBase;
|
import com.jfoenix.validation.base.ValidatorBase;
|
||||||
import javafx.animation.KeyFrame;
|
|
||||||
import javafx.animation.KeyValue;
|
|
||||||
import javafx.animation.Timeline;
|
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.NamedArg;
|
import javafx.beans.NamedArg;
|
||||||
import javafx.beans.binding.BooleanBinding;
|
import javafx.beans.binding.BooleanBinding;
|
||||||
@@ -39,7 +36,6 @@ import javafx.scene.control.Label;
|
|||||||
import javafx.scene.control.TextInputControl;
|
import javafx.scene.control.TextInputControl;
|
||||||
import javafx.scene.layout.*;
|
import javafx.scene.layout.*;
|
||||||
|
|
||||||
import javafx.util.Duration;
|
|
||||||
import org.jackhuang.hmcl.Metadata;
|
import org.jackhuang.hmcl.Metadata;
|
||||||
import org.jackhuang.hmcl.auth.AccountFactory;
|
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||||
@@ -268,33 +264,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
|
||||||
btnYes.getStyleClass().add("dialog-error");
|
|
||||||
btnYes.setOnAction(e -> doCreate.run());
|
|
||||||
btnYes.setDisable(true);
|
|
||||||
|
|
||||||
int countdown = 10;
|
|
||||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
|
||||||
for (int i = 0; i < countdown; i++) {
|
|
||||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
|
||||||
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
|
||||||
}
|
|
||||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
|
||||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
|
||||||
new KeyValue(btnYes.disableProperty(), false));
|
|
||||||
|
|
||||||
Timeline timeline = new Timeline(keyFrames);
|
|
||||||
Controllers.confirmAction(
|
|
||||||
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
|
|
||||||
MessageDialogPane.MessageType.WARNING,
|
|
||||||
btnYes,
|
|
||||||
() -> {
|
|
||||||
timeline.stop();
|
|
||||||
body.setDisable(false);
|
body.setDisable(false);
|
||||||
spinner.hideSpinner();
|
spinner.hideSpinner();
|
||||||
}
|
});
|
||||||
);
|
|
||||||
timeline.play();
|
|
||||||
} else {
|
} else {
|
||||||
doCreate.run();
|
doCreate.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ final class ComponentListCell extends StackPane {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private void updateLayout() {
|
private void updateLayout() {
|
||||||
if (content instanceof ComponentList) {
|
if (content instanceof ComponentList list) {
|
||||||
ComponentList list = (ComponentList) content;
|
|
||||||
content.getStyleClass().remove("options-list");
|
content.getStyleClass().remove("options-list");
|
||||||
content.getStyleClass().add("options-sublist");
|
content.getStyleClass().add("options-sublist");
|
||||||
|
|
||||||
@@ -130,7 +129,10 @@ final class ComponentListCell extends StackPane {
|
|||||||
groupNode.getChildren().add(headerRippler);
|
groupNode.getChildren().add(headerRippler);
|
||||||
|
|
||||||
VBox container = new VBox();
|
VBox container = new VBox();
|
||||||
|
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
|
||||||
|
if (hasPadding) {
|
||||||
container.setPadding(new Insets(8, 16, 10, 16));
|
container.setPadding(new Insets(8, 16, 10, 16));
|
||||||
|
}
|
||||||
FXUtils.setLimitHeight(container, 0);
|
FXUtils.setLimitHeight(container, 0);
|
||||||
FXUtils.setOverflowHidden(container);
|
FXUtils.setOverflowHidden(container);
|
||||||
container.getChildren().setAll(content);
|
container.getChildren().setAll(content);
|
||||||
@@ -149,7 +151,8 @@ final class ComponentListCell extends StackPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1);
|
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
|
||||||
|
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
|
||||||
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
|
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
|
||||||
double contentHeight = expanded ? newAnimatedHeight : 0;
|
double contentHeight = expanded ? newAnimatedHeight : 0;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
package org.jackhuang.hmcl.ui.construct;
|
package org.jackhuang.hmcl.ui.construct;
|
||||||
|
|
||||||
import javafx.beans.DefaultProperty;
|
import javafx.beans.DefaultProperty;
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList {
|
|||||||
|
|
||||||
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
|
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
|
||||||
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
|
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
|
||||||
|
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
|
||||||
|
|
||||||
public ComponentSublist() {
|
public ComponentSublist() {
|
||||||
super();
|
super();
|
||||||
@@ -55,4 +58,16 @@ public class ComponentSublist extends ComponentList {
|
|||||||
public void setHeaderRight(Node headerRight) {
|
public void setHeaderRight(Node headerRight) {
|
||||||
this.headerRight.set(headerRight);
|
this.headerRight.set(headerRight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasComponentPadding() {
|
||||||
|
return componentPadding.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BooleanProperty componentPaddingProperty() {
|
||||||
|
return componentPadding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setComponentPadding(boolean componentPadding) {
|
||||||
|
this.componentPadding.set(componentPadding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,11 +182,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
|||||||
launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage()));
|
launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage()));
|
||||||
|
|
||||||
// sixth item in left sidebar
|
// sixth item in left sidebar
|
||||||
AdvancedListItem chatItem = new AdvancedListItem();
|
AdvancedListItem terracottaItem = new AdvancedListItem();
|
||||||
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
terracottaItem.setLeftGraphic(wrap(SVG.HOST));
|
||||||
chatItem.setActionButtonVisible(false);
|
terracottaItem.setActionButtonVisible(false);
|
||||||
chatItem.setTitle(i18n("chat"));
|
terracottaItem.setTitle(i18n("terracotta"));
|
||||||
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage()));
|
||||||
|
|
||||||
// the left sidebar
|
// the left sidebar
|
||||||
AdvancedListBox sideBar = new AdvancedListBox()
|
AdvancedListBox sideBar = new AdvancedListBox()
|
||||||
@@ -198,7 +198,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
|||||||
.add(downloadItem)
|
.add(downloadItem)
|
||||||
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
||||||
.add(launcherSettingsItem)
|
.add(launcherSettingsItem)
|
||||||
.add(chatItem);
|
.add(terracottaItem);
|
||||||
|
|
||||||
// the root page, with the sidebar in left, navigator in center.
|
// the root page, with the sidebar in left, navigator in center.
|
||||||
setLeft(sideBar);
|
setLeft(sideBar);
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 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.terracotta;
|
||||||
|
|
||||||
|
import com.jfoenix.controls.JFXProgressBar;
|
||||||
|
import javafx.beans.property.DoubleProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleDoubleProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.beans.value.WeakChangeListener;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Cursor;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.input.Clipboard;
|
||||||
|
import javafx.scene.input.ClipboardContent;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
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.task.Schedulers;
|
||||||
|
import org.jackhuang.hmcl.task.Task;
|
||||||
|
import org.jackhuang.hmcl.terracotta.TerracottaManager;
|
||||||
|
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||||
|
import org.jackhuang.hmcl.terracotta.TerracottaState;
|
||||||
|
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||||
|
import org.jackhuang.hmcl.ui.Controllers;
|
||||||
|
import org.jackhuang.hmcl.ui.FXUtils;
|
||||||
|
import org.jackhuang.hmcl.ui.SVG;
|
||||||
|
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||||
|
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||||
|
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||||
|
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||||
|
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||||
|
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.Zipper;
|
||||||
|
import org.jackhuang.hmcl.util.logging.Logger;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
|
||||||
|
public class TerracottaControllerPage extends StackPane {
|
||||||
|
private static final ObjectProperty<TerracottaState> UI_STATE = new SimpleObjectProperty<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
FXUtils.onChangeAndOperate(TerracottaManager.stateProperty(), state -> {
|
||||||
|
if (state != null) {
|
||||||
|
UI_STATE.set(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||||
|
|
||||||
|
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
|
||||||
|
Consider moving UI into multiple files for each state respectively. */
|
||||||
|
public TerracottaControllerPage() {
|
||||||
|
TransitionPane transition = new TransitionPane();
|
||||||
|
|
||||||
|
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
|
||||||
|
DoubleProperty progressProperty = new SimpleDoubleProperty();
|
||||||
|
ObservableList<Node> nodesProperty = FXCollections.observableList(new ArrayList<>());
|
||||||
|
|
||||||
|
FXUtils.applyDragListener(this, path -> {
|
||||||
|
TerracottaState state = UI_STATE.get();
|
||||||
|
|
||||||
|
if (state instanceof TerracottaState.Uninitialized ||
|
||||||
|
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||||
|
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
|
||||||
|
) {
|
||||||
|
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, files -> {
|
||||||
|
Path path = files.get(0);
|
||||||
|
|
||||||
|
if (!TerracottaManager.validate(path)) {
|
||||||
|
Controllers.dialog(
|
||||||
|
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
|
||||||
|
i18n("message.error"),
|
||||||
|
MessageDialogPane.MessageType.ERROR
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TerracottaState state = UI_STATE.get(), next;
|
||||||
|
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
|
||||||
|
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
|
||||||
|
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||||
|
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
}, () -> {
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next = TerracottaManager.install(path);
|
||||||
|
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||||
|
next = TerracottaManager.recover(path);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next != null) {
|
||||||
|
UI_STATE.set(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ChangeListener<TerracottaState> listener = (_uiState, legacyState, state) -> {
|
||||||
|
if (legacyState != null && legacyState.isUIFakeState() && !state.isUIFakeState() && legacyState.getClass() == state.getClass()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressProperty.unbind();
|
||||||
|
|
||||||
|
if (state instanceof TerracottaState.Bootstrap) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.bootstrap"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
nodesProperty.setAll();
|
||||||
|
} else if (state instanceof TerracottaState.Uninitialized uninitialized) {
|
||||||
|
String fork = uninitialized.hasLegacy() ? "update" : "not_exist";
|
||||||
|
|
||||||
|
statusProperty.set(i18n("terracotta.status.uninitialized." + fork));
|
||||||
|
progressProperty.set(0);
|
||||||
|
|
||||||
|
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||||
|
body.setLineSpacing(4);
|
||||||
|
|
||||||
|
LineButton download = LineButton.of();
|
||||||
|
download.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||||
|
download.setTitle(i18n(String.format("terracotta.status.uninitialized.%s.title", fork)));
|
||||||
|
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
|
||||||
|
download.setRightIcon(SVG.ARROW_FORWARD);
|
||||||
|
FXUtils.onClicked(download, () -> {
|
||||||
|
if (uninitialized.hasLegacy()) {
|
||||||
|
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||||
|
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
}, () -> {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(body, download, getThirdPartyDownloadNodes());
|
||||||
|
} else if (state instanceof TerracottaState.Preparing) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.preparing"));
|
||||||
|
progressProperty.bind(((TerracottaState.Preparing) state).progressProperty());
|
||||||
|
nodesProperty.setAll(getThirdPartyDownloadNodes());
|
||||||
|
} else if (state instanceof TerracottaState.Launching) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.launching"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
nodesProperty.setAll();
|
||||||
|
} else if (state instanceof TerracottaState.Unknown) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.unknown"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
nodesProperty.setAll();
|
||||||
|
} else if (state instanceof TerracottaState.Waiting) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.waiting"));
|
||||||
|
progressProperty.set(1);
|
||||||
|
|
||||||
|
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||||
|
flow.setLineSpacing(4);
|
||||||
|
|
||||||
|
LineButton host = LineButton.of();
|
||||||
|
host.setLeftIcon(SVG.HOST);
|
||||||
|
host.setTitle(i18n("terracotta.status.waiting.host.title"));
|
||||||
|
host.setSubtitle(i18n("terracotta.status.waiting.host.desc"));
|
||||||
|
host.setRightIcon(SVG.ARROW_FORWARD);
|
||||||
|
FXUtils.onClicked(host, () -> {
|
||||||
|
if (LauncherHelper.countMangedProcesses() >= 1) {
|
||||||
|
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||||
|
if (s1 != null) {
|
||||||
|
UI_STATE.set(s1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Controllers.dialog(new MessageDialogPane.Builder(
|
||||||
|
i18n("terracotta.status.waiting.host.launch.desc"),
|
||||||
|
i18n("terracotta.status.waiting.host.launch.title"),
|
||||||
|
MessageDialogPane.MessageType.QUESTION
|
||||||
|
).addAction(i18n("version.launch"), () -> {
|
||||||
|
Profile profile = Profiles.getSelectedProfile();
|
||||||
|
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||||
|
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
|
||||||
|
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||||
|
if (s1 != null) {
|
||||||
|
UI_STATE.set(s1);
|
||||||
|
}
|
||||||
|
}).addCancel(() -> {
|
||||||
|
}).build());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
LineButton guest = LineButton.of();
|
||||||
|
guest.setLeftIcon(SVG.ADD_CIRCLE);
|
||||||
|
guest.setTitle(i18n("terracotta.status.waiting.guest.title"));
|
||||||
|
guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc"));
|
||||||
|
guest.setRightIcon(SVG.ARROW_FORWARD);
|
||||||
|
FXUtils.onClicked(guest, () -> {
|
||||||
|
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
|
||||||
|
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
|
||||||
|
if (task != null) {
|
||||||
|
task.whenComplete(Schedulers.javafx(), (s, e) -> {
|
||||||
|
if (e != null) {
|
||||||
|
reject.accept(i18n("terracotta.status.waiting.guest.prompt.invalid"));
|
||||||
|
} else {
|
||||||
|
resolve.run();
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
}).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||||
|
} else {
|
||||||
|
resolve.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(flow, host, guest);
|
||||||
|
} else if (state instanceof TerracottaState.HostScanning) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.scanning"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
|
||||||
|
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
|
||||||
|
body.setLineSpacing(4);
|
||||||
|
|
||||||
|
LineButton room = LineButton.of();
|
||||||
|
room.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
room.setTitle(i18n("terracotta.back"));
|
||||||
|
room.setSubtitle(i18n("terracotta.status.scanning.back"));
|
||||||
|
FXUtils.onClicked(room, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(body, room);
|
||||||
|
} else if (state instanceof TerracottaState.HostStarting) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.host_starting"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
|
||||||
|
LineButton room = LineButton.of();
|
||||||
|
room.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
room.setTitle(i18n("terracotta.back"));
|
||||||
|
room.setSubtitle(i18n("terracotta.status.host_starting.back"));
|
||||||
|
FXUtils.onClicked(room, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(room);
|
||||||
|
} else if (state instanceof TerracottaState.HostOK hostOK) {
|
||||||
|
if (hostOK.isForkOf(legacyState)) {
|
||||||
|
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(hostOK.getProfiles());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
String cs = hostOK.getCode();
|
||||||
|
|
||||||
|
statusProperty.set(i18n("terracotta.status.host_ok"));
|
||||||
|
progressProperty.set(1);
|
||||||
|
|
||||||
|
VBox code = new VBox(4);
|
||||||
|
code.setAlignment(Pos.CENTER);
|
||||||
|
{
|
||||||
|
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
|
||||||
|
{
|
||||||
|
ClipboardContent cp = new ClipboardContent();
|
||||||
|
cp.putString(cs);
|
||||||
|
Clipboard.getSystemClipboard().setContent(cp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: The implementation to display Room Code is ambiguous. Consider using a clearer JavaFX Element in the future.
|
||||||
|
TextField label = new TextField(cs);
|
||||||
|
label.setEditable(false);
|
||||||
|
label.setFocusTraversable(false);
|
||||||
|
label.setAlignment(Pos.CENTER);
|
||||||
|
label.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;");
|
||||||
|
VBox.setMargin(label, new Insets(10, 0, 10, 0));
|
||||||
|
label.setScaleX(1.8);
|
||||||
|
label.setScaleY(1.8);
|
||||||
|
holder.add(FXUtils.onWeakChange(label.selectedTextProperty(), string -> {
|
||||||
|
if (string != null && !string.isEmpty() && !cs.equals(string)) {
|
||||||
|
label.selectAll();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
code.getChildren().setAll(desc, label);
|
||||||
|
}
|
||||||
|
FXUtils.onClicked(code, () -> FXUtils.copyText(cs));
|
||||||
|
|
||||||
|
LineButton copy = LineButton.of();
|
||||||
|
copy.setLeftIcon(SVG.CONTENT_COPY);
|
||||||
|
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
|
||||||
|
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
|
||||||
|
FXUtils.onClicked(copy, () -> FXUtils.copyText(cs));
|
||||||
|
|
||||||
|
LineButton back = LineButton.of();
|
||||||
|
back.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
back.setTitle(i18n("terracotta.back"));
|
||||||
|
back.setSubtitle(i18n("terracotta.status.host_ok.back"));
|
||||||
|
FXUtils.onClicked(back, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
|
||||||
|
}
|
||||||
|
} else if (state instanceof TerracottaState.GuestStarting) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.guest_starting"));
|
||||||
|
progressProperty.set(-1);
|
||||||
|
|
||||||
|
LineButton room = LineButton.of();
|
||||||
|
room.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
room.setTitle(i18n("terracotta.back"));
|
||||||
|
room.setSubtitle(i18n("terracotta.status.guest_starting.back"));
|
||||||
|
FXUtils.onClicked(room, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(room);
|
||||||
|
} else if (state instanceof TerracottaState.GuestOK guestOK) {
|
||||||
|
if (guestOK.isForkOf(legacyState)) {
|
||||||
|
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(guestOK.getProfiles());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
statusProperty.set(i18n("terracotta.status.guest_ok"));
|
||||||
|
progressProperty.set(1);
|
||||||
|
|
||||||
|
LineButton tutorial = LineButton.of();
|
||||||
|
tutorial.setTitle(i18n("terracotta.status.guest_ok.title"));
|
||||||
|
tutorial.setSubtitle(i18n("terracotta.status.guest_ok.desc", guestOK.getUrl()));
|
||||||
|
|
||||||
|
LineButton back = LineButton.of();
|
||||||
|
back.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
back.setTitle(i18n("terracotta.back"));
|
||||||
|
back.setSubtitle(i18n("terracotta.status.guest_ok.back"));
|
||||||
|
FXUtils.onClicked(back, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
|
||||||
|
}
|
||||||
|
} else if (state instanceof TerracottaState.Exception exception) {
|
||||||
|
statusProperty.set(i18n("terracotta.status.exception.desc." + exception.getType().name().toLowerCase(Locale.ROOT)));
|
||||||
|
progressProperty.set(1);
|
||||||
|
nodesProperty.setAll();
|
||||||
|
|
||||||
|
LineButton back = LineButton.of();
|
||||||
|
back.setLeftIcon(SVG.ARROW_BACK);
|
||||||
|
back.setTitle(i18n("terracotta.back"));
|
||||||
|
back.setSubtitle(i18n("terracotta.status.exception.back"));
|
||||||
|
FXUtils.onClicked(back, () -> {
|
||||||
|
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
SpinnerPane exportLog = new SpinnerPane();
|
||||||
|
LineButton exportLogInner = LineButton.of();
|
||||||
|
exportLogInner.setLeftIcon(SVG.OUTPUT);
|
||||||
|
exportLogInner.setTitle(i18n("terracotta.export_log"));
|
||||||
|
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
|
||||||
|
exportLog.setContent(exportLogInner);
|
||||||
|
exportLog.getProperties().put("ComponentList.noPadding", true);
|
||||||
|
// FIXME: SpinnerPane loses its content width in loading state.
|
||||||
|
exportLog.minHeightProperty().bind(back.heightProperty());
|
||||||
|
|
||||||
|
FXUtils.onClicked(exportLogInner, () -> {
|
||||||
|
exportLog.setLoading(true);
|
||||||
|
|
||||||
|
TerracottaManager.exportLogs().thenAcceptAsync(Schedulers.io(), data -> {
|
||||||
|
if (data == null || data.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path path = Path.of("terracotta-log-" + LocalDateTime.now().format(
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")
|
||||||
|
) + ".zip").toAbsolutePath();
|
||||||
|
try (Zipper zipper = new Zipper(path)) {
|
||||||
|
zipper.putTextFile(data, StandardCharsets.UTF_8, "terracotta.log");
|
||||||
|
try (OutputStream os = zipper.putStream("hmcl-latest.log")) {
|
||||||
|
Logger.LOG.exportLogs(os);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FXUtils.showFileInExplorer(path);
|
||||||
|
}).thenRunAsync(
|
||||||
|
() -> Thread.sleep(3000)
|
||||||
|
).whenComplete(
|
||||||
|
Schedulers.javafx(),
|
||||||
|
e -> exportLog.setLoading(false)
|
||||||
|
).start();
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesProperty.setAll(back, exportLog);
|
||||||
|
} else if (state instanceof TerracottaState.Fatal fatal) {
|
||||||
|
String message = i18n("terracotta.status.fatal." + fatal.getType().name().toLowerCase(Locale.ROOT));
|
||||||
|
|
||||||
|
statusProperty.set(message);
|
||||||
|
progressProperty.set(1);
|
||||||
|
|
||||||
|
if (fatal.isRecoverable()) {
|
||||||
|
LineButton retry = LineButton.of();
|
||||||
|
retry.setLeftIcon(SVG.RESTORE);
|
||||||
|
retry.setTitle(i18n("terracotta.status.fatal.retry"));
|
||||||
|
retry.setSubtitle(message);
|
||||||
|
FXUtils.onClicked(retry, () -> {
|
||||||
|
TerracottaState s = TerracottaManager.recover(null);
|
||||||
|
if (s != null) {
|
||||||
|
UI_STATE.set(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||||
|
nodesProperty.setAll(retry, getThirdPartyDownloadNodes());
|
||||||
|
} else {
|
||||||
|
nodesProperty.setAll(retry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodesProperty.setAll();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new AssertionError(state.getClass().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentList components = new ComponentList();
|
||||||
|
{
|
||||||
|
VBox statusPane = new VBox(8);
|
||||||
|
VBox.setMargin(statusPane, new Insets(0, 0, 0, 4));
|
||||||
|
{
|
||||||
|
Label status = new Label();
|
||||||
|
status.textProperty().bind(statusProperty);
|
||||||
|
JFXProgressBar progress = new JFXProgressBar();
|
||||||
|
progress.progressProperty().bind(progressProperty);
|
||||||
|
progress.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
|
||||||
|
statusPane.getChildren().setAll(status, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
ObservableList<Node> children = components.getContent();
|
||||||
|
children.add(statusPane);
|
||||||
|
children.addAll(nodesProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
transition.setContent(components, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||||
|
};
|
||||||
|
listener.changed(UI_STATE, null, UI_STATE.get());
|
||||||
|
holder.add(listener);
|
||||||
|
UI_STATE.addListener(new WeakChangeListener<>(listener));
|
||||||
|
|
||||||
|
VBox content = new VBox(10);
|
||||||
|
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("terracotta.status")), transition);
|
||||||
|
content.setPadding(new Insets(10));
|
||||||
|
content.setFillWidth(true);
|
||||||
|
|
||||||
|
ScrollPane scrollPane = new ScrollPane(content);
|
||||||
|
FXUtils.smoothScrolling(scrollPane);
|
||||||
|
scrollPane.setFitToWidth(true);
|
||||||
|
|
||||||
|
getChildren().setAll(scrollPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentList getThirdPartyDownloadNodes() {
|
||||||
|
ComponentSublist locals = new ComponentSublist();
|
||||||
|
locals.setComponentPadding(false);
|
||||||
|
|
||||||
|
LineButton header = LineButton.of(false);
|
||||||
|
header.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||||
|
header.setTitle(i18n("terracotta.from_local.title"));
|
||||||
|
header.setSubtitle(i18n("terracotta.from_local.desc"));
|
||||||
|
locals.setHeaderLeft(header);
|
||||||
|
|
||||||
|
for (TerracottaMetadata.Link link : TerracottaMetadata.getPackageLinks()) {
|
||||||
|
HBox node = new HBox();
|
||||||
|
node.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
node.setPadding(new Insets(10, 16, 10, 16));
|
||||||
|
|
||||||
|
Label description = new Label(link.description().getText(I18n.getLocale().getCandidateLocales()));
|
||||||
|
HBox placeholder = new HBox();
|
||||||
|
HBox.setHgrow(placeholder, Priority.ALWAYS);
|
||||||
|
Node icon = SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16);
|
||||||
|
node.getChildren().setAll(description, placeholder, icon);
|
||||||
|
|
||||||
|
String url = link.link();
|
||||||
|
RipplerContainer container = new RipplerContainer(node);
|
||||||
|
container.setOnMouseClicked(ev -> Controllers.dialog(
|
||||||
|
i18n("terracotta.from_local.guide", TerracottaMetadata.PACKAGE_NAME),
|
||||||
|
i18n("message.info"), MessageDialogPane.MessageType.INFO,
|
||||||
|
() -> FXUtils.openLink(url)
|
||||||
|
));
|
||||||
|
container.getProperties().put("ComponentList.noPadding", true);
|
||||||
|
locals.getContent().add(container);
|
||||||
|
}
|
||||||
|
return locals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class LineButton extends RipplerContainer {
|
||||||
|
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||||
|
|
||||||
|
private final TwoLineListItem middle = new TwoLineListItem();
|
||||||
|
private final ObjectProperty<Node> left = new SimpleObjectProperty<>();
|
||||||
|
private final ObjectProperty<Node> right = new SimpleObjectProperty<>();
|
||||||
|
|
||||||
|
public static LineButton of() {
|
||||||
|
return of(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LineButton of(boolean padding) {
|
||||||
|
HBox container = new HBox();
|
||||||
|
if (padding) {
|
||||||
|
container.setPadding(new Insets(10, 16, 10, 16));
|
||||||
|
}
|
||||||
|
container.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
container.setCursor(Cursor.HAND);
|
||||||
|
container.setSpacing(16);
|
||||||
|
|
||||||
|
LineButton button = new LineButton(container);
|
||||||
|
VBox spacing = new VBox();
|
||||||
|
HBox.setHgrow(spacing, Priority.ALWAYS);
|
||||||
|
button.holder.add(FXUtils.observeWeak(() -> {
|
||||||
|
List<Node> nodes = new ArrayList<>(4);
|
||||||
|
Node left = button.left.get();
|
||||||
|
if (left != null) {
|
||||||
|
nodes.add(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.add(button.middle);
|
||||||
|
nodes.add(spacing);
|
||||||
|
|
||||||
|
Node right = button.right.get();
|
||||||
|
if (right != null) {
|
||||||
|
nodes.add(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.getChildren().setAll(nodes);
|
||||||
|
}, button.middle.titleProperty(), button.middle.subtitleProperty(), button.left, button.right));
|
||||||
|
button.getProperties().put("ComponentList.noPadding", true);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LineButton(Node container) {
|
||||||
|
super(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.middle.setTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubtitle(String subtitle) {
|
||||||
|
this.middle.setSubtitle(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLeftImage(Image left) {
|
||||||
|
this.left.set(new ImageView(left));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLeftIcon(SVG left) {
|
||||||
|
this.left.set(left.createIcon(Theme.blackFill(), 28));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRightIcon(SVG right) {
|
||||||
|
this.right.set(right.createIcon(Theme.blackFill(), 28));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PlayerProfileUI extends VBox {
|
||||||
|
private final TransitionPane transition;
|
||||||
|
|
||||||
|
public PlayerProfileUI(List<TerracottaProfile> profiles) {
|
||||||
|
super(8);
|
||||||
|
VBox.setMargin(this, new Insets(0, 0, 0, 4));
|
||||||
|
{
|
||||||
|
Label status = new Label();
|
||||||
|
status.setText(i18n("terracotta.player_list"));
|
||||||
|
|
||||||
|
transition = new TransitionPane();
|
||||||
|
getChildren().setAll(status, transition);
|
||||||
|
|
||||||
|
updateProfiles(profiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProfiles(List<TerracottaProfile> profiles) {
|
||||||
|
VBox pane = new VBox(8);
|
||||||
|
|
||||||
|
for (TerracottaProfile profile : profiles) {
|
||||||
|
TwoLineListItem item = new TwoLineListItem();
|
||||||
|
item.setTitle(profile.getName());
|
||||||
|
item.setSubtitle(profile.getVendor());
|
||||||
|
item.getTags().setAll(TwoLineListItem.createTagLabel(
|
||||||
|
i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)))
|
||||||
|
);
|
||||||
|
|
||||||
|
pane.getChildren().add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transition.setContent(pane, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher
|
||||||
|
* Copyright (C) 2025 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.terracotta;
|
||||||
|
|
||||||
|
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||||
|
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||||
|
import org.jackhuang.hmcl.Metadata;
|
||||||
|
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.AdvancedListBox;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.PageAware;
|
||||||
|
import org.jackhuang.hmcl.ui.construct.TabHeader;
|
||||||
|
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||||
|
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||||
|
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||||
|
|
||||||
|
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||||
|
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta")));
|
||||||
|
private final TabHeader tab;
|
||||||
|
private final TabHeader.Tab<TerracottaControllerPage> statusPage = new TabHeader.Tab<>("statusPage");
|
||||||
|
private final TransitionPane transitionPane = new TransitionPane();
|
||||||
|
|
||||||
|
public TerracottaPage() {
|
||||||
|
statusPage.setNodeSupplier(TerracottaControllerPage::new);
|
||||||
|
tab = new TabHeader(statusPage);
|
||||||
|
tab.select(statusPage);
|
||||||
|
|
||||||
|
transitionPane.setContent(statusPage.getNode(), ContainerAnimations.NONE);
|
||||||
|
FXUtils.onChange(tab.getSelectionModel().selectedItemProperty(), newValue -> {
|
||||||
|
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
|
||||||
|
});
|
||||||
|
|
||||||
|
AdvancedListItem chatItem = new AdvancedListItem();
|
||||||
|
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
||||||
|
chatItem.setActionButtonVisible(false);
|
||||||
|
chatItem.setTitle(i18n("chat"));
|
||||||
|
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||||
|
|
||||||
|
AdvancedListItem easytierItem = new AdvancedListItem();
|
||||||
|
easytierItem.setLeftGraphic(wrap(SVG.HOST));
|
||||||
|
easytierItem.setActionButtonVisible(false);
|
||||||
|
easytierItem.setTitle(i18n("terracotta.easytier"));
|
||||||
|
easytierItem.setOnAction(e -> FXUtils.openLink("https://easytier.cn/"));
|
||||||
|
|
||||||
|
AdvancedListBox sideBar = new AdvancedListBox()
|
||||||
|
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE)
|
||||||
|
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
|
||||||
|
.add(chatItem)
|
||||||
|
.add(easytierItem);
|
||||||
|
FXUtils.setLimitWidth(sideBar, 200);
|
||||||
|
setLeft(sideBar);
|
||||||
|
|
||||||
|
setCenter(transitionPane);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageShown() {
|
||||||
|
tab.onPageShown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageHidden() {
|
||||||
|
tab.onPageHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||||
|
return state.getReadOnlyProperty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
|
|||||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||||
|
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||||
import org.jackhuang.hmcl.util.platform.Platform;
|
import org.jackhuang.hmcl.util.platform.Platform;
|
||||||
|
|
||||||
import javax.swing.*;
|
import javax.swing.*;
|
||||||
@@ -62,7 +63,6 @@ import java.util.List;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.jar.Manifest;
|
|
||||||
|
|
||||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
import static java.util.stream.Collectors.toSet;
|
import static java.util.stream.Collectors.toSet;
|
||||||
@@ -253,14 +253,7 @@ public final class SelfDependencyPatcher {
|
|||||||
.map(DependencyDescriptor::localPath)
|
.map(DependencyDescriptor::localPath)
|
||||||
.toArray(Path[]::new);
|
.toArray(Path[]::new);
|
||||||
|
|
||||||
String addOpens = null;
|
String addOpens = JarUtils.getAttribute("hmcl.add-opens", null);
|
||||||
try (InputStream input = SelfDependencyPatcher.class.getResourceAsStream("/META-INF/MANIFEST.MF")) {
|
|
||||||
if (input != null)
|
|
||||||
addOpens = new Manifest(input).getMainAttributes().getValue("Add-Opens");
|
|
||||||
} catch (IOException e) {
|
|
||||||
LOG.warning("Failed to read MANIFEST.MF file", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]);
|
JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,5 +68,15 @@
|
|||||||
"title" : "Java Animated PNG",
|
"title" : "Java Animated PNG",
|
||||||
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
|
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
|
||||||
"externalLink" : "https://github.com/aellerton/japng"
|
"externalLink" : "https://github.com/aellerton/japng"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Terracotta",
|
||||||
|
"subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.",
|
||||||
|
"externalLink": "https://github.com/burningtnt/Terracotta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "EasyTier",
|
||||||
|
"subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy",
|
||||||
|
"externalLink": "https://github.com/EasyTier/EasyTier"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
BIN
HMCL/src/main/resources/assets/img/terracotta.png
Normal file
BIN
HMCL/src/main/resources/assets/img/terracotta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
HMCL/src/main/resources/assets/img/terracotta@2x.png
Normal file
BIN
HMCL/src/main/resources/assets/img/terracotta@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1413,6 +1413,76 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows
|
|||||||
system.architecture=Architecture
|
system.architecture=Architecture
|
||||||
system.operating_system=Operating System
|
system.operating_system=Operating System
|
||||||
|
|
||||||
|
terracotta=Multiplayer
|
||||||
|
terracotta.easytier=About EasyTier
|
||||||
|
terracotta.terracotta=Terracotta | Multiplayer
|
||||||
|
terracotta.status=Lobby
|
||||||
|
terracotta.back=Exit
|
||||||
|
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
|
||||||
|
terracotta.from_local.title=Third-party download channels for Multiplayer Core
|
||||||
|
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
|
||||||
|
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
|
||||||
|
terracotta.from_local.file_name_mismatch=You should download the Multiplayer Core package named %1$s instead of %2$s
|
||||||
|
terracotta.export_log=Exports the Multiplayer Core log
|
||||||
|
terracotta.export_log.desc=Gathering more information for analysis
|
||||||
|
terracotta.status.bootstrap=Gathering information
|
||||||
|
terracotta.status.uninitialized.not_exist=Multiplayer Core: Not Downloaded
|
||||||
|
terracotta.status.uninitialized.not_exist.title=Download Multiplayer Core (~ 8MiB)
|
||||||
|
terracotta.status.uninitialized.update=Multiplayer Core: Update Available
|
||||||
|
terracotta.status.uninitialized.update.title=Update Multiplayer Core (~ 8MiB)
|
||||||
|
terracotta.status.uninitialized.desc=You legally promise to strictly abide by all laws and regulations of your country or region during the multiplayer process.
|
||||||
|
terracotta.confirm.title=User Notice
|
||||||
|
terracotta.confirm.desc=Terracotta is a third-party open source free software, which has little relationship with HMCL.\n\
|
||||||
|
Terracotta is based on P2P, so that the final experience depends greatly on your network condition.\n\
|
||||||
|
You promise to strictly abide by all laws and regulations in your country or region when playing online.
|
||||||
|
terracotta.status.preparing=Multiplayer Core: Downloading (DO NOT exit HMCL)
|
||||||
|
terracotta.status.launching=Multiplayer Core: Initializing
|
||||||
|
terracotta.status.unknown=Multiplayer Core: Initializing
|
||||||
|
terracotta.status.waiting=Multiplayer Core: Ready
|
||||||
|
terracotta.status.waiting.host.title=I want to host a session
|
||||||
|
terracotta.status.waiting.host.desc=Create a room and generate an invite code to play with friends
|
||||||
|
terracotta.status.waiting.host.launch.title=You seem to have forgotten to launch the game
|
||||||
|
terracotta.status.waiting.host.launch.desc=No running game found
|
||||||
|
terracotta.status.waiting.host.launch.skip=Game has launched
|
||||||
|
terracotta.status.waiting.guest.title=I want to join a session
|
||||||
|
terracotta.status.waiting.guest.desc=Enter the invite code from the host player to join the game world
|
||||||
|
terracotta.status.waiting.guest.prompt.title=Please enter the invite code from the host
|
||||||
|
terracotta.status.waiting.guest.prompt.invalid=Invalid invite code
|
||||||
|
terracotta.status.scanning=Scanning LAN worlds
|
||||||
|
terracotta.status.scanning.desc=Please <a href="hmcl://game/launch">start the game</a>, open a world, press ESC, select "Open to LAN", then select "Start LAN World".
|
||||||
|
terracotta.status.scanning.back=This will also stop scanning LAN worlds.
|
||||||
|
terracotta.status.host_starting=Room Creating
|
||||||
|
terracotta.status.host_starting.back=This will stop creating the room.
|
||||||
|
terracotta.status.host_ok=Room created
|
||||||
|
terracotta.status.host_ok.code=Invitation code (Copied)
|
||||||
|
terracotta.status.host_ok.code.copy=Copy invitation code
|
||||||
|
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
|
||||||
|
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
|
||||||
|
terracotta.status.guest_starting=Joining room
|
||||||
|
terracotta.status.guest_starting.back=This will not stop other guests from joining the room.
|
||||||
|
terracotta.status.guest_ok=Room Joined
|
||||||
|
terracotta.status.guest_ok.back=This will not stop other guests from joining the room.
|
||||||
|
terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby.
|
||||||
|
terracotta.status.guest_ok.desc=Backup address: %s
|
||||||
|
terracotta.status.exception.back=Please try again
|
||||||
|
terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable
|
||||||
|
terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable
|
||||||
|
terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers
|
||||||
|
terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers
|
||||||
|
terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically
|
||||||
|
terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers
|
||||||
|
terracotta.status.fatal.retry=Retry
|
||||||
|
terracotta.status.fatal.os=Sorry, HMCL cannot enable Terracotta | Multiplayer on your operating system or architecture. Please use a more modern operating system.
|
||||||
|
terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again.
|
||||||
|
terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core.
|
||||||
|
terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core.
|
||||||
|
terracotta.status.fatal.unknown=Fatal Error: Unknown.
|
||||||
|
terracotta.player_list=Player List
|
||||||
|
terracotta.player_anonymous=Anonymous Player
|
||||||
|
terracotta.player_kind.host=Host
|
||||||
|
terracotta.player_kind.local=Yourself
|
||||||
|
terracotta.player_kind.guest=Guest
|
||||||
|
|
||||||
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
|
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
|
||||||
|
|
||||||
update=Update
|
update=Update
|
||||||
|
|||||||
@@ -1201,6 +1201,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine
|
|||||||
system.architecture=架構
|
system.architecture=架構
|
||||||
system.operating_system=作業系統
|
system.operating_system=作業系統
|
||||||
|
|
||||||
|
terracotta=多人遊戲
|
||||||
|
terracotta.easytier=關於 EasyTier
|
||||||
|
terracotta.terracotta=Terracotta | 陶瓦聯機
|
||||||
|
terracotta.status=聯機大廳
|
||||||
|
terracotta.back=退出
|
||||||
|
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
|
||||||
|
terracotta.from_local.title=線上核心第三方下載管道
|
||||||
|
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
|
||||||
|
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
|
||||||
|
terracotta.from_local.file_name_mismatch=您應該下載名為 %1$s 的線上核心包,而非 %2$s
|
||||||
|
terracotta.export_log=匯出線上核心日誌
|
||||||
|
terracotta.export_log.desc=為分析錯誤提供更多信息
|
||||||
|
terracotta.status.bootstrap=正在收集資訊
|
||||||
|
terracotta.status.uninitialized.not_exist=未下載聯機核心
|
||||||
|
terracotta.status.uninitialized.not_exist.title=下載聯機核心(約 8MiB)
|
||||||
|
terracotta.status.uninitialized.update=需更新聯機核心
|
||||||
|
terracotta.status.uninitialized.update.title=更新聯機核心(約 8MiB)
|
||||||
|
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
|
||||||
|
terracotta.confirm.title=使用者須知
|
||||||
|
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,與 HMCL 無強關聯性。\n\
|
||||||
|
多人連線基於 p2p,最終線上體驗和您的網路情況有較大關係。\n\
|
||||||
|
您承諾,在多人連線全過程中,您將嚴格遵守您所在國家或地區的全部法律法規。
|
||||||
|
terracotta.status.preparing=正在下載聯機核心(請勿退出啟動器)
|
||||||
|
terracotta.status.launching=正在初始化聯機核心
|
||||||
|
terracotta.status.unknown=正在初始化聯機核心
|
||||||
|
terracotta.status.waiting=聯機核心已就緒
|
||||||
|
terracotta.status.waiting.host.title=我想當房主
|
||||||
|
terracotta.status.waiting.host.desc=建立房間並產生邀請碼,與好友一起暢玩
|
||||||
|
terracotta.status.waiting.host.launch.title=您似乎忘記啟動遊戲了
|
||||||
|
terracotta.status.waiting.host.launch.desc=未能找到正在執行的遊戲
|
||||||
|
terracotta.status.waiting.host.launch.skip=遊戲已啟動
|
||||||
|
terracotta.status.waiting.guest.title=我想當房客
|
||||||
|
terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲世界
|
||||||
|
terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼
|
||||||
|
terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤
|
||||||
|
terracotta.status.scanning=正在掃描區域網路世界
|
||||||
|
terracotta.status.scanning.desc=請<a href="hmcl://game/launch">啟動遊戲</a>,進入單人存檔,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。
|
||||||
|
terracotta.status.scanning.back=這將同時停止掃描區域網路世界。
|
||||||
|
terracotta.status.host_starting=正在建立房間
|
||||||
|
terracotta.status.host_starting.back=這將會取消建立房間。
|
||||||
|
terracotta.status.host_ok=已建立房間
|
||||||
|
terracotta.status.host_ok.code=邀請碼(已自動複製到剪貼簿)
|
||||||
|
terracotta.status.host_ok.code.copy=複製邀請碼
|
||||||
|
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
|
||||||
|
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
|
||||||
|
terracotta.status.guest_starting=正在加入房間
|
||||||
|
terracotta.status.guest_starting.back=這不會影響其他房客加入目前房間。
|
||||||
|
terracotta.status.guest_ok=已加入房間
|
||||||
|
terracotta.status.guest_ok.back=這不會影響其他房客加入目前房間。
|
||||||
|
terracotta.status.guest_ok.title=請啟動遊戲,選擇多人遊戲,雙擊進入陶瓦聯機大廳。
|
||||||
|
terracotta.status.guest_ok.desc=備用連線位址:%s
|
||||||
|
terracotta.status.exception.back=可再試一次
|
||||||
|
terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已關閉或網路不穩定
|
||||||
|
terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定
|
||||||
|
terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題
|
||||||
|
terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題
|
||||||
|
terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲存檔,房間已自動關閉
|
||||||
|
terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題
|
||||||
|
terracotta.status.fatal.retry=重試
|
||||||
|
terracotta.status.fatal.os=抱歉,HMCL 不能在您的作業系統或架構上啟用多人連線。請使用更主流的作業系統
|
||||||
|
terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次
|
||||||
|
terracotta.status.fatal.install=嚴重錯誤:無法安裝線上核心
|
||||||
|
terracotta.status.fatal.terracotta=嚴重錯誤:無法與線上核心通訊
|
||||||
|
terracotta.status.fatal.unknown=嚴重錯誤:原因未知
|
||||||
|
terracotta.player_list=玩家列表
|
||||||
|
terracotta.player_anonymous=匿名玩家
|
||||||
|
terracotta.player_kind.host=房主
|
||||||
|
terracotta.player_kind.local=你
|
||||||
|
terracotta.player_kind.guest=房客
|
||||||
|
|
||||||
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
|
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
|
||||||
|
|
||||||
update=啟動器更新
|
update=啟動器更新
|
||||||
|
|||||||
@@ -1211,6 +1211,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代
|
|||||||
system.architecture=架构
|
system.architecture=架构
|
||||||
system.operating_system=操作系统
|
system.operating_system=操作系统
|
||||||
|
|
||||||
|
terracotta=多人联机
|
||||||
|
terracotta.easytier=关于 EasyTier
|
||||||
|
terracotta.terracotta=Terracotta | 陶瓦联机
|
||||||
|
terracotta.status=联机大厅
|
||||||
|
terracotta.back=退出
|
||||||
|
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
|
||||||
|
terracotta.from_local.title=联机核心第三方下载渠道
|
||||||
|
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
|
||||||
|
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
|
||||||
|
terracotta.from_local.file_name_mismatch=您应当下载名为 %1$s 的联机核心包,而非 %2$s
|
||||||
|
terracotta.export_log=导出联机核心日志
|
||||||
|
terracotta.export_log.desc=为分析错误提供更多信息
|
||||||
|
terracotta.status.bootstrap=正在收集信息
|
||||||
|
terracotta.status.uninitialized.not_exist=未下载联机核心
|
||||||
|
terracotta.status.uninitialized.not_exist.title=下载联机核心(约 8MiB)
|
||||||
|
terracotta.status.uninitialized.update=需更新联机核心
|
||||||
|
terracotta.status.uninitialized.update.title=更新联机核心(约 8MiB)
|
||||||
|
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
|
||||||
|
terracotta.confirm.title=用户须知
|
||||||
|
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,与 HMCL 无强关联性。\n\
|
||||||
|
多人联机基于 p2p,最终联机体验和您的网络情况有较大关系。\n\
|
||||||
|
在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规。
|
||||||
|
terracotta.status.preparing=正在下载联机核心(请勿退出启动器)
|
||||||
|
terracotta.status.launching=正在初始化联机核心
|
||||||
|
terracotta.status.unknown=正在初始化联机核心
|
||||||
|
terracotta.status.waiting=联机核心已就绪
|
||||||
|
terracotta.status.waiting.host.title=我想当房主
|
||||||
|
terracotta.status.waiting.host.desc=创建房间并生成邀请码,与好友一起畅玩
|
||||||
|
terracotta.status.waiting.host.launch.title=您似乎忘记启动游戏了
|
||||||
|
terracotta.status.waiting.host.launch.desc=未能找到正在运行的游戏
|
||||||
|
terracotta.status.waiting.host.launch.skip=游戏已启动
|
||||||
|
terracotta.status.waiting.guest.title=我想当房客
|
||||||
|
terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏世界
|
||||||
|
terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码
|
||||||
|
terracotta.status.waiting.guest.prompt.invalid=邀请码错误
|
||||||
|
terracotta.status.scanning=正在扫描局域网世界
|
||||||
|
terracotta.status.scanning.desc=请<a href="hmcl://game/launch">启动游戏</a>,进入单人存档,按下 ESC 键,选择对局域网开放,点击创建局域网世界。
|
||||||
|
terracotta.status.scanning.back=这将同时停止扫描局域网世界。
|
||||||
|
terracotta.status.host_starting=正在启动房间
|
||||||
|
terracotta.status.host_starting.back=这将会取消创建房间。
|
||||||
|
terracotta.status.host_ok=已启动房间
|
||||||
|
terracotta.status.host_ok.code=邀请码(已自动复制到剪贴板)
|
||||||
|
terracotta.status.host_ok.code.copy=复制邀请码
|
||||||
|
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
|
||||||
|
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
|
||||||
|
terracotta.status.guest_starting=正在加入房间
|
||||||
|
terracotta.status.guest_starting.back=这不会影响其他房客加入当前房间。
|
||||||
|
terracotta.status.guest_ok=已加入房间
|
||||||
|
terracotta.status.guest_ok.back=这不会影响其他房客加入当前房间。
|
||||||
|
terracotta.status.guest_ok.title=请启动游戏,选择多人游戏,双击进入陶瓦联机大厅。
|
||||||
|
terracotta.status.guest_ok.desc=备用联机地址:%s
|
||||||
|
terracotta.status.exception.back=可再试一次
|
||||||
|
terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已关闭或网络不稳定
|
||||||
|
terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定
|
||||||
|
terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||||
|
terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||||
|
terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏存档,房间已自动关闭
|
||||||
|
terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题
|
||||||
|
terracotta.status.fatal.retry=重试
|
||||||
|
terracotta.status.fatal.os=抱歉,HMCL 不能在您的操作系统或架构上启用多人联机。请使用更主流的操作系统
|
||||||
|
terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次
|
||||||
|
terracotta.status.fatal.install=严重错误:无法安装联机核心
|
||||||
|
terracotta.status.fatal.terracotta=严重错误:无法与联机核心通讯
|
||||||
|
terracotta.status.fatal.unknown=严重错误:原因未知
|
||||||
|
terracotta.player_list=玩家列表
|
||||||
|
terracotta.player_anonymous=匿名玩家
|
||||||
|
terracotta.player_kind.host=房主
|
||||||
|
terracotta.player_kind.local=你
|
||||||
|
terracotta.player_kind.guest=房客
|
||||||
|
|
||||||
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
|
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
|
||||||
|
|
||||||
update=启动器更新
|
update=启动器更新
|
||||||
|
|||||||
43
HMCL/src/main/resources/assets/terracotta.json
Normal file
43
HMCL/src/main/resources/assets/terracotta.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"version_legacy": "0\\.3\\.[89]-rc.[0-9]",
|
||||||
|
"version_recent": [
|
||||||
|
"0.3.9-rc.9"
|
||||||
|
],
|
||||||
|
"version_latest": "0.3.9-rc.10",
|
||||||
|
"classifiers": {
|
||||||
|
"linux-arm64": "sha256:d5d386e9936d99dd8c31cdab2098a9db04bf55263faf5cd27d388139a42dee0b",
|
||||||
|
"linux-x86_64": "sha256:c43f111460c777315f12081d934729c08cc67b1a2048fdf3ae451e2682157dd8",
|
||||||
|
"macos-arm64": "sha256:49cbf5880aa94551951abda0e225bbbd9c43b9078ead8a738f5b995085d6022c",
|
||||||
|
"macos-arm64.pkg": "sha256:756831f50d67ed2382f8507dfcc94fcc32d6082e087cd4f6a2fc40ae39f25cdf",
|
||||||
|
"macos-x86_64": "sha256:fdf519f018ce4b3d5b4be06b86dcf9990e94b4010db9dac391175d917214cc01",
|
||||||
|
"macos-x86_64.pkg": "sha256:fc030f0205aa3667033c79ac514045bb7c94ae0175dc5946ea920eefee7ad997",
|
||||||
|
"windows-arm64.exe": "sha256:4b395093b0acaea85a36ce1e569c206d9dfa7e11bfe70409f00bc6c6100df88e",
|
||||||
|
"windows-x86_64.exe": "sha256:2b8add8f6330452d29faaa944ac63c35452e61c5a4ff009069d2c3017c7186c6"
|
||||||
|
},
|
||||||
|
"downloads": [
|
||||||
|
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||||
|
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||||
|
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}",
|
||||||
|
"https://ghfast.top/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||||
|
"https://cdn.crashmc.com/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||||
|
"https://cp.zkitefly.eu.org/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"desc": {
|
||||||
|
"default": "GitHub Release",
|
||||||
|
"zh": "GitHub 发布页",
|
||||||
|
"zh-Hant": "GitHub 發布頁"
|
||||||
|
},
|
||||||
|
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": {
|
||||||
|
"default": "Tencent QQ Group",
|
||||||
|
"zh": "QQ 群",
|
||||||
|
"zh-Hant": "QQ 群"
|
||||||
|
},
|
||||||
|
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -424,7 +424,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
|||||||
|
|
||||||
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
||||||
|
|
||||||
public final void withResult(boolean success) {
|
public void withResult(boolean success) {
|
||||||
this.success = success;
|
this.success = success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
package org.jackhuang.hmcl.util.io;
|
package org.jackhuang.hmcl.util.io;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.function.ExceptionalPredicate;
|
import org.jackhuang.hmcl.util.function.ExceptionalPredicate;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
@@ -153,6 +154,31 @@ public final class Zipper implements Closeable {
|
|||||||
zos.closeEntry();
|
zos.closeEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OutputStream putStream(String path) throws IOException {
|
||||||
|
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||||
|
return new OutputStream() {
|
||||||
|
public void write(int b) throws IOException {
|
||||||
|
zos.write(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void write(@NotNull byte[] b) throws IOException {
|
||||||
|
zos.write(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void write(@NotNull byte[] b, int off, int len) throws IOException {
|
||||||
|
zos.write(b, off, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void flush() throws IOException {
|
||||||
|
zos.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() throws IOException {
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public void putLines(Stream<String> lines, String path) throws IOException {
|
public void putLines(Stream<String> lines, String path) throws IOException {
|
||||||
zos.putNextEntry(new ZipEntry(normalize(path)));
|
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public final class SystemUtils {
|
|||||||
return Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
|
return Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void onLogLine(String log) {
|
public static void onLogLine(String log) {
|
||||||
LOG.info(log);
|
LOG.info(log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ subprojects {
|
|||||||
options.encoding = "UTF-8"
|
options.encoding = "UTF-8"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
tasks.withType<Checkstyle> {
|
||||||
|
maxHeapSize.set("2g")
|
||||||
|
}
|
||||||
|
|
||||||
configure<CheckstyleExtension> {
|
configure<CheckstyleExtension> {
|
||||||
sourceSets = setOf()
|
sourceSets = setOf()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user