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 4800d1ad645..51e6477d066 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -34,6 +34,9 @@ import org.jackhuang.hmcl.mod.ModpackCompletionException; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackProvider; +import org.jackhuang.hmcl.mod.modrinth.ModrinthCheckServerPackUpdateTask; +import org.jackhuang.hmcl.mod.modrinth.ModrinthManifest; +import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.*; import org.jackhuang.hmcl.ui.*; @@ -43,6 +46,7 @@ import org.jackhuang.hmcl.ui.construct.PromptDialogPane; import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane; import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; @@ -56,6 +60,7 @@ import java.net.SocketTimeoutException; import java.net.URI; import java.nio.file.AccessDeniedException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.*; @@ -157,6 +162,43 @@ private void launch0() { .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); + return null; + }) + .thenComposeAsync(() -> { + try { + ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); + if (ModrinthModpackProvider.INSTANCE.getName().equals(configuration.getType())) { + Path manifestFile = repository.getVersionRoot(selectedVersion).resolve("modrinth.index.json"); + if (Files.exists(manifestFile)) { + ModrinthManifest manifest = JsonUtils.fromJsonFile(manifestFile, ModrinthManifest.class); + if (StringUtils.isNotBlank(manifest.getFileApi())) { + return new ModrinthCheckServerPackUpdateTask(manifest.getVersionId(), manifest.getFileApi()); + } + } + } + } catch (IOException ignored) { + } + return null; + }) + .thenComposeAsync(hasUpdate -> { + if (hasUpdate == null || !hasUpdate) return null; + + CompletableFuture future = new CompletableFuture<>(); + runInFX(() -> { + Controllers.confirm(i18n("modpack.update.found"), i18n("modpack.update"), MessageType.QUESTION, () -> { + try { + ModpackConfiguration config = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); + Task updateTask = ModpackHelper.getUpdateTask(profile, ModrinthCheckServerPackUpdateTask.getUpdateFile(), java.nio.charset.StandardCharsets.UTF_8, selectedVersion, config); + Controllers.taskDialog(updateTask, i18n("modpack.update"), TaskCancellationAction.NORMAL); + } catch (Exception e) { + Controllers.dialog(StringUtils.getStackTrace(e), i18n("message.error"), MessageType.ERROR); + } + future.completeExceptionally(new CancellationException()); + }, () -> future.complete(null)); + }); + return Task.fromCompletableFuture(future); + }) + .thenComposeAsync(() -> { if (setting.isNotCheckGame()) return null; return Task.allOf( @@ -177,7 +219,7 @@ private void launch0() { || renderer.mesaDriverName() == null) return null; - Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); + Library lib = NativePatcher.getWindowsMesaLoader(javaVersionRef.get(), renderer, OperatingSystem.SYSTEM_VERSION); if (lib == null) return null; Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a18a8203d6b..ff8427404d5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -972,6 +972,7 @@ modpack.type.server.export=Allows server owner to remotely update the game insta modpack.type.server.malformed=Invalid modpack manifest. Please contact the modpack maker to resolve this problem. modpack.unsupported=Unsupported modpack format modpack.update=Updating modpack +modpack.update.found=A new version of the modpack is available. Update now? modpack.wizard=Modpack Export Guide modpack.wizard.step.1=Basic Settings modpack.wizard.step.1.title=Some basic informations for the modpack. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 24f40bff62c..7ee47ebba25 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -779,6 +779,7 @@ modpack.type.server.export=允許伺服器管理員遠端更新遊戲用戶端 modpack.type.server.malformed=伺服器模組包配置格式錯誤,請聯絡伺服器管理員解決此問題 modpack.unsupported=Hello Minecraft! Launcher 不支援該模組包格式 modpack.update=正在升級模組包 +modpack.update.found=發現模組包新版本,是否更新? modpack.wizard=匯出模組包引導 modpack.wizard.step.1=基本設定 modpack.wizard.step.1.title=設定模組包的主要訊息 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 341793b1a0c..7a5c2e1e2cf 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -784,6 +784,7 @@ modpack.type.server.export=允许服务器管理员远程更新游戏客户端 modpack.type.server.malformed=服务器整合包配置格式错误,请联系服务器管理员解决此问题。\n如遇到问题,你可以点击右上角帮助按钮进行求助。 modpack.unsupported=Hello Minecraft! Launcher 不支持该整合包格式 modpack.update=正在升级整合包 +modpack.update.found=发现整合包新版本,是否更新? modpack.wizard=导出整合包向导 modpack.wizard.step.1=基本设置 modpack.wizard.step.1.title=设置整合包的主要信息 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthCheckServerPackUpdateTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthCheckServerPackUpdateTask.java new file mode 100644 index 00000000000..247b8371258 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthCheckServerPackUpdateTask.java @@ -0,0 +1,139 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 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.mod.modrinth; + +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * Checks whether a Modrinth server modpack has a newer version available on the remote server. + * + * The task downloads {@code server.mrpack} from the remote API, extracts the manifest, and compares + * the remote version ID with the local one using {@link VersionNumber}. The result is {@code true} if + * an update is available, {@code false} otherwise. + * + * When an update is found, the downloaded {@code server.mrpack} path is stored in a static field + * and can be retrieved via {@link #getUpdateFile()}. If no update is available or an error occurs, + * the temporary file is deleted. + * + * @see ModrinthManifest + * @see FileDownloadTask + */ +public class ModrinthCheckServerPackUpdateTask extends Task { + + /** + * The path to the downloaded {@code server.mrpack} file, or {@code null} if no update file is available. + */ + private static @Nullable Path updateFilePath = null; + + /** + * The current local version ID of the modpack. + */ + private final String versionId; + + /** + * The task that downloads {@code server.mrpack} from the remote API, or {@code null} if no download URL is available. + */ + private final @Nullable FileDownloadTask downloadTask; + + /** + * Creates a new update check task. + * + * @param versionId the current local version ID of the modpack. + * @param fileApi the base URL for downloading the server modpack; if blank, no download will be attempted. + */ + public ModrinthCheckServerPackUpdateTask(String versionId, String fileApi) { + this.versionId = versionId; + + if (StringUtils.isNotBlank(fileApi)) { + try { + updateFilePath = Files.createTempFile("hmcl-server-auto-update", ".mrpack"); + downloadTask = new FileDownloadTask(fileApi + "/server.mrpack", updateFilePath); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else { + downloadTask = null; + } + } + + @Override + public Collection> getDependents() { + return downloadTask != null ? Collections.singleton(downloadTask) : Collections.emptySet(); + } + + @Override + public boolean isRelyingOnDependents() { + return false; + } + + /** + * Returns the path to the downloaded {@code server.mrpack} file, or {@code null} if no update file is available. + * + * @return the path to the downloaded update file, or {@code null} + */ + public static @Nullable Path getUpdateFile() { + return updateFilePath; + } + + @Override + public void execute() throws Exception { + if (updateFilePath == null || downloadTask == null || downloadTask.getException() != null) { + setResult(false); + return; + } + + try { + ModrinthManifest remoteManifest; + try (var zip = CompressingUtils.openZipFile(updateFilePath)) { + remoteManifest = JsonUtils.fromNonNullJson( + CompressingUtils.readTextZipEntry(zip, "modrinth.index.json"), + ModrinthManifest.class); + } + + if (VersionNumber.compare(remoteManifest.getVersionId(), versionId) > 0) { + setResult(true); + } else { + setResult(false); + } + } catch (Exception e) { + LOG.warning("Failed to check for modpack updates", e); + setResult(false); + } finally { + if (!getResult()) { + try { + Files.deleteIfExists(updateFilePath); + } catch (IOException ignored) { + } + } + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java index c977b101766..d3e143cb730 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthInstallTask.java @@ -90,16 +90,6 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey()); } } - dependents.add(builder.buildAsync()); - - onDone().register(event -> { - Exception ex = event.getTask().getException(); - if (event.isFailed()) { - if (!(ex instanceof ModpackCompletionException)) { - repository.removeVersionFromDisk(name); - } - } - }); ModpackConfiguration config = null; try { @@ -111,8 +101,21 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF } } catch (JsonParseException | IOException ignore) { } - this.config = config; + + if (config == null || !config.getManifest().getDependencies().equals(manifest.getDependencies())) { + dependents.add(builder.buildAsync()); + } + + onDone().register(event -> { + Exception ex = event.getTask().getException(); + if (event.isFailed()) { + if (!(ex instanceof ModpackCompletionException)) { + repository.removeVersionFromDisk(name); + } + } + }); + List subDirectories = Arrays.asList("/client-overrides", "/overrides"); dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), subDirectories, any -> true, config).withStage("hmcl.modpack")); dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack")); @@ -150,6 +153,15 @@ public void execute() throws Exception { Files.deleteIfExists(oldFile); } } + // For local files which is already in new manifest, remove them + for (ModrinthManifest.File newManifestFile : manifest.getFiles()) { + if (config.getManifest().getFiles().stream().noneMatch(newManifestFile::equals)) { + Path newFile = run.resolve(newManifestFile.getPath()); + if (Files.exists(newFile)) { + Files.deleteIfExists(newFile); + } + } + } } Path root = repository.getVersionRoot(name); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthManifest.java index ee5ac48e6fa..4085ea346c9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthManifest.java @@ -37,8 +37,9 @@ public class ModrinthManifest implements ModpackManifest, Validation { private final @Nullable String summary; private final List files; private final Map dependencies; + private final @Nullable String fileApi; - public ModrinthManifest(String game, int formatVersion, String versionId, String name, @Nullable String summary, List files, Map dependencies) { + public ModrinthManifest(String game, int formatVersion, String versionId, String name, @Nullable String summary, List files, Map dependencies, @Nullable String fileApi) { this.game = game; this.formatVersion = formatVersion; this.versionId = versionId; @@ -46,6 +47,11 @@ public ModrinthManifest(String game, int formatVersion, String versionId, String this.summary = summary; this.files = files; this.dependencies = dependencies; + this.fileApi = fileApi; + } + + public ModrinthManifest(String game, int formatVersion, String versionId, String name, @Nullable String summary, List files, Map dependencies) { + this(game, formatVersion, versionId, name, summary, files, dependencies, null); } public String getGame() { @@ -80,6 +86,11 @@ public String getGameVersion() { return dependencies.get("minecraft"); } + @Nullable + public String getFileApi() { + return fileApi; + } + @Override public ModpackProvider getProvider() { return ModrinthModpackProvider.INSTANCE; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java index b4f7d9fc29b..70db485f927 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java @@ -23,6 +23,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.stream.Stream; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameRepository; @@ -31,7 +32,9 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.RemoteMod; @@ -133,31 +136,55 @@ public void execute() throws Exception { try (var zip = new Zipper(modpackFile)) { Path runDirectory = repository.getRunDirectory(version); List files = new ArrayList<>(); - Set filesInManifest = new HashSet<>(); + Set filesInManifest = new HashSet<>(); // a set contains the value of key "path" in every element of files String[] resourceDirs = {"resourcepacks", "shaderpacks", "mods"}; - for (String dir : resourceDirs) { - Path dirPath = runDirectory.resolve(dir); - if (Files.exists(dirPath)) { - Files.walk(dirPath) - .filter(Files::isRegularFile) - .forEach(file -> { - try { - String relativePath = runDirectory.relativize(file).normalize().toString().replace(File.separatorChar, '/'); - - if (!info.getWhitelist().contains(relativePath)) { - return; + String fileApi = StringUtils.isBlank(info.getFileApi()) ? null : StringUtils.removeSuffix(info.getFileApi(), "/"); + try (Stream stream = Files.list(runDirectory)) { + for (Path dirPath : (Iterable) stream::iterator) { + if (Files.exists(dirPath)) { + boolean isValidDir = Arrays.asList(resourceDirs).contains(dirPath.getFileName().toString()); // allow remote file match + Files.walk(dirPath) + .filter(Files::isRegularFile) + .forEach(file -> { + try { + String relativePath = runDirectory.relativize(file).normalize().toString().replace(File.separatorChar, '/'); + + if (!info.getWhitelist().contains(relativePath)) { + return; + } + + ModrinthManifest.File fileEntry = null; + if (isValidDir) { + fileEntry = tryGetRemoteFile(file, relativePath); + } + if (fileEntry != null) { + files.add(fileEntry); + filesInManifest.add(relativePath); + } else { + if (fileApi != null) { + Map hashes = new HashMap<>(); + hashes.put("sha1", DigestUtils.digestToString("SHA-1", file)); + hashes.put("sha512", DigestUtils.digestToString("SHA-512", file)); + + long fileSize = Files.size(file); + if (fileSize > Integer.MAX_VALUE) { + LOG.warning("File " + relativePath + " is too large (size: " + fileSize + " bytes), precision may be lost when converting to int"); + } + files.add(new ModrinthManifest.File( + relativePath, + hashes, + null, + Collections.singletonList(fileApi + "/" + NetworkUtils.encodeLocation(relativePath)), + (int) fileSize + )); + } + } + } catch (IOException e) { + LOG.warning("Failed to process file: " + file, e); } - - ModrinthManifest.File fileEntry = tryGetRemoteFile(file, relativePath); - if (fileEntry != null) { - files.add(fileEntry); - filesInManifest.add(relativePath); - } - } catch (IOException e) { - LOG.warning("Failed to process file: " + file, e); - } - }); + }); + } } } @@ -200,6 +227,7 @@ public void execute() throws Exception { } public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() + .requireFileApi(true) .requireNoCreateRemoteFiles() .requireSkipCurseForgeRemoteFiles(); }