Terracotta | 陶瓦联机 (#4215)

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
Burning_TNT
2025-10-03 20:01:08 +08:00
committed by GitHub
parent 365bb089c5
commit 3c3e2b8cfc
31 changed files with 2453 additions and 70 deletions

View File

@@ -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

View File

@@ -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"])

View File

@@ -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())

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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();
} }

View File

@@ -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;

View File

@@ -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);
}
} }

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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]);
} }

View File

@@ -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"
} }
] ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -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 ProtocolHost 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

View File

@@ -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=啟動器更新

View File

@@ -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=启动器更新

View 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"
}
]
}

View File

@@ -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;
} }

View File

@@ -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)));

View File

@@ -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);
} }
} }

View File

@@ -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()
} }