From 3247b774100c3afb81b30b20b9a3577d5bc0d2c1 Mon Sep 17 00:00:00 2001 From: Burning_TNT Date: Sun, 4 Jan 2026 22:06:44 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=99=B6=E7=93=A6=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD=20=E5=B9=B6=20=E5=9C=A8=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E8=81=94=E6=9C=BA=E6=88=BF=E9=97=B4=E6=97=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20p2p=20=E9=9A=BE=E5=BA=A6=20(#4929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCL/build.gradle.kts | 26 ++ .../hmcl/terracotta/TerracottaBundle.java | 191 +++++++++++++ .../hmcl/terracotta/TerracottaManager.java | 262 ++++++++++-------- .../hmcl/terracotta/TerracottaMetadata.java | 168 ++++++----- .../hmcl/terracotta/TerracottaNative.java | 147 ---------- .../hmcl/terracotta/TerracottaState.java | 63 +++-- .../provider/AbstractTerracottaProvider.java | 63 +++++ .../terracotta/provider/GeneralProvider.java | 46 +-- .../provider/ITerracottaProvider.java | 77 ----- .../terracotta/provider/MacOSProvider.java | 102 +++---- .../terracotta/TerracottaControllerPage.java | 66 +++-- .../hmcl/ui/terracotta/TerracottaPage.java | 3 +- .../resources/assets/lang/I18N.properties | 6 +- .../resources/assets/lang/I18N_ar.properties | 1 - .../resources/assets/lang/I18N_zh.properties | 6 +- .../assets/lang/I18N_zh_CN.properties | 6 +- .../src/main/resources/assets/terracotta.json | 88 ++++-- HMCL/terracotta-template.json | 35 +++ .../org/jackhuang/hmcl/util/io/FileUtils.java | 21 ++ buildSrc/build.gradle.kts | 1 + .../gradle/TerracottaConfigUpgradeTask.java | 167 +++++++++++ gradle/libs.versions.toml | 1 + 22 files changed, 950 insertions(+), 596 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java create mode 100644 HMCL/terracotta-template.json create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 00137f2df..156969060 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils import org.jackhuang.hmcl.gradle.ci.JenkinsUtils import org.jackhuang.hmcl.gradle.l10n.CheckTranslations @@ -240,6 +241,11 @@ tasks.processResources { from(upsideDownTranslate.map { it.outputFile }) from(createLocaleNamesResourceBundle.map { it.outputDirectory }) } + + inputs.property("terracotta_version", libs.versions.terracotta) + doLast { + upgradeTerracottaConfig.get().checkValid() + } } val makeExecutables by tasks.registering { @@ -362,6 +368,26 @@ tasks.register("run") { } } +// terracotta + +val upgradeTerracottaConfig = tasks.register("upgradeTerracottaConfig") { + val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json") + val source = layout.projectDirectory.file("terracotta-template.json"); + + classifiers.set(listOf( + "windows-x86_64", "windows-arm64", + "macos-x86_64", "macos-arm64", + "linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64", + "freebsd-x86_64" + )) + + version.set(libs.versions.terracotta) + downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz") + + templateFile.set(source) + outputFile.set(destination) +} + // Check Translations tasks.register("checkTranslations") { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java new file mode 100644 index 000000000..727edde9b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java @@ -0,0 +1,191 @@ +/* + * 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.download.ArtifactMalformedException; +import org.jackhuang.hmcl.task.FetchTask; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.logging.Logger; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.tree.TarFileTree; + +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.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +public final class TerracottaBundle { + private final Path root; + + private final List links; + + private final FileDownloadTask.IntegrityCheck hash; + + private final Map files; + + public TerracottaBundle(Path root, List links, FileDownloadTask.IntegrityCheck hash, Map files) { + this.root = root; + this.links = links; + this.hash = hash; + this.files = files; + } + + public Task download(AbstractTerracottaProvider.DownloadContext context) { + return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz")) + .thenComposeAsync(Schedulers.javafx(), pkg -> { + FileDownloadTask download = new FileDownloadTask(links, pkg, hash) { + @Override + protected Context getContext(HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { + FetchTask.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 { + context.checkCancellation(); + delegate.write(buffer, offset, len); + } + + @Override + public void close() throws IOException { + if (isSuccess()) { + context.checkCancellation(); + } + + delegate.close(); + } + }; + } + }; + + context.bindProgress(download.progressProperty()); + return download.thenSupplyAsync(() -> pkg); + }); + } + + public Task install(Path pkg) { + return Task.runAsync(() -> { + Files.createDirectories(root); + FileUtils.cleanDirectory(root); + + try (TarFileTree tree = TarFileTree.open(pkg)) { + for (Map.Entry entry : files.entrySet()) { + String file = entry.getKey(); + FileDownloadTask.IntegrityCheck check = entry.getValue(); + + Path path = root.resolve(file); + TarArchiveEntry archive = tree.getEntry("/" + file); + if (archive == null) { + throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file)); + } + + MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); + try ( + InputStream is = tree.getInputStream(archive); + OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest) + ) { + is.transferTo(os); + } + + String hash = HexFormat.of().formatHex(digest.digest()); + if (!check.getChecksum().equalsIgnoreCase(hash)) { + throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash); + } + + switch (OperatingSystem.CURRENT_OS) { + case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode())); + } + } + } + }).whenComplete(exception -> { + if (exception != null) { + FileUtils.deleteDirectory(root); + } + }); + } + + public Path locate(String file) { + FileDownloadTask.IntegrityCheck check = files.get(file); + if (check == null) { + throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file)); + } + return root.resolve(file).toAbsolutePath(); + } + + public AbstractTerracottaProvider.Status status() throws IOException { + if (Files.exists(root) && isLocalBundleValid()) { + return AbstractTerracottaProvider.Status.READY; + } + + try { + if (TerracottaMetadata.hasLegacyVersionFiles()) { + return AbstractTerracottaProvider.Status.LEGACY_VERSION; + } + } catch (IOException e) { + Logger.LOG.warning("Cannot determine whether legacy versions exist.", e); + } + return AbstractTerracottaProvider.Status.NOT_EXIST; + } + + private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer. + long total = 0; + byte[] buffer = new byte[8192]; + + for (Map.Entry entry : files.entrySet()) { + Path path = root.resolve(entry.getKey()); + FileDownloadTask.IntegrityCheck check = entry.getValue(); + if (!Files.isReadable(path)) { + return false; + } + + MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); + try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) { + int n; + while ((n = is.read(buffer)) >= 0) { + total += n; + if (total >= 50 * 1024 * 1024) { // >=50MB + return false; + } + } + } + if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) { + return false; + } + } + return true; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java index ea7060860..f93b11d7c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java @@ -18,7 +18,6 @@ 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; @@ -29,24 +28,26 @@ 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.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.FXThread; 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.HttpRequest; 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.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; @@ -70,9 +71,10 @@ public final class TerracottaManager { 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())); + case READY -> launch(setState(new TerracottaState.Launching()), false); } } catch (Exception e) { + LOG.warning("Cannot initialize Terracotta.", e); compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN)); } }); @@ -82,114 +84,145 @@ public final class TerracottaManager { return STATE.getReadOnlyProperty(); } - static { - Lang.thread(() -> { - while (true) { - TerracottaState state = STATE_V.get(); - if (!(state instanceof TerracottaState.PortSpecific portSpecific)) { - LockSupport.parkNanos(500_000); + private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true); + + @FXThread // Written in FXThread, read-only on background daemon + private static volatile boolean daemonRunning = false; + + private static void runBackground() { + final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500); + final long BACKGROUND = TimeUnit.SECONDS.toMillis(15); + + while (true) { + if (daemonRunning) { + LockSupport.parkNanos(ACTIVE); + } else { + long deadline = System.currentTimeMillis() + BACKGROUND; + do { + LockSupport.parkUntil(deadline); + } while (!daemonRunning && System.currentTimeMillis() < deadline - 100); + } + + if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) { + continue; + } + int port = state.port; + int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE; + + TerracottaState next; + try { + TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port)) + .retry(5) + .getJson(TerracottaState.Ready.class); + if (object.index <= index) { 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); + object.port = port; + next = object; + } catch (Exception e) { + LOG.warning("Cannot fetch state from Terracotta.", e); + next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); } - }, "Terracotta Background Daemon", true); + + compareAndSet(state, next); + } } - public static boolean validate(Path file) { - return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME); - } - - public static TerracottaState.Preparing install(@Nullable Path file) { + @FXThread + public static void switchDaemon(boolean active) { 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 = 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 CancellationException) { - } 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)); + boolean dr = daemonRunning; + if (dr != active) { + daemonRunning = active; + if (active) { + LockSupport.unpark(DAEMON); } - }).start(); - - return compareAndSet(state, preparing) ? preparing : null; + } } - private static ITerracottaProvider getProvider() { - ITerracottaProvider provider = TerracottaMetadata.PROVIDER; + private static AbstractTerracottaProvider getProvider() { + AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER; if (provider == null) { throw new AssertionError("Terracotta Provider must NOT be null."); } return provider; } - public static TerracottaState recover(@Nullable Path file) { + public static boolean isInvalidBundle(Path file) { + return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME); + } + + @FXThread + public static TerracottaState.Preparing download() { + FXUtils.checkFxUserThread(); + + TerracottaState state = STATE_V.get(); + if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) + ) { + return null; + } + + TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true); + + Task.composeAsync(() -> getProvider().download(preparing)) + .thenComposeAsync(pkg -> { + if (!preparing.requestInstallFence()) { + return null; + } + + return getProvider().install(pkg).thenRunAsync(() -> { + TerracottaState.Launching launching = new TerracottaState.Launching(); + if (compareAndSet(preparing, launching)) { + launch(launching, true); + } + }); + }).whenComplete(exception -> { + if (exception instanceof CancellationException) { + // no-op + } 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 compareAndSet(state, preparing) ? preparing : null; + } + + @FXThread + public static TerracottaState.Preparing install(Path bundle) { + FXUtils.checkFxUserThread(); + if (isInvalidBundle(bundle)) { + return null; + } + + TerracottaState state = STATE_V.get(); + TerracottaState.Preparing preparing; + if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) { + preparing = previousPreparing; + } else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) { + preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false); + } else { + return null; + } + + Task.composeAsync(() -> getProvider().install(bundle)) + .thenRunAsync(() -> { + TerracottaState.Launching launching = new TerracottaState.Launching(); + if (compareAndSet(preparing, launching)) { + launch(launching, true); + } + }) + .whenComplete(exception -> { + compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); + }).start(); + + return state != preparing && compareAndSet(state, preparing) ? preparing : null; + } + + @FXThread + public static TerracottaState recover() { FXUtils.checkFxUserThread(); TerracottaState state = STATE_V.get(); @@ -198,21 +231,23 @@ public final class TerracottaManager { } try { + // FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making + // hash check accept 50MB at most. Calling it on JavaFX should be safe. return switch (getProvider().status()) { - case NOT_EXIST, LEGACY_VERSION -> install(file); + case NOT_EXIST, LEGACY_VERSION -> download(); case READY -> { TerracottaState.Launching launching = setState(new TerracottaState.Launching()); - launch(launching); + launch(launching, false); yield launching; } }; - } catch (NullPointerException | IOException e) { + } catch (RuntimeException | 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) { + private static void launch(TerracottaState.Launching state, boolean removeLegacy) { 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))); @@ -230,7 +265,7 @@ public final class TerracottaManager { if (exitTime == -1) { exitTime = System.currentTimeMillis(); } else if (System.currentTimeMillis() - exitTime >= 10000) { - throw new IllegalStateException("Process has exited for 10s."); + throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode())); } } } @@ -238,6 +273,9 @@ public final class TerracottaManager { TerracottaState next; if (exception == null) { next = new TerracottaState.Unknown(port); + if (removeLegacy) { + TerracottaMetadata.removeLegacyVersionFiles(); + } } else { next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); } @@ -272,23 +310,27 @@ public final class TerracottaManager { 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(); + String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/scanning", portSpecific.port), Map.of( + "player", getPlayerName() + )); + new GetTask(uri).setSignificance(Task.TaskSignificance.MINOR).start(); return new TerracottaState.HostScanning(-1, -1, null); } return null; } - public static Task setGuesting(String room) { + 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() - ))) + String uri = NetworkUtils.withQuery(String.format("http://127.0.0.1:%d/state/guesting", portSpecific.port), Map.of( + "room", room, + "player", getPlayerName() + )); + + return new GetTask(uri) .setSignificance(Task.TaskSignificance.MINOR) - .thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null)) + .thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null)) .setSignificance(Task.TaskSignificance.MINOR); } else { return null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java index e35387e93..b4502a9b3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java @@ -20,10 +20,10 @@ 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.AbstractTerracottaProvider; 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.Lang; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.i18n.LocalizedText; @@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionRange; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -40,11 +42,11 @@ 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.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -52,46 +54,60 @@ public final class TerracottaMetadata { private TerracottaMetadata() { } - public record Link(@SerializedName("desc") LocalizedText description, String link) { + private record Options(String version, String classifier) { + public String replace(String value) { + return value.replace("${version}", version).replace("${classifier}", classifier); + } } + @JsonSerializable + public record Link( + @SerializedName("desc") LocalizedText description, + @SerializedName("link") String link + ) { + } + + @JsonSerializable + private record Package( + @SerializedName("hash") String hash, + @SerializedName("files") Map files + ) { + } + + @JsonSerializable private record Config( - @SerializedName("version_legacy") String legacy, - @SerializedName("version_recent") List recent, @SerializedName("version_latest") String latest, - @SerializedName("classifiers") Map classifiers, + @SerializedName("packages") Map pkgs, @SerializedName("downloads") List downloads, @SerializedName("downloads_CN") List downloadsCN, @SerializedName("links") List links ) { - private @Nullable TerracottaNative of(String classifier) { - String hash = this.classifiers.get(classifier); - if (hash == null) + private @Nullable TerracottaBundle resolve(Options options) { + Package pkg = pkgs.get(options.classifier); + if (pkg == null) { return null; - - if (!hash.startsWith("sha256:")) - throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier)); - hash = hash.substring("sha256:".length()); - - List links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size()); - for (String download : LocaleUtils.IS_CHINA_MAINLAND - ? Lang.merge(this.downloadsCN, this.downloads) - : Lang.merge(this.downloads, this.downloadsCN)) { - links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier))); } - 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) + Stream stream = downloads.stream(), streamCN = downloadsCN.stream(); + List links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN)) + .map(link -> URI.create(options.replace(link))) + .toList(); + + Map files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue()) + )); + + return new TerracottaBundle( + Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(), + links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash), + files ); } } - public static final ITerracottaProvider PROVIDER; + public static final AbstractTerracottaProvider PROVIDER; public static final String PACKAGE_NAME; public static final List PACKAGE_LINKS; public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of( @@ -99,8 +115,6 @@ public final class TerracottaMetadata { "launcher_version", Metadata.VERSION )); - private static final Pattern LEGACY; - private static final List RECENT; private static final String LATEST; static { @@ -111,95 +125,73 @@ public final class TerracottaMetadata { 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) - )); - } + Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName()); + TerracottaBundle bundle = config.resolve(options); + AbstractTerracottaProvider provider; + if (bundle == null || (provider = locateProvider(bundle, options)) == null) { + PROVIDER = null; + PACKAGE_NAME = null; + PACKAGE_LINKS = null; + } else { + PROVIDER = provider; + PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz"); + List packageLinks = config.links.stream() + .map(link -> new Link(link.description, options.replace(link.link))) + .collect(Collectors.toList()); 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); } } @Nullable - private static ProviderContext locateProvider(Config config) { - String arch = Architecture.SYSTEM_ARCH.getCheckedName(); + private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) { + String prefix = options.replace("terracotta-${version}-${classifier}"); + + // FIXME: As HMCL is a cross-platform application, developers may mistakenly locate + // non-existent files in non-native platform logic without assertion errors during debugging. return switch (OperatingSystem.CURRENT_OS) { case WINDOWS -> { if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10)) yield null; - TerracottaNative target = config.of("windows-%s.exe".formatted(arch)); - yield target != null - ? new ProviderContext(new GeneralProvider(target), "windows", arch) - : null; - } - case LINUX -> { - TerracottaNative target = config.of("linux-%s".formatted(arch)); - yield target != null - ? new ProviderContext(new GeneralProvider(target), "linux", arch) - : null; - } - case MACOS -> { - TerracottaNative installer = config.of("macos-%s.pkg".formatted(arch)); - TerracottaNative binary = config.of("macos-%s".formatted(arch)); - - yield installer != null && binary != null - ? new ProviderContext(new MacOSProvider(installer, binary), "macos", arch) - : null; + yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe")); } + case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix)); + case MACOS -> new MacOSProvider( + bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg") + ); default -> null; }; } - public static void removeLegacyVersionFiles() throws IOException { - try (DirectoryStream terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) { + public static void removeLegacyVersionFiles() { + try (DirectoryStream terracotta = collectLegacyVersionFiles()) { 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); } } + } catch (IOException e) { + LOG.warning("Unable to remove legacy terracotta files.", 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; - } - } + try (DirectoryStream terracotta = collectLegacyVersionFiles()) { + return terracotta.iterator().hasNext(); } + } - return false; + private static DirectoryStream collectLegacyVersionFiles() throws IOException { + VersionRange range = VersionNumber.atMost(LATEST); + return Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta"), path -> { + String name = FileUtils.getName(path); + return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name)); + }); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java deleted file mode 100644 index 079537978..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNative.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * 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 index bce764013..25fa43e18 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java @@ -21,15 +21,16 @@ 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 javafx.beans.value.ObservableDoubleValue; import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile; -import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider; +import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; 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.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; public abstract sealed class TerracottaState { @@ -63,32 +64,38 @@ public abstract sealed class TerracottaState { } } - public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context { + public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext { private final ReadOnlyDoubleWrapper progress; - private final AtomicBoolean installFence = new AtomicBoolean(false); + private final AtomicBoolean hasInstallFence; - Preparing(ReadOnlyDoubleWrapper progress) { + Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) { this.progress = progress; + this.hasInstallFence = new AtomicBoolean(hasInstallFence); } public ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); } - @Override - public void bindProgress(ObservableValue value) { - progress.bind(value); - } - - @Override public boolean requestInstallFence() { - return installFence.compareAndSet(false, true); + return hasInstallFence.compareAndSet(true, false); + } + + public boolean hasInstallFence() { + return hasInstallFence.get(); } @Override - public boolean hasInstallFence() { - return !installFence.get(); + public void bindProgress(ObservableDoubleValue progress) { + this.progress.bind(progress); + } + + @Override + public void checkCancellation() { + if (!hasInstallFence()) { + throw new CancellationException("User has installed terracotta from local archives."); + } } } @@ -112,7 +119,7 @@ public abstract sealed class TerracottaState { @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 = GuestConnecting.class, name = "guest-connecting"), @JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"), @JsonSubtype(clazz = GuestOK.class, name = "guest-ok"), @JsonSubtype(clazz = Exception.class, name = "exception"), @@ -202,12 +209,34 @@ public abstract sealed class TerracottaState { } } - public static final class GuestStarting extends Ready { - GuestStarting(int port, int index, String state) { + public static final class GuestConnecting extends Ready { + GuestConnecting(int port, int index, String state) { super(port, index, state); } } + public static final class GuestStarting extends Ready { + public enum Difficulty { + UNKNOWN, + EASIEST, + SIMPLE, + MEDIUM, + TOUGH + } + + @SerializedName("difficulty") + private final Difficulty difficulty; + + GuestStarting(int port, int index, String state, Difficulty difficulty) { + super(port, index, state); + this.difficulty = difficulty; + } + + public Difficulty getDifficulty() { + return difficulty; + } + } + public static final class GuestOK extends Ready implements Validation { @SerializedName("url") private final String url; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java new file mode 100644 index 000000000..2e812b8d0 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java @@ -0,0 +1,63 @@ +/* + * 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.ObservableDoubleValue; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.terracotta.TerracottaBundle; +import org.jackhuang.hmcl.util.FXThread; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CancellationException; + +public abstract class AbstractTerracottaProvider { + public enum Status { + NOT_EXIST, + LEGACY_VERSION, + READY + } + + public interface DownloadContext { + @FXThread + void bindProgress(ObservableDoubleValue progress); + + void checkCancellation() throws CancellationException; + } + + protected final TerracottaBundle bundle; + + protected AbstractTerracottaProvider(TerracottaBundle bundle) { + this.bundle = bundle; + } + + public Status status() throws IOException { + return bundle.status(); + } + + public final Task download(DownloadContext progress) { + return bundle.download(progress); + } + + public Task install(Path pkg) throws IOException { + return bundle.install(pkg); + } + + public abstract List ofCommandLine(Path portTransfer); +} 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 index 7e585e55a..ca918eb9c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java @@ -17,51 +17,21 @@ */ 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 org.jackhuang.hmcl.terracotta.TerracottaBundle; -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 final class GeneralProvider extends AbstractTerracottaProvider { + private final Path executable; - public GeneralProvider(TerracottaNative target) { - this.target = target; + public GeneralProvider(TerracottaBundle bundle, Path executable) { + super(bundle); + this.executable = executable; } @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()); + public List ofCommandLine(Path portTransfer) { + return List.of(executable.toString(), "--hmcl", portTransfer.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 deleted file mode 100644 index be6b8dd52..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/ITerracottaProvider.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 index abb6195b4..83debfc0a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java @@ -19,103 +19,77 @@ package org.jackhuang.hmcl.terracotta.provider; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.terracotta.TerracottaNative; +import org.jackhuang.hmcl.terracotta.TerracottaBundle; import org.jackhuang.hmcl.util.io.FileUtils; 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.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; import java.util.List; -import java.util.Set; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class MacOSProvider implements ITerracottaProvider { - public final TerracottaNative installer, binary; +public final class MacOSProvider extends AbstractTerracottaProvider { + private final Path executable, installer; - public MacOSProvider(TerracottaNative installer, TerracottaNative binary) { + public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) { + super(bundle); + this.executable = executable; 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(); + return bundle.status(); } @Override - public Task install(Context context, @Nullable TarFileTree tree) throws IOException { - assert installer != null && binary != null; + public Task install(Path pkg) throws IOException { + return super.install(pkg).thenComposeAsync(() -> { + Path osascript = SystemUtils.which("osascript"); + if (osascript == null) { + throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); + } - 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 + Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") + .toRealPath() + .resolve(FileUtils.getName(installer)); + Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING); - return Task.allOf( - installerTask.thenComposeAsync(() -> { - Path osascript = SystemUtils.which("osascript"); - if (osascript == null) { - throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); - } + ManagedProcess process = new ManagedProcess(new ProcessBuilder( + osascript.toString(), "-e", String.format( + "do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges", + movedInstaller, i18n("terracotta.sudo_installing") + ))); + process.pumpInputStream(SystemUtils::onLogLine); + process.pumpErrorStream(SystemUtils::onLogLine); - Path pkg = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") - .toRealPath() - .resolve(FileUtils.getName(installer.getPath())); - Files.copy(installer.getPath(), pkg, StandardCopyOption.REPLACE_EXISTING); + return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> { + try { + FileUtils.cleanDirectory(movedInstaller.getParent()); + } catch (IOException e) { + LOG.warning("Cannot remove temporary Terracotta package file.", e); + } - ManagedProcess process = new ManagedProcess(new ProcessBuilder( - osascript.toString(), "-e", String.format( - "do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges", - pkg, i18n("terracotta.sudo_installing") - ))); - process.pumpInputStream(SystemUtils::onLogLine); - process.pumpErrorStream(SystemUtils::onLogLine); - - return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> { - try { - FileUtils.cleanDirectory(pkg.getParent()); - } catch (IOException e) { - LOG.warning("Cannot remove temporary Terracotta package file.", e); - } - - if (process.getExitCode() != 0) { - throw new IllegalStateException(String.format( - "Cannot install Terracotta %s: system installer exited with code %d", - pkg, - process.getExitCode() - )); - } - }); - }), - 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 - ))) - ); + if (process.getExitCode() != 0) { + throw new IllegalStateException(String.format( + "Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode() + )); + } + }); + }); } @Override public List ofCommandLine(Path path) { - assert binary != null; - - return List.of(binary.getPath().toString(), "--hmcl", path.toString()); + return List.of(executable.toString(), "--hmcl", path.toString()); } } 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 index 08206be3b..2ffb37890 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -57,7 +57,13 @@ 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.*; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.ComponentSublist; +import org.jackhuang.hmcl.ui.construct.HintPane; +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.i18n.LocaleUtils; @@ -96,6 +102,11 @@ public class TerracottaControllerPage extends StackPane { /* 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() { + holder.add(FXUtils.observeWeak(() -> { + // Run daemon process only if HMCL is focused and is displaying current node. + TerracottaManager.switchDaemon(getScene() != null && Controllers.getStage().isFocused()); + }, this.sceneProperty(), Controllers.getStage().focusedProperty())); + TransitionPane transition = new TransitionPane(); ObjectProperty statusProperty = new SimpleObjectProperty<>(); @@ -107,7 +118,7 @@ public class TerracottaControllerPage extends StackPane { if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() || - state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK + state instanceof TerracottaState.Fatal fatal && fatal.isRecoverable() ) { return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz"); } else { @@ -116,7 +127,7 @@ public class TerracottaControllerPage extends StackPane { }, files -> { Path path = files.get(0); - if (!TerracottaManager.validate(path)) { + if (TerracottaManager.isInvalidBundle(path)) { Controllers.dialog( i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)), i18n("message.error"), @@ -125,25 +136,7 @@ public class TerracottaControllerPage extends StackPane { 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.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, - MessageDialogPane.MessageType.INFO, () -> { - TerracottaState.Preparing s = TerracottaManager.install(path); - if (s != null) { - UI_STATE.set(s); - } - }, null); - return; - } - - next = TerracottaManager.install(path); - } else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) { - next = TerracottaManager.recover(path); - } else { - return; - } + TerracottaState.Preparing next = TerracottaManager.install(path); if (next != null) { UI_STATE.set(next); } @@ -176,7 +169,7 @@ public class TerracottaControllerPage extends StackPane { download.setSubtitle(i18n("terracotta.status.uninitialized.desc")); download.setRightIcon(SVG.ARROW_FORWARD); FXUtils.onClicked(download, () -> { - TerracottaState.Preparing s = TerracottaManager.install(null); + TerracottaState.Preparing s = TerracottaManager.download(); if (s != null) { UI_STATE.set(s); } @@ -253,7 +246,7 @@ public class TerracottaControllerPage extends StackPane { 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); + Task task = TerracottaManager.setGuesting(code); if (task != null) { task.whenComplete(Schedulers.javafx(), (s, e) -> { if (e != null) { @@ -372,7 +365,7 @@ public class TerracottaControllerPage extends StackPane { nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles())); } } - } else if (state instanceof TerracottaState.GuestStarting) { + } else if (state instanceof TerracottaState.GuestConnecting || state instanceof TerracottaState.GuestStarting) { statusProperty.set(i18n("terracotta.status.guest_starting")); progressProperty.set(-1); @@ -387,7 +380,26 @@ public class TerracottaControllerPage extends StackPane { } }); - nodesProperty.setAll(room); + nodesProperty.clear(); + if (state instanceof TerracottaState.GuestStarting) { + TerracottaState.GuestStarting.Difficulty difficulty = ((TerracottaState.GuestStarting) state).getDifficulty(); + if (difficulty != null && difficulty != TerracottaState.GuestStarting.Difficulty.UNKNOWN) { + LineButton info = LineButton.of(); + info.setLeftIcon(switch (difficulty) { + case UNKNOWN -> throw new AssertionError(); + case EASIEST, SIMPLE -> SVG.INFO; + case MEDIUM, TOUGH -> SVG.WARNING; + }); + + String difficultyID = difficulty.name().toLowerCase(Locale.ROOT); + info.setTitle(i18n(String.format("terracotta.difficulty.%s", difficultyID))); + info.setSubtitle(i18n("terracotta.difficulty.estimate_only")); + + nodesProperty.add(info); + } + } + + nodesProperty.add(room); } else if (state instanceof TerracottaState.GuestOK guestOK) { if (guestOK.isForkOf(legacyState)) { if (nodesProperty.get(nodesProperty.size() - 1) instanceof PlayerProfileUI profileUI) { @@ -486,7 +498,7 @@ public class TerracottaControllerPage extends StackPane { retry.setTitle(i18n("terracotta.status.fatal.retry")); retry.setSubtitle(message); FXUtils.onClicked(retry, () -> { - TerracottaState s = TerracottaManager.recover(null); + TerracottaState s = TerracottaManager.recover(); if (s != null) { UI_STATE.set(s); } 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 index 3f94c7d82..3dfde7e8c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -85,8 +85,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); }, it -> mainPage.getProfile().setSelectedVersion(it.getId())); }) - .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)) - .addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/")); + .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)); BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); left.setBottom(toolbar); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e4bf02391..ac5211cbd 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1436,13 +1436,17 @@ 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.feedback.title=Fill Out Feedback Form terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form. terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core +terracotta.difficulty.easiest=Excellent network: almost connected! +terracotta.difficulty.simple=Good network: connection may take some time +terracotta.difficulty.medium=Average network: enabling fallback routes, though connection may fail +terracotta.difficulty.tough=Poor network: enabling fallback routes, though connection may fail +terracotta.difficulty.estimate_only=Success rate is an estimate based on host and client NAT types, for reference only! 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. diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 1b0c01ca4..6521bdfb0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -1400,7 +1400,6 @@ system.architecture=البنية system.operating_system=نظام التشغيل terracotta=اللعب الجماعي -terracotta.easytier=حول EasyTier terracotta.terracotta=Terracotta | اللعب الجماعي terracotta.status=الردهة terracotta.back=خروج diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 5f39568b3..479d02b3a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1215,13 +1215,17 @@ system.architecture=架構 system.operating_system=作業系統 terracotta=多人遊戲 -terracotta.easytier=關於 EasyTier terracotta.terracotta=Terracotta | 陶瓦聯機 terracotta.status=聯機大廳 terracotta.back=退出 terracotta.feedback.title=填寫回饋表 terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。 terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心 +terracotta.difficulty.easiest=當前網路狀況極佳:稍等一下就成功! +terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間… +terracotta.difficulty.medium=目前網路狀態中等:已啟用抗干擾備用線路,連線可能失敗 +terracotta.difficulty.tough=目前網路狀態極差:已啟用抗干擾備用線路,連線可能失敗 +terracotta.difficulty.estimate_only=連接成功率由房主和房客的 NAT 類型推算得到,僅供參考。 terracotta.from_local.title=線上核心第三方下載管道 terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢 terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。 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 fddd5f71b..7604ecf29 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1219,13 +1219,17 @@ system.architecture=架构 system.operating_system=操作系统 terracotta=多人联机 -terracotta.easytier=关于 EasyTier terracotta.terracotta=Terracotta | 陶瓦联机 terracotta.status=联机大厅 terracotta.back=退出 terracotta.feedback.title=填写反馈表 terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。 terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心 +terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功! +terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间…… +terracotta.difficulty.medium=当前网络状态中等:已启用抗干扰备用线路,连接可能失败 +terracotta.difficulty.tough=当前网络状态极差:已启用抗干扰备用线路,连接可能失败 +terracotta.difficulty.estimate_only=连接成功率由房主和房客的 NAT 类型推算得到,仅供参考。 terracotta.from_local.title=联机核心第三方下载渠道 terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢 terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。 diff --git a/HMCL/src/main/resources/assets/terracotta.json b/HMCL/src/main/resources/assets/terracotta.json index db16397af..4074b3996 100644 --- a/HMCL/src/main/resources/assets/terracotta.json +++ b/HMCL/src/main/resources/assets/terracotta.json @@ -1,29 +1,73 @@ { - "version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|(1[0-3]))", - "version_recent": [ - "0.3.13" - ], - "version_latest": "0.3.14", - "classifiers": { - "freebsd-x86_64": "sha256:ff8c50b701e55fde9701a1f7fbe9d7317b24087c28ca6337ba9d695c53ad499a", - "linux-arm64": "sha256:6912a14255cdeedee512244c686dd7f80dcc45d8dfbe129ea4ea3f1b22d3de60", - "linux-x86_64": "sha256:9b13f51dc41c5112f2ab163df5c0dccdff3d22deb793a6698c013e9c09badd00", - "linux-loongarch64": "sha256:729ca24ef643547b6a3917520ec3335b92e226ad1884c85d6bb6c2cfa917cb03", - "linux-riscv64": "sha256:afbd8cffeacc47431f18c3ae659f7a308e1d3f2efab0ab9c5ece5176d068bf30", - "macos-arm64": "sha256:9ed9c7a35b01dce440135c91e4a5e617d84f6c8ba13a117d13a2796dc69584f5", - "macos-arm64.pkg": "sha256:49dbebd608c4036c260f09d4005dedaacb26fc1582b10479ed51476baa48f4a9", - "macos-x86_64": "sha256:40a44703237b0a194b0fb18075e3b2361ace8f4319b60269cb56c5548d2afb3a", - "macos-x86_64.pkg": "sha256:95a9de782b9c3a352ae6ec702c2fc4ca34ba807e8e4a768e61902a0c78391332", - "windows-arm64.exe": "sha256:8428c6bc6228213c5f645ab3dd0f3b684da515be103ee53d5bdc4adf59fc8a5f", - "windows-x86_64.exe": "sha256:942b91888264e1c96a10b7c94bd134f4b10d3e352e3c731def0ff6a3069c8221" + "__comment__": "THIS FILE IS MACHINE GENERATED! DO NOT EDIT!", + "version_latest": "0.4.1", + "packages": { + "windows-x86_64": { + "hash": "4693fec29bb54a0bb1a1a8a263a935f1673b8f76e84d125974cf259f1912a195beab4dfd04c23cae592cf71b116e82ecd48828b1445ab75c980c8cd79c777d21", + "files": { + "VCRUNTIME140.DLL": "3d4b24061f72c0e957c7b04a0c4098c94c8f1afb4a7e159850b9939c7210d73398be6f27b5ab85073b4e8c999816e7804fef0f6115c39cd061f4aaeb4dcda8cf", + "terracotta-0.4.1-windows-x86_64.exe": "3d29f7deb61b8b87e14032baecad71042340f6259d7ff2822336129714470e1a951cd96ee5d6a8c9312e7cf1bb9bb9d1cbf757cfa44d2b2f214362c32ea03b5b" + } + }, + "windows-arm64": { + "hash": "6c68da53142cc92598a9d3b803f110c77927ee7b481e2f623dfab17cd97fee4a58378e796030529713d3e394355135cc01d5f5d86cef7dbd31bbf8e913993d4c", + "files": { + "VCRUNTIME140.DLL": "5cb5ce114614101d260f4754c09e8a0dd57e4da885ebb96b91e274326f3e1dd95ed0ade9f542f1922fad0ed025e88a1f368e791e1d01fae69718f0ec3c7b98c8", + "terracotta-0.4.1-windows-arm64.exe": "d2cf0f29aac752d322e594da6144bbe2874aabde52b5890a6f04a33b77a377464cbf3367e40de6b046c7226517f48e23fd40e611198fcaa1f30503c36d99b20c" + } + }, + "macos-x86_64": { + "hash": "c247ab9023cf47231f3a056ddf70fe823e504c19ce241bf264c0a3cf2981c044b236dc239f430afb2541445d1b61d18898f8af5e1c063f31fb952bdfbea4aff5", + "files": { + "terracotta-0.4.1-macos-x86_64": "dd2cde70a950849498425b8b00c17fb80edb8dd9bc0312da4885d97a28a4959d87782bd2202ef7f71bdbf2a92609089585c9e3faf24c10df6150e221e111a764", + "terracotta-0.4.1-macos-x86_64.pkg": "6057d5b4ea93da45796277a20ddaea7850e8c66326ded769f20fff74e132b453d6d615d937fc8f1d62b0b36b26961df5afb76535aecf85f25d2160889225ac6d" + } + }, + "macos-arm64": { + "hash": "0ddf48a44ea2563c37707caea8381ad3c69da73dd443d07dd98fe630f4e24ccda6f1fcdc9207a66bf61758b887a7b47186883ccd12bcb6b68387d8d756873f44", + "files": { + "terracotta-0.4.1-macos-arm64": "ddc335ee082b4c0323314d482933810fc2d377cfc131a9aa978b54f245f1bed8973139edf7cf94f7ae6c04660dfe18d275b1a81638781e482b4ff585f351eed9", + "terracotta-0.4.1-macos-arm64.pkg": "5cbb41a450f080234b66fa264351bd594e3f6ef1599c96f277baa308e34dd26caefa3a34b3d65e972bc20868f2d4a661e8b3910d4b0311a374c6ac680bdccf8f" + } + }, + "linux-x86_64": { + "hash": "c00b0622203c1610a8e72bde5230fca6fe56cf1f45dc9bc7e857350f3a050d442c814084758090799b9a5b875730fa666539ee75bec706c05d9338ea208301eb", + "files": { + "terracotta-0.4.1-linux-x86_64": "e53a9d8ec085ef7a7816b3da374e5c88fced2cf8319d447be3226422d01f9d7ee2019e403eafe21082135050652b2788b7d9520cc432c8d08931930b99595ed7" + } + }, + "linux-arm64": { + "hash": "4d661e1123ca050980960fe2d32b8d93a2c3ba807748569a45f16050fb1b52089bfc64c2dd64aba652dfed0e4ac3fba1ff9518cc3f95598c99fc71d93552b589", + "files": { + "terracotta-0.4.1-linux-arm64": "d4ccf6ff8c2fac060fecaa3c8368677f9342e283f2435493b3e259e922ee0bb0b08f15a292bf91898800a462336c64d0dee7b73f03c1752e5b0988a05adb3b52" + } + }, + "linux-loongarch64": { + "hash": "8297bb37617e9e7ce282fc553c5b14c84a900bcff4d025be31fd4a4da8b3943d040afc6143aa17de9a88e5fa29af7254d38db8ae6418ee539c2301632448da09", + "files": { + "terracotta-0.4.1-linux-loongarch64": "ffdf7582d095307b91ddfc3e5d0daa78d434e4e34916d0cdf1520ae74b188fe5a48307047bf2da9a526eb725fe80cf230a93001bc8199d236b9cf28a1beaa6e9" + } + }, + "linux-riscv64": { + "hash": "092f863885205107525e7ccb0e18977f6fd3018910ca5819772ec741dd8cffee52cc352a44b928bd2ba99ab881adaadff9e3bf4bf283f7384a35fea14becb0b4", + "files": { + "terracotta-0.4.1-linux-riscv64": "a6e5d70ddc433bf804764b69e8c204a6a428ece22b9d8ab713ed339fb81bfa1d29daeb6bdfd62c85ff193396315f96172f4a28925e5a4efc45f7d6fa868782a9" + } + }, + "freebsd-x86_64": { + "hash": "288bd7a97b2e2c5fb3c7d8107ed1311bc881fb74cf92da40b6c84156239d0832d2306b74b1e93a683188a5db896a6506326c6a7a4ac0ab2e798fa4f1f00787f0", + "files": { + "terracotta-0.4.1-freebsd-x86_64": "c90e7db3c5e5cc8d53d8d10a8bf88899e2c418758c14c3d14bc24efc32a711f76882bb5b5a49db5b0256ead4f22b666139bd2a212bf09036d7e7e64ddec4ec3c" + } + } }, "downloads": [ - "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}" + "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" ], "downloads_CN": [ - "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}", - "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}", - "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}" + "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" ], "links": [ { @@ -40,7 +84,7 @@ "zh": "QQ 群", "zh-Hant": "QQ 群" }, - "link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf" + "link": "https://qm.qq.com/cgi-bin/qm/qr?k\u003dnIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5\u0026jump_from\u003dwebapi\u0026authKey\u003dsXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf" } ] } \ No newline at end of file diff --git a/HMCL/terracotta-template.json b/HMCL/terracotta-template.json new file mode 100644 index 000000000..d3a183da2 --- /dev/null +++ b/HMCL/terracotta-template.json @@ -0,0 +1,35 @@ +{ + "__comment__": "Run upgradeTerracottaConfig task with Gradle to resolve this template.", + + "version_latest": "@script_generated", + + "packages": "@script_generated", + + "downloads": [ + "https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" + ], + "downloads_CN": [ + "https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz", + "https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz" + ], + + "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/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index b53a14620..97290f628 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -527,4 +527,25 @@ public final class FileUtils { public static String printFileStructure(Path path, int maxDepth) throws IOException { return DirectoryStructurePrinter.list(path, maxDepth); } + + public static EnumSet parsePosixFilePermission(int unixMode) { + EnumSet permissions = EnumSet.noneOf(PosixFilePermission.class); + + // Owner permissions + if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ); + if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE); + if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE); + + // Group permissions + if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ); + if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE); + if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE); + + // Others permissions + if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ); + if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE); + if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE); + + return permissions; + } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index f0b6737ce..789dca8a8 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,6 +10,7 @@ repositories { dependencies { implementation(libs.gson) implementation(libs.jna) + implementation(libs.kala.compress.tar) } java { diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java new file mode 100644 index 000000000..7a035963a --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/TerracottaConfigUpgradeTask.java @@ -0,0 +1,167 @@ +package org.jackhuang.hmcl.gradle; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.SerializedName; +import kala.compress.archivers.tar.TarArchiveEntry; +import kala.compress.archivers.tar.TarArchiveReader; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.zip.GZIPInputStream; + +public abstract class TerracottaConfigUpgradeTask extends DefaultTask { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + @Input + public abstract ListProperty<@NotNull String> getClassifiers(); + + @Input + public abstract Property<@NotNull String> getVersion(); + + @Input + public abstract Property<@NotNull String> getDownloadURL(); + + @InputFile + public abstract RegularFileProperty getTemplateFile(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @TaskAction + public void run() throws Exception { + JsonObject config = GSON.fromJson( + Files.readString(getTemplateFile().get().getAsFile().toPath(), StandardCharsets.UTF_8), + JsonObject.class + ); + + Map files = new LinkedHashMap<>(); + HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build(); + try { + List>> tasks = new ArrayList<>(); + for (String classifier : getClassifiers().get()) { + Path path = Files.createTempFile("terracotta-bundle-", ".tar.gz"); + String url = getDownloadURL().get().replace("${classifier}", classifier).replace("${version}", getVersion().get()); + files.put(classifier, path); + + tasks.add(client.sendAsync( + HttpRequest.newBuilder().GET().uri(URI.create(url)).build(), + HttpResponse.BodyHandlers.ofFile(path) + )); + } + + for (CompletableFuture> task : tasks) { + HttpResponse response = task.get(); + if (response.statusCode() != 200) { + throw new IOException(String.format("Unable to request %s: %d", response.uri(), response.statusCode())); + } + } + } finally { + if (client instanceof AutoCloseable) { // Since Java21, HttpClient implements AutoCloseable: https://bugs.openjdk.org/browse/JDK-8304165 + ((AutoCloseable) client).close(); + } + } + + Map bundles = new LinkedHashMap<>(); + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + HexFormat hexFormat = HexFormat.of(); + + for (Map.Entry entry : files.entrySet()) { + String classifier = entry.getKey(); + Path bundle = entry.getValue(); + Path decompressedBundle = Files.createTempFile("terracotta-bundle-", ".tar"); + try (InputStream is = new GZIPInputStream(new DigestInputStream(Files.newInputStream(bundle), digest)); + OutputStream os = Files.newOutputStream(decompressedBundle)) { + is.transferTo(os); + } + + String bundleHash = hexFormat.formatHex(digest.digest()); + + Map bundleContents = new LinkedHashMap<>(); + try (TarArchiveReader reader = new TarArchiveReader(decompressedBundle)) { + List entries = new ArrayList<>(reader.getEntries()); + entries.sort(Comparator.comparing(TarArchiveEntry::getName)); + + for (TarArchiveEntry archiveEntry : entries) { + String[] split = archiveEntry.getName().split("/", 2); + if (split.length != 1) { + throw new IllegalStateException( + String.format("Illegal bundle %s: files (%s) in sub directories are unsupported.", classifier, archiveEntry.getName()) + ); + } + String name = split[0]; + + try (InputStream is = new DigestInputStream(reader.getInputStream(archiveEntry), digest)) { + is.transferTo(OutputStream.nullOutputStream()); + } + String hash = hexFormat.formatHex(digest.digest()); + + bundleContents.put(name, hash); + } + } + + bundles.put(classifier, new Bundle(bundleHash, bundleContents)); + + Files.delete(bundle); + Files.delete(decompressedBundle); + } + + config.add("__comment__", new JsonPrimitive("THIS FILE IS MACHINE GENERATED! DO NOT EDIT!")); + config.add("version_latest", new JsonPrimitive(getVersion().get())); + config.add("packages", GSON.toJsonTree(bundles)); + + Files.writeString(getOutputFile().get().getAsFile().toPath(), GSON.toJson(config), StandardCharsets.UTF_8); + } + + public void checkValid() throws IOException { + Path output = getOutputFile().get().getAsFile().toPath(); + if (Files.isReadable(output)) { + String version = GSON.fromJson(Files.readString(output, StandardCharsets.UTF_8), JsonObject.class) + .get("version_latest").getAsJsonPrimitive().getAsString(); + if (Objects.equals(version, getVersion().get())) { + return; + } + } + + throw new GradleException(String.format("Terracotta config isn't up-to-date! " + + "You might have just edited the version number in libs.version.toml. " + + "Please run task %s to resolve the new config.", getPath())); + } + + private record Bundle( + @SerializedName("hash") String hash, + @SerializedName("files") Map files + ) { + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0df83a45..0d1c8e544 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ pci-ids = "0.4.0" java-info = "1.0" authlib-injector = "1.2.7" monet-fx = "0.4.0" +terracotta = "0.4.1" # testing junit = "6.0.1"