diff --git a/.github/workflows/check-codes.yml b/.github/workflows/check-codes.yml index 5a27e421d8..c09c84ded6 100644 --- a/.github/workflows/check-codes.yml +++ b/.github/workflows/check-codes.yml @@ -22,4 +22,4 @@ jobs: java-version: '17' java-package: 'jdk+fx' - name: Check Codes - run: ./gradlew checkstyle checkTranslations --no-daemon --parallel + run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index ba54ab2610..5c8d476b50 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -121,8 +121,25 @@ tasks.compileJava { 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 { add("hmcl.version" to project.version.toString()) + add("hmcl.add-opens" to addOpens.joinToString(" ")) System.getenv("GITHUB_SHA")?.let { 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 { enabled = false dependsOn(tasks["shadowJar"]) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 394224d522..ad1e6cd4e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -915,7 +915,15 @@ public void onExit(int exitCode, ExitType exitType) { } - public static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); + private static final Queue> 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() { while (!PROCESSES.isEmpty()) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java new file mode 100644 index 0000000000..0f6bf5f511 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java @@ -0,0 +1,325 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE); + private static final ReadOnlyObjectWrapper STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain()); + private static final InvocationDispatcher 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 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 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 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 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; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java new file mode 100644 index 0000000000..883ff67936 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java @@ -0,0 +1,203 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 recent, + @SerializedName("version_latest") String latest, + + @SerializedName("classifiers") Map classifiers, + @SerializedName("downloads") List downloads, + @SerializedName("links") List links + ) { + private TerracottaNative of(String classifier) { + List 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 PACKAGE_LINKS; + + private static final Pattern LEGACY; + private static final List 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 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 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 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 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java new file mode 100644 index 0000000000..079537978c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java @@ -0,0 +1,147 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 links; + private final FileDownloadTask.IntegrityCheck checking; + private final Path path; + + public TerracottaNative(List 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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java new file mode 100644 index 0000000000..246523aee2 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java @@ -0,0 +1,304 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 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 profiles; + + HostOK(int port, int index, String state, String code, int profileIndex, List 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 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 profiles; + + GuestOK(int port, int index, String state, String url, int profileIndex, List 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 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; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/ProfileKind.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/ProfileKind.java new file mode 100644 index 0000000000..7fcec7125f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/ProfileKind.java @@ -0,0 +1,29 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +package org.jackhuang.hmcl.terracotta.profile; + +import com.google.gson.annotations.SerializedName; + +public enum ProfileKind { + @SerializedName("HOST") + HOST, + @SerializedName("LOCAL") + LOCAL, + @SerializedName("GUEST") + GUEST +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/TerracottaProfile.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/TerracottaProfile.java new file mode 100644 index 0000000000..660e1d0d90 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/TerracottaProfile.java @@ -0,0 +1,57 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java new file mode 100644 index 0000000000..7e585e55a9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java @@ -0,0 +1,67 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 ofCommandLine(Path path) { + return List.of(target.getPath().toString(), "--hmcl", path.toString()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java new file mode 100644 index 0000000000..be6b8dd520 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java @@ -0,0 +1,77 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 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 ofCommandLine(Path path); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java new file mode 100644 index 0000000000..dbfc903f94 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java @@ -0,0 +1,97 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 ofCommandLine(Path path) { + assert binary != null; + + return List.of(binary.getPath().toString(), "--hmcl", path.toString()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 8a0b682a0a..346745edd9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -40,6 +40,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -55,8 +56,10 @@ import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider; import org.jackhuang.hmcl.ui.main.LauncherSettingsPage; 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.VersionPage; +import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Architecture; @@ -108,6 +111,7 @@ public final class Controllers { return accountListPage; }); private static Lazy settingsPage = new Lazy<>(LauncherSettingsPage::new); + private static Lazy terracottaPage = new Lazy<>(TerracottaPage::new); private Controllers() { } @@ -150,6 +154,11 @@ public static DownloadPage getDownloadPage() { return downloadPage.get(); } + // FXThread + public static Node getTerracottaPage() { + return terracottaPage.get(); + } + // FXThread public static DecoratorController getDecorator() { return decorator; @@ -424,6 +433,30 @@ public static void confirmAction(String text, String title, MessageType type, Bu 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 prompt(String title, FutureCallback onResult) { return prompt(title, onResult, ""); } @@ -470,6 +503,10 @@ public static void onHyperlinkAction(String href) { Controllers.getSettingsPage().showFeedback(); Controllers.navigate(Controllers.getSettingsPage()); break; + case "hmcl://game/launch": + Profile profile = Profiles.getSelectedProfile(); + Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep); + break; } } else { FXUtils.openLink(href); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 9b4bd3f09c..3118b32594 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -19,9 +19,6 @@ import com.jfoenix.controls.*; import com.jfoenix.validation.base.ValidatorBase; -import javafx.animation.KeyFrame; -import javafx.animation.KeyValue; -import javafx.animation.Timeline; import javafx.application.Platform; import javafx.beans.NamedArg; import javafx.beans.binding.BooleanBinding; @@ -39,7 +36,6 @@ import javafx.scene.control.TextInputControl; import javafx.scene.layout.*; -import javafx.util.Duration; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.CharacterSelector; @@ -268,33 +264,10 @@ private void onAccept() { }; if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) { - JFXButton btnYes = new JFXButton(i18n("button.ok")); - 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); - spinner.hideSpinner(); - } - ); - timeline.play(); + Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> { + body.setDisable(false); + spinner.hideSpinner(); + }); } else { doCreate.run(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java index 5603f158d3..459bb7c2bf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentListCell.java @@ -73,8 +73,7 @@ protected void layoutChildren() { @SuppressWarnings("unchecked") private void updateLayout() { - if (content instanceof ComponentList) { - ComponentList list = (ComponentList) content; + if (content instanceof ComponentList list) { content.getStyleClass().remove("options-list"); content.getStyleClass().add("options-sublist"); @@ -130,7 +129,10 @@ private void updateLayout() { groupNode.getChildren().add(headerRippler); VBox container = new VBox(); - container.setPadding(new Insets(8, 16, 10, 16)); + boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding(); + if (hasPadding) { + container.setPadding(new Insets(8, 16, 10, 16)); + } FXUtils.setLimitHeight(container, 0); FXUtils.setOverflowHidden(container); container.getChildren().setAll(content); @@ -149,7 +151,8 @@ private void updateLayout() { } 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 contentHeight = expanded ? newAnimatedHeight : 0; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java index d1bf8f54a0..3516399aac 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java @@ -18,7 +18,9 @@ package org.jackhuang.hmcl.ui.construct; import javafx.beans.DefaultProperty; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.Node; @@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList { private final ObjectProperty headerLeft = new SimpleObjectProperty<>(this, "headerLeft"); private final ObjectProperty headerRight = new SimpleObjectProperty<>(this, "headerRight"); + private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true); public ComponentSublist() { super(); @@ -55,4 +58,16 @@ public ObjectProperty headerRightProperty() { public void setHeaderRight(Node 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); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 61402b4ad5..31e04e6bbf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -182,11 +182,11 @@ protected Skin(RootPage control) { launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage())); // sixth item in left sidebar - 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 terracottaItem = new AdvancedListItem(); + terracottaItem.setLeftGraphic(wrap(SVG.HOST)); + terracottaItem.setActionButtonVisible(false); + terracottaItem.setTitle(i18n("terracotta")); + terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage())); // the left sidebar AdvancedListBox sideBar = new AdvancedListBox() @@ -198,7 +198,7 @@ protected Skin(RootPage control) { .add(downloadItem) .startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT)) .add(launcherSettingsItem) - .add(chatItem); + .add(terracottaItem); // the root page, with the sidebar in left, navigator in center. setLeft(sideBar); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java new file mode 100644 index 0000000000..250357bfb3 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -0,0 +1,661 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 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 statusProperty = new SimpleObjectProperty<>(); + DoubleProperty progressProperty = new SimpleDoubleProperty(); + ObservableList 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 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 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 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 left = new SimpleObjectProperty<>(); + private final ObjectProperty 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 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 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 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); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java new file mode 100644 index 0000000000..6a0fbae74a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -0,0 +1,92 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui 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 . + */ +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 = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta"))); + private final TabHeader tab; + private final TabHeader.Tab 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 stateProperty() { + return state.getReadOnlyProperty(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/SelfDependencyPatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/SelfDependencyPatcher.java index 816572294c..a4c36edff5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/SelfDependencyPatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/SelfDependencyPatcher.java @@ -47,6 +47,7 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.Platform; import javax.swing.*; @@ -62,7 +63,6 @@ import java.util.*; import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.jar.Manifest; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.stream.Collectors.toSet; @@ -253,14 +253,7 @@ private void loadFromCache() throws IOException, ReflectiveOperationException { .map(DependencyDescriptor::localPath) .toArray(Path[]::new); - String addOpens = 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); - } - + String addOpens = JarUtils.getAttribute("hmcl.add-opens", null); JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]); } diff --git a/HMCL/src/main/resources/assets/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index 7daa693592..d6d705f822 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -68,5 +68,15 @@ "title" : "Java Animated PNG", "subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.", "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" } ] \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/img/terracotta.png b/HMCL/src/main/resources/assets/img/terracotta.png new file mode 100644 index 0000000000..53276ef78d Binary files /dev/null and b/HMCL/src/main/resources/assets/img/terracotta.png differ diff --git a/HMCL/src/main/resources/assets/img/terracotta@2x.png b/HMCL/src/main/resources/assets/img/terracotta@2x.png new file mode 100644 index 0000000000..b41eee9009 Binary files /dev/null and b/HMCL/src/main/resources/assets/img/terracotta@2x.png differ diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 3e813e492f..420041abdc 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1413,6 +1413,76 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows system.architecture=Architecture 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 start the game, open a world, press ESC, select "Open to LAN", then select "Start LAN World". +terracotta.status.scanning.back=This will also stop scanning LAN worlds. +terracotta.status.host_starting=Room Creating +terracotta.status.host_starting.back=This will stop creating the room. +terracotta.status.host_ok=Room created +terracotta.status.host_ok.code=Invitation code (Copied) +terracotta.status.host_ok.code.copy=Copy invitation code +terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code. +terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin. +terracotta.status.guest_starting=Joining room +terracotta.status.guest_starting.back=This will not stop other guests from joining the room. +terracotta.status.guest_ok=Room Joined +terracotta.status.guest_ok.back=This will not stop other guests from joining the room. +terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby. +terracotta.status.guest_ok.desc=Backup address: %s +terracotta.status.exception.back=Please try again +terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable +terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable +terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers +terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers +terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically +terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers +terracotta.status.fatal.retry=Retry +terracotta.status.fatal.os=Sorry, HMCL cannot enable Terracotta | Multiplayer on your operating system or architecture. Please use a more modern operating system. +terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again. +terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core. +terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core. +terracotta.status.fatal.unknown=Fatal Error: Unknown. +terracotta.player_list=Player List +terracotta.player_anonymous=Anonymous Player +terracotta.player_kind.host=Host +terracotta.player_kind.local=Yourself +terracotta.player_kind.guest=Guest + unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security. update=Update diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 29ce5106b6..2461570f23 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1201,6 +1201,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine system.architecture=架構 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=請啟動遊戲,進入單人存檔,按 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。我們無法保證其安全性,請注意甄別。 update=啟動器更新 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 43603a3cbb..67a6537ccb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1211,6 +1211,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代 system.architecture=架构 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=请启动游戏,进入单人存档,按下 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。我们无法保证其安全性,请注意甄别。 update=启动器更新 diff --git a/HMCL/src/main/resources/assets/terracotta.json b/HMCL/src/main/resources/assets/terracotta.json new file mode 100644 index 0000000000..bfd13a595d --- /dev/null +++ b/HMCL/src/main/resources/assets/terracotta.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java index 47f0364ea0..adc537df01 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/FetchTask.java @@ -424,7 +424,7 @@ protected static abstract class Context implements Closeable { 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; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java index 023ad1ca8c..51e8220c9f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Zipper.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.util.io; import org.jackhuang.hmcl.util.function.ExceptionalPredicate; +import org.jetbrains.annotations.NotNull; import java.io.*; import java.nio.charset.Charset; @@ -153,6 +154,31 @@ public void putStream(InputStream in, String path) throws IOException { 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 lines, String path) throws IOException { zos.putNextEntry(new ZipEntry(normalize(path))); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java index 87916d3b69..3ea8b867ad 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/SystemUtils.java @@ -112,7 +112,7 @@ public static boolean supportJVMAttachment() { 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); } } diff --git a/build.gradle.kts b/build.gradle.kts index 512e7dca80..b3b3fe7caa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,11 @@ subprojects { options.encoding = "UTF-8" } + @Suppress("UnstableApiUsage") + tasks.withType { + maxHeapSize.set("2g") + } + configure { sourceSets = setOf() }