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 2274efeec..de5f4d6c2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -35,7 +35,11 @@ import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.curse.CurseCompletionException; import org.jackhuang.hmcl.mod.curse.CurseCompletionTask; +import org.jackhuang.hmcl.mod.curse.CurseInstallTask; +import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask; +import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackLocalInstallTask; import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask; +import org.jackhuang.hmcl.mod.server.ServerModpackLocalInstallTask; import org.jackhuang.hmcl.setting.LauncherVisibility; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.VersionSetting; @@ -140,10 +144,12 @@ public final class LauncherHelper { }), Task.composeAsync(() -> { try { ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); - if ("Curse".equals(configuration.getType())) + if (CurseInstallTask.MODPACK_TYPE.equals(configuration.getType())) return new CurseCompletionTask(dependencyManager, selectedVersion); - else if ("Server".equals(configuration.getType())) + else if (ServerModpackLocalInstallTask.MODPACK_TYPE.equals(configuration.getType())) return new ServerModpackCompletionTask(dependencyManager, selectedVersion); + else if (McbbsModpackLocalInstallTask.MODPACK_TYPE.equals(configuration.getType())) + return new McbbsModpackCompletionTask(dependencyManager, selectedVersion); else return null; } catch (IOException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index 81b1d592c..4ba03ba6f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -199,6 +199,11 @@ public final class ModpackHelper { throw new MismatchedModpackTypeException(HMCLModpackInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); return new ModpackUpdateTask(profile.getRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name)); + case McbbsModpackLocalInstallTask.MODPACK_TYPE: + if (!(modpack.getManifest() instanceof McbbsModpackManifest)) + throw new MismatchedModpackTypeException(McbbsModpackLocalInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); + + return new ModpackUpdateTask(profile.getRepository(), name, new McbbsModpackLocalInstallTask(profile.getDependency(), zipFile, modpack, (McbbsModpackManifest) modpack.getManifest(), name)); case ServerModpackLocalInstallTask.MODPACK_TYPE: if (!(modpack.getManifest() instanceof ServerModpackManifest)) throw new MismatchedModpackTypeException(ServerModpackLocalInstallTask.MODPACK_TYPE, getManifestType(modpack.getManifest())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java index 51e105bed..cd01f6bee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameItem.java @@ -82,7 +82,7 @@ public class GameItem extends Control { CompletableFuture.runAsync(() -> { try { - ModpackConfiguration config = profile.getRepository().readModpackConfiguration(version); + ModpackConfiguration config = profile.getRepository().readModpackConfiguration(version); if (config == null) return; tag.set(config.getVersion()); } catch (IOException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java index 4fe4249bb..179d54b44 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/InstallerListPage.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.ui.versions; import javafx.application.Platform; -import javafx.beans.binding.Bindings; import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.stage.FileChooser; @@ -40,10 +39,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; import java.util.function.Function; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; 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 56891a27e..b2795a3d3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -238,11 +238,11 @@ modpack.choose.local.detail=你可以直接将整合包文件拖入本页面以 modpack.choose.remote=从互联网下载整合包 modpack.choose.remote.detail=需要提供整合包的下载链接 modpack.choose.remote.tooltip=要下载的整合包的链接 -modpack.desc=描述你要制作的整合包,比如整合包注意事项和更新记录,支持 Markdown(图片请用网络图)。 +modpack.desc=描述你要制作的整合包,比如整合包注意事项和更新记录,支持 HTML(图片请用网络图)。 modpack.description=整合包描述 modpack.enter_name=给游戏起个你喜欢的名字 modpack.export=导出整合包 -modpack.export.as=请选择整合包类型 (若无法决定,请选择 HMCL 类型) +modpack.export.as=请选择整合包类型 (若无法决定,请选择我的世界中文论坛整合包标准) modpack.file_api=整合包下载链接前缀 modpack.files.blueprints=BuildCraft 蓝图 modpack.files.config=Mod 配置文件 @@ -278,7 +278,7 @@ modpack.type.curse.tolerable_error=但未能完成 Curse 整合包文件的下 modpack.type.curse.error=未能完成 Curse 整合包的下载,请多次重试或设置代理 modpack.type.curse.not_found=部分必需文件已经在网络中被删除并且再也无法下载,请尝试该整合包的最新版本或者安装其他整合包。 modpack.type.mcbbs=我的世界中文论坛整合包标准 -modpack.type.hmcl.export=可以被 Hello Minecraft! Launcher (HMCL) 导入 +modpack.type.mcbbs.export=可以被 Hello Minecraft! Launcher (HMCL) 导入 modpack.type.multimc=MultiMC modpack.type.multimc.export=可以被 Hello Minecraft! Launcher (HMCL) 和 MultiMC 导入 modpack.type.server=服务器自动更新整合包 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java index b59c0011d..f30a5ce5a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/MinecraftInstanceTask.java @@ -33,7 +33,7 @@ import java.util.List; import static org.jackhuang.hmcl.util.DigestUtils.digest; import static org.jackhuang.hmcl.util.Hex.encodeHex; -public final class MinecraftInstanceTask extends Task { +public final class MinecraftInstanceTask extends Task> { private final File zipFile; private final Charset encoding; @@ -73,6 +73,8 @@ public final class MinecraftInstanceTask extends Task { }); } - FileUtils.writeText(jsonFile, JsonUtils.GSON.toJson(new ModpackConfiguration<>(manifest, type, name, version, overrides))); + ModpackConfiguration configuration = new ModpackConfiguration<>(manifest, type, name, version, overrides); + FileUtils.writeText(jsonFile, JsonUtils.GSON.toJson(configuration)); + setResult(configuration); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java index 2a4afef33..53203fa18 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java @@ -42,6 +42,14 @@ public final class ModManager { this.id = id; } + public GameRepository getRepository() { + return repository; + } + + public String getVersion() { + return id; + } + private Path getModsDirectory() { return repository.getRunDirectory(id).toPath().resolve("mods"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackCompletionTask.java index 80d914f30..c5f6fa400 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackCompletionTask.java @@ -21,181 +21,302 @@ import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.mod.ModpackConfiguration; -import org.jackhuang.hmcl.task.GetTask; -import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.mod.curse.CurseCompletionException; +import org.jackhuang.hmcl.mod.curse.CurseMetaMod; +import org.jackhuang.hmcl.task.*; import org.jackhuang.hmcl.util.Logging; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.logging.Level; +import java.util.stream.Collectors; +import java.util.stream.Stream; -public class McbbsModpackCompletionTask extends Task { +import static org.jackhuang.hmcl.util.DigestUtils.digest; +import static org.jackhuang.hmcl.util.Hex.encodeHex; +public class McbbsModpackCompletionTask extends CompletableFutureTask { + + private final DefaultDependencyManager dependency; private final DefaultGameRepository repository; + private final ModManager modManager; private final String version; - private ModpackConfiguration manifest; + private final File configurationFile; + private ModpackConfiguration configuration; private GetTask dependent; - private McbbsModpackManifest remoteManifest; + private McbbsModpackManifest manifest; private final List> dependencies = new LinkedList<>(); + private final AtomicBoolean allNameKnown = new AtomicBoolean(true); + private final AtomicInteger finished = new AtomicInteger(0); + private final AtomicBoolean notFound = new AtomicBoolean(false); + public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, String version) { this(dependencyManager, version, null); } - public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, String version, ModpackConfiguration manifest) { + public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, String version, ModpackConfiguration configuration) { + this.dependency = dependencyManager; this.repository = dependencyManager.getGameRepository(); + this.modManager = repository.getModManager(version); this.version = version; + this.configurationFile = repository.getModpackConfiguration(version); + this.configuration = configuration; + } - if (manifest == null) { - try { - File manifestFile = repository.getModpackConfiguration(version); - if (manifestFile.exists()) { - this.manifest = JsonUtils.GSON.fromJson(FileUtils.readText(manifestFile), new TypeToken>() { + @Override + public CompletableFuture getFuture(TaskCompletableFuture executor) { + return breakable(CompletableFuture.runAsync(wrap(() -> { + if (configuration == null) { + // Load configuration from disk + try { + configuration = JsonUtils.fromNonNullJson(FileUtils.readText(configurationFile), new TypeToken>() { }.getType()); + } catch (IOException | JsonParseException e) { + throw new IOException("Malformed modpack configuration"); } - } catch (Exception e) { - Logging.LOG.log(Level.WARNING, "Unable to read mcbbs modpack manifest.json", e); } + manifest = configuration.getManifest(); + if (manifest == null) throw new CustomException(); + })).thenComposeAsync(unused -> { + // we first download latest manifest + return breakable(CompletableFuture.runAsync(wrap(() -> { + if (StringUtils.isBlank(manifest.getFileApi())) { + // skip this phase + throw new CustomException(); + } + })).thenComposeAsync(wrap(unused1 -> { + return executor.one(new GetTask(new URL(manifest.getFileApi() + "/manifest.json"))); + })).thenComposeAsync(wrap(unused1 -> { + McbbsModpackManifest remoteManifest; + // We needs to update modpack from online server. + try { + remoteManifest = JsonUtils.fromNonNullJson(dependent.getResult(), McbbsModpackManifest.class); + } catch (JsonParseException e) { + throw new IOException("Unable to parse server manifest.json from " + manifest.getFileApi(), e); + } + + Path rootPath = repository.getVersionRoot(version).toPath(); + + Map localFiles = manifest.getFiles().stream().collect(Collectors.toMap(Function.identity(), Function.identity())); + + // for files in new modpack + List newFiles = new ArrayList<>(remoteManifest.getFiles().size()); + List> tasks = new ArrayList<>(); + for (McbbsModpackManifest.File file : remoteManifest.getFiles()) { + Path actualPath = getFilePath(file); + McbbsModpackManifest.File oldFile = localFiles.remove(file); + boolean download = false; + if (oldFile == null) { + // If old modpack does not have this entry, download it + download = true; + } else if (actualPath != null) { + if (!Files.exists(actualPath)) { + // If both old and new modpacks have this entry, but the file is missing... + // Re-download it since network problem may cause file missing + download = true; + } else if (getFileHash(file) != null) { + // If user modified this entry file, we will not replace this file since this modified file is what user expects. + // Or we have downloaded latest file in previous completion task, this time we have no need to download it again. + String fileHash = encodeHex(digest("SHA-1", actualPath)); + String oldHash = getFileHash(oldFile); + String newHash = getFileHash(file); + if (oldHash == null) { + // We don't know whether the file is modified or not, just update it. + download = true; + } else if (!Objects.equals(fileHash, newHash)) { + if (file.isForce()) { + // this file is not allowed to be modified, required by modpack author. + download = true; + } else if (Objects.equals(oldHash, fileHash)) { + download = true; + } + } + } + } else { + // we resolve files with unknown path later. + } + + if (download) { + tasks.add(downloadFile(remoteManifest, file)); + } + + newFiles.add(mergeFile(oldFile, file)); + } + + // If old modpack have this entry, and new modpack deleted it. Delete this file. + // for-loop above removes still existing file in localFiles. Remaining elements + // are files removed by next modpack version. + // Notice that this loop will also remove Curse mods. + for (McbbsModpackManifest.File file : localFiles.keySet()) { + Path actualPath = getFilePath(file); + if (actualPath != null && Files.exists(actualPath)) + Files.deleteIfExists(actualPath); + } + + manifest = remoteManifest.setFiles(newFiles); + return executor.all(tasks.stream().filter(Objects::nonNull).collect(Collectors.toList())); + })).thenAcceptAsync(wrap(unused1 -> { + File manifestFile = repository.getModpackConfiguration(version); + FileUtils.writeText(manifestFile, JsonUtils.GSON.toJson( + new ModpackConfiguration<>(manifest, this.configuration.getType(), this.manifest.getName(), this.manifest.getVersion(), + this.manifest.getFiles().stream() + .flatMap(file -> file instanceof McbbsModpackManifest.AddonFile + ? Stream.of((McbbsModpackManifest.AddonFile) file) + : Stream.empty()) + .map(file -> new ModpackConfiguration.FileInformation(file.getPath(), file.getHash())) + .collect(Collectors.toList())))); + }))); + }).thenComposeAsync(unused -> { + AtomicBoolean allNameKnown = new AtomicBoolean(true); + AtomicInteger finished = new AtomicInteger(0); + AtomicBoolean notFound = new AtomicBoolean(false); + + return breakable(CompletableFuture.completedFuture(null) + .thenComposeAsync(wrap(unused1 -> { + List> dependencies = new ArrayList<>(); + // Because in China, Curse is too difficult to visit, + // if failed, ignore it and retry next time. + McbbsModpackManifest newManifest = manifest.setFiles( + manifest.getFiles().parallelStream() + .map(rawFile -> { + updateProgress(finished.incrementAndGet(), manifest.getFiles().size()); + if (rawFile instanceof McbbsModpackManifest.CurseFile) { + McbbsModpackManifest.CurseFile file = (McbbsModpackManifest.CurseFile) rawFile; + if (StringUtils.isBlank(file.getFileName())) { + try { + return file.withFileName(NetworkUtils.detectFileName(file.getUrl())); + } catch (IOException e) { + try { + String result = NetworkUtils.doGet(NetworkUtils.toURL(String.format("https://cursemeta.dries007.net/%d/%d.json", file.getProjectID(), file.getFileID()))); + CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class); + return file.withFileName(mod.getFileNameOnDisk()).withURL(mod.getDownloadURL()); + } catch (FileNotFoundException fof) { + Logging.LOG.log(Level.WARNING, "Could not query cursemeta for deleted mods: " + file.getUrl(), fof); + notFound.set(true); + return file; + } catch (IOException | JsonParseException e2) { + try { + String result = NetworkUtils.doGet(NetworkUtils.toURL(String.format("https://addons-ecs.forgesvc.net/api/v2/addon/%d/file/%d", file.getProjectID(), file.getFileID()))); + CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class); + return file.withFileName(mod.getFileName()).withURL(mod.getDownloadURL()); + } catch (FileNotFoundException fof) { + Logging.LOG.log(Level.WARNING, "Could not query forgesvc for deleted mods: " + file.getUrl(), fof); + notFound.set(true); + return file; + } catch (IOException | JsonParseException e3) { + Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e); + Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e2); + Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e3); + allNameKnown.set(false); + return file; + } + } + } + } else { + return file; + } + } else { + return rawFile; + } + }) + .collect(Collectors.toList())); + + manifest = newManifest; + configuration = configuration.setManifest(newManifest); + FileUtils.writeText(configurationFile, JsonUtils.GSON.toJson(configuration)); + + for (McbbsModpackManifest.File file : newManifest.getFiles()) + if (file instanceof McbbsModpackManifest.CurseFile) { + McbbsModpackManifest.CurseFile curseFile = (McbbsModpackManifest.CurseFile) file; + if (StringUtils.isNotBlank(curseFile.getFileName())) { + if (!modManager.hasSimpleMod(curseFile.getFileName())) { + FileDownloadTask task = new FileDownloadTask(curseFile.getUrl(), modManager.getSimpleModPath(curseFile.getFileName()).toFile()); + task.setCacheRepository(dependency.getCacheRepository()); + task.setCaching(true); + dependencies.add(task.withCounter()); + } + } + } + + if (!dependencies.isEmpty()) { + getProperties().put("total", dependencies.size()); + } + + return executor.all(dependencies); + })).whenComplete(wrap((unused1, ex) -> { + // Let this task fail if the curse manifest has not been completed. + // But continue other downloads. + if (notFound.get()) + throw new CurseCompletionException(new FileNotFoundException()); + if (!allNameKnown.get() || ex != null) + throw new CurseCompletionException(); + }))); + })); + } + + @Nullable + private Path getFilePath(McbbsModpackManifest.File file) { + if (file instanceof McbbsModpackManifest.AddonFile) { + return modManager.getRepository().getRunDirectory(modManager.getVersion()).toPath().resolve(((McbbsModpackManifest.AddonFile) file).getPath()); + } else if (file instanceof McbbsModpackManifest.CurseFile) { + String fileName = ((McbbsModpackManifest.CurseFile) file).getFileName(); + if (fileName == null) return null; + return modManager.getSimpleModPath(fileName); } else { - this.manifest = manifest; + throw new IllegalArgumentException(); } } - @Override - public boolean doPreExecute() { - return true; - } - - @Override - public void preExecute() throws Exception { - if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return; - dependent = new GetTask(new URL(manifest.getManifest().getFileApi() + "/manifest.json")); - } - - @Override - public Collection> getDependencies() { - return dependencies; - } - - @Override - public Collection> getDependents() { - return dependent == null ? Collections.emptySet() : Collections.singleton(dependent); - } - - @Override - public void execute() throws Exception { - if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return; - - try { - remoteManifest = JsonUtils.fromNonNullJson(dependent.getResult(), McbbsModpackManifest.class); - } catch (JsonParseException e) { - throw new IOException(e); + private String getFileHash(McbbsModpackManifest.File file) { + if (file instanceof McbbsModpackManifest.AddonFile) { + return ((McbbsModpackManifest.AddonFile) file).getHash(); + } else { + return null; } - - Path rootPath = repository.getVersionRoot(version).toPath(); - - // Because in China, Curse is too difficult to visit, - // if failed, ignore it and retry next time. -// CurseManifest newManifest = manifest.setFiles( -// manifest.getFiles().parallelStream() -// .map(file -> { -// updateProgress(finished.incrementAndGet(), manifest.getFiles().size()); -// if (StringUtils.isBlank(file.getFileName())) { -// try { -// return file.withFileName(NetworkUtils.detectFileName(file.getUrl())); -// } catch (IOException e) { -// try { -// String result = NetworkUtils.doGet(NetworkUtils.toURL(String.format("https://cursemeta.dries007.net/%d/%d.json", file.getProjectID(), file.getFileID()))); -// CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class); -// return file.withFileName(mod.getFileNameOnDisk()).withURL(mod.getDownloadURL()); -// } catch (FileNotFoundException fof) { -// Logging.LOG.log(Level.WARNING, "Could not query cursemeta for deleted mods: " + file.getUrl(), fof); -// notFound.set(true); -// return file; -// } catch (IOException | JsonParseException e2) { -// try { -// String result = NetworkUtils.doGet(NetworkUtils.toURL(String.format("https://addons-ecs.forgesvc.net/api/v2/addon/%d/file/%d", file.getProjectID(), file.getFileID()))); -// CurseMetaMod mod = JsonUtils.fromNonNullJson(result, CurseMetaMod.class); -// return file.withFileName(mod.getFileName()).withURL(mod.getDownloadURL()); -// } catch (FileNotFoundException fof) { -// Logging.LOG.log(Level.WARNING, "Could not query forgesvc for deleted mods: " + file.getUrl(), fof); -// notFound.set(true); -// return file; -// } catch (IOException | JsonParseException e3) { -// Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e); -// Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e2); -// Logging.LOG.log(Level.WARNING, "Unable to fetch the file name of URL: " + file.getUrl(), e3); -// allNameKnown.set(false); -// return file; -// } -// } -// } -// } else { -// return file; -// } -// }) -// .collect(Collectors.toList())); -// -// Map files = manifest.getManifest().getFiles().stream() -// .collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath, -// Function.identity())); -// -// Set remoteFiles = remoteManifest.getFiles().stream().map(ModpackConfiguration.FileInformation::getPath) -// .collect(Collectors.toSet()); -// -// // for files in new modpack -// for (ModpackConfiguration.FileInformation file : remoteManifest.getFiles()) { -// Path actualPath = rootPath.resolve(file.getPath()); -// boolean download; -// if (!files.containsKey(file.getPath())) { -// // If old modpack does not have this entry, download it -// download = true; -// } else if (!Files.exists(actualPath)) { -// // If both old and new modpacks have this entry, but the file is missing... -// // Re-download it since network problem may cause file missing -// download = true; -// } else { -// // If user modified this entry file, we will not replace this file since this modified file is that user expects. -// String fileHash = encodeHex(digest("SHA-1", actualPath)); -// String oldHash = files.get(file.getPath()).getHash(); -// download = !Objects.equals(oldHash, file.getHash()) && Objects.equals(oldHash, fileHash); -// } -// -// if (download) { -// dependencies.add(new FileDownloadTask( -// new URL(remoteManifest.getFileApi() + "/overrides/" + NetworkUtils.encodeLocation(file.getPath())), -// actualPath.toFile(), -// new FileDownloadTask.IntegrityCheck("SHA-1", file.getHash()))); -// } -// } -// -// // If old modpack have this entry, and new modpack deleted it. Delete this file. -// for (ModpackConfiguration.FileInformation file : manifest.getManifest().getFiles()) { -// Path actualPath = rootPath.resolve(file.getPath()); -// if (Files.exists(actualPath) && !remoteFiles.contains(file.getPath())) -// Files.deleteIfExists(actualPath); -// } } - @Override - public boolean doPostExecute() { - return true; + private Task downloadFile(McbbsModpackManifest remoteManifest, McbbsModpackManifest.File file) throws IOException { + if (file instanceof McbbsModpackManifest.AddonFile) { + McbbsModpackManifest.AddonFile addonFile = (McbbsModpackManifest.AddonFile) file; + return new FileDownloadTask( + new URL(remoteManifest.getFileApi() + "/overrides/" + NetworkUtils.encodeLocation(addonFile.getPath())), + modManager.getSimpleModPath(addonFile.getPath()).toFile(), + addonFile.getHash() != null ? new FileDownloadTask.IntegrityCheck("SHA-1", addonFile.getHash()) : null); + } else if (file instanceof McbbsModpackManifest.CurseFile) { + // we download it later. + return null; + } else { + throw new IllegalArgumentException(); + } } - @Override - public void postExecute() throws Exception { -// if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return; -// File manifestFile = repository.getModpackConfiguration(version); -// FileUtils.writeText(manifestFile, JsonUtils.GSON.toJson(new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles()))); + @NotNull + private McbbsModpackManifest.File mergeFile(@Nullable McbbsModpackManifest.File oldFile, @NotNull McbbsModpackManifest.File newFile) { + if (newFile instanceof McbbsModpackManifest.AddonFile) { + return newFile; + } else if (newFile instanceof McbbsModpackManifest.CurseFile) { + // Preserves prefetched file names and urls. + return oldFile != null ? oldFile : newFile; + } else { + throw new IllegalArgumentException(); + } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackLocalInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackLocalInstallTask.java index 9622b1ea8..e9ff4090b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackLocalInstallTask.java @@ -41,16 +41,19 @@ import java.util.stream.Stream; public class McbbsModpackLocalInstallTask extends Task { + private final DefaultDependencyManager dependencyManager; private final File zipFile; private final Modpack modpack; private final McbbsModpackManifest manifest; private final String name; private final boolean update; private final DefaultGameRepository repository; + private final MinecraftInstanceTask instanceTask; private final List> dependencies = new LinkedList<>(); private final List> dependents = new LinkedList<>(); public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, File zipFile, Modpack modpack, McbbsModpackManifest manifest, String name) { + this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; @@ -87,7 +90,8 @@ public class McbbsModpackLocalInstallTask extends Task { } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), "/overrides", any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), "/overrides", manifest, MODPACK_TYPE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack")); + instanceTask = new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), "/overrides", manifest, MODPACK_TYPE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)); + dependents.add(instanceTask.withStage("hmcl.modpack")); } @Override @@ -115,6 +119,8 @@ public class McbbsModpackLocalInstallTask extends Task { // This mcbbs modpack was installed by other launchers. // TODO: maintain libraries. } + + dependencies.add(new McbbsModpackCompletionTask(dependencyManager, name, instanceTask.getResult()).withStage("hmcl.modpack.download")); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java index cc8055f9a..a1447589f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackManifest.java @@ -143,6 +143,10 @@ public class McbbsModpackManifest implements Validation { return launchInfo; } + public McbbsModpackManifest setFiles(List files) { + return new McbbsModpackManifest(manifestType, manifestVersion, name, version, author, description, fileApi, url, forceUpdate, origins, addons, libraries, files, settings, launchInfo); + } + @Override public void validate() throws JsonParseException, TolerableValidationException { if (!MANIFEST_TYPE.equals(manifestType)) @@ -230,7 +234,7 @@ public class McbbsModpackManifest implements Validation { } ) public static abstract class File implements Validation { - private final boolean force; + protected final boolean force; public File(boolean force) { this.force = force; @@ -251,8 +255,8 @@ public class McbbsModpackManifest implements Validation { public AddonFile(boolean force, String path, String hash) { super(force); - this.path = path; - this.hash = hash; + this.path = Objects.requireNonNull(path); + this.hash = Objects.requireNonNull(hash); } public String getPath() { @@ -270,6 +274,19 @@ public class McbbsModpackManifest implements Validation { Validation.requireNonNull(path, "AddonFile.path cannot be null"); Validation.requireNonNull(hash, "AddonFile.hash cannot be null"); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AddonFile addonFile = (AddonFile) o; + return path.equals(addonFile.path); + } + + @Override + public int hashCode() { + return Objects.hash(path); + } } public static final class CurseFile extends File { @@ -298,6 +315,7 @@ public class McbbsModpackManifest implements Validation { return fileID; } + @Nullable public String getFileName() { return fileName; } @@ -307,6 +325,14 @@ public class McbbsModpackManifest implements Validation { : NetworkUtils.toURL(NetworkUtils.encodeLocation(url)); } + public CurseFile withFileName(String fileName) { + return new CurseFile(force, projectID, fileID, fileName, url); + } + + public CurseFile withURL(String url) { + return new CurseFile(force, projectID, fileID, fileName, url); + } + @Override public void validate() throws JsonParseException, TolerableValidationException { super.validate(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackRemoteInstallTask.java index c13290c1c..585c74665 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackRemoteInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/mcbbs/McbbsModpackRemoteInstallTask.java @@ -23,13 +23,13 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.mod.ModpackConfiguration; -import org.jackhuang.hmcl.mod.server.ServerModpackManifest; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import java.io.File; import java.io.IOException; +import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -40,9 +40,9 @@ public class McbbsModpackRemoteInstallTask extends Task { private final DefaultGameRepository repository; private final List> dependencies = new LinkedList<>(); private final List> dependents = new LinkedList<>(); - private final ServerModpackManifest manifest; + private final McbbsModpackManifest manifest; - public McbbsModpackRemoteInstallTask(DefaultDependencyManager dependencyManager, ServerModpackManifest manifest, String name) { + public McbbsModpackRemoteInstallTask(DefaultDependencyManager dependencyManager, McbbsModpackManifest manifest, String name) { this.name = name; this.dependency = dependencyManager; this.repository = dependencyManager.getGameRepository(); @@ -53,7 +53,7 @@ public class McbbsModpackRemoteInstallTask extends Task { throw new IllegalArgumentException("Version " + name + " already exists."); GameBuilder builder = dependencyManager.gameBuilder().name(name); - for (ServerModpackManifest.Addon addon : manifest.getAddons()) { + for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } @@ -63,14 +63,14 @@ public class McbbsModpackRemoteInstallTask extends Task { repository.removeVersionFromDisk(name); }); - ModpackConfiguration config = null; + ModpackConfiguration config = null; try { if (json.exists()) { - config = JsonUtils.GSON.fromJson(FileUtils.readText(json), new TypeToken>() { + config = JsonUtils.GSON.fromJson(FileUtils.readText(json), new TypeToken>() { }.getType()); if (!MODPACK_TYPE.equals(config.getType())) - throw new IllegalArgumentException("Version " + name + " is not a Server modpack. Cannot update this version."); + throw new IllegalArgumentException("Version " + name + " is not a Mcbbs modpack. Cannot update this version."); } } catch (JsonParseException | IOException ignore) { } @@ -88,7 +88,7 @@ public class McbbsModpackRemoteInstallTask extends Task { @Override public void execute() throws Exception { -// dependencies.add(new McbbsModpackCompletionTask(dependency, name, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); + dependencies.add(new McbbsModpackCompletionTask(dependency, name, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); } public static final String MODPACK_TYPE = "Server"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java index 5d960ffcb..9b6cc41bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/AsyncTaskExecutor.java @@ -99,7 +99,7 @@ public final class AsyncTaskExecutor extends TaskExecutor { future.cancel(true); } - private CompletableFuture executeTasks(Task parentTask, Collection> tasks) { + private CompletableFuture executeTasksExceptionally(Task parentTask, Collection> tasks) { if (tasks == null || tasks.isEmpty()) return CompletableFuture.completedFuture(null); @@ -114,7 +114,11 @@ public final class AsyncTaskExecutor extends TaskExecutor { .map(task -> CompletableFuture.completedFuture(null) .thenComposeAsync(unused2 -> executeTask(parentTask, task)) ).toArray(CompletableFuture[]::new)); - }) + }); + } + + private CompletableFuture executeTasks(Task parentTask, Collection> tasks) { + return executeTasksExceptionally(parentTask, tasks) .thenApplyAsync(unused -> (Exception) null) .exceptionally(throwable -> { Throwable resolved = resolveException(throwable); @@ -127,7 +131,79 @@ public final class AsyncTaskExecutor extends TaskExecutor { }); } - private CompletableFuture executeTask(Task parentTask, Task task) { + private CompletableFuture executeCompletableFutureTask(Task parentTask, CompletableFutureTask task) { + checkCancellation(); + + return CompletableFuture.completedFuture(null) + .thenComposeAsync(unused -> { + checkCancellation(); + + task.setCancelled(this::isCancelled); + task.setState(Task.TaskState.READY); + if (parentTask != null && task.getStage() == null) + task.setStage(parentTask.getStage()); + + if (task.getSignificance().shouldLog()) + Logging.LOG.log(Level.FINE, "Executing task: " + task.getName()); + + taskListeners.forEach(it -> it.onReady(task)); + + return task.getFuture(new TaskCompletableFuture() { + @Override + public CompletableFuture one(Task subtask) { + return executeTask(task, subtask); + } + + @Override + public CompletableFuture all(Collection> tasks) { + return executeTasksExceptionally(task, tasks); + } + }); + }) + .thenApplyAsync(result -> { + checkCancellation(); + + if (task.getSignificance().shouldLog()) { + Logging.LOG.log(Level.FINER, "Task finished: " + task.getName()); + } + + task.setResult(result); + task.onDone().fireEvent(new TaskEvent(this, task, false)); + taskListeners.forEach(it -> it.onFinished(task)); + + task.setState(Task.TaskState.SUCCEEDED); + + return result; + }) + .exceptionally(throwable -> { + Throwable resolved = resolveException(throwable); + if (resolved instanceof Exception) { + Exception e = (Exception) resolved; + if (e instanceof InterruptedException || e instanceof CancellationException) { + task.setException(null); + if (task.getSignificance().shouldLog()) { + Logging.LOG.log(Level.FINE, "Task aborted: " + task.getName()); + } + task.onDone().fireEvent(new TaskEvent(this, task, true)); + taskListeners.forEach(it -> it.onFailed(task, e)); + } else { + task.setException(e); + exception = e; + if (task.getSignificance().shouldLog()) { + Logging.LOG.log(Level.FINE, "Task failed: " + task.getName(), e); + } + task.onDone().fireEvent(new TaskEvent(this, task, true)); + taskListeners.forEach(it -> it.onFailed(task, e)); + } + + task.setState(Task.TaskState.FAILED); + } + + throw new CompletionException(resolved); // rethrow error + }); + } + + private CompletableFuture executeNormalTask(Task parentTask, Task task) { checkCancellation(); return CompletableFuture.completedFuture(null) @@ -185,7 +261,7 @@ public final class AsyncTaskExecutor extends TaskExecutor { return CompletableFuture.completedFuture(dependenciesException); } }) - .thenAcceptAsync(dependenciesException -> { + .thenApplyAsync(dependenciesException -> { boolean isDependenciesSucceeded = dependenciesException == null; if (!isDependenciesSucceeded && task.isRelyingOnDependencies()) { @@ -204,6 +280,8 @@ public final class AsyncTaskExecutor extends TaskExecutor { taskListeners.forEach(it -> it.onFinished(task)); task.setState(Task.TaskState.SUCCEEDED); + + return task.getResult(); }) .exceptionally(throwable -> { Throwable resolved = resolveException(throwable); @@ -233,6 +311,14 @@ public final class AsyncTaskExecutor extends TaskExecutor { }); } + private CompletableFuture executeTask(Task parentTask, Task task) { + if (task instanceof CompletableFutureTask) { + return executeCompletableFutureTask(parentTask, (CompletableFutureTask) task); + } else { + return executeNormalTask(parentTask, task); + } + } + private static Throwable resolveException(Throwable e) { if (e instanceof ExecutionException || e instanceof CompletionException) return resolveException(e.getCause()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CompletableFutureTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CompletableFutureTask.java new file mode 100644 index 000000000..90fd040c2 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CompletableFutureTask.java @@ -0,0 +1,92 @@ +package org.jackhuang.hmcl.task; + +import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer; +import org.jackhuang.hmcl.util.function.ExceptionalConsumer; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import org.jackhuang.hmcl.util.function.ExceptionalRunnable; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +public abstract class CompletableFutureTask extends Task { + + @Override + public void execute() throws Exception { + } + + public abstract CompletableFuture getFuture(TaskCompletableFuture executor); + + protected static Runnable wrap(ExceptionalRunnable runnable) { + return () -> { + try { + runnable.run(); + } catch (Exception e) { + rethrow(e); + } + }; + } + + protected static Function wrap(ExceptionalFunction fn) { + return t -> { + try { + return fn.apply(t); + } catch (Exception e) { + rethrow(e); + throw new InternalError("Unreachable code"); + } + }; + } + + protected static Consumer wrap(ExceptionalConsumer fn) { + return t -> { + try { + fn.accept(t); + } catch (Exception e) { + rethrow(e); + } + }; + } + + protected static BiConsumer wrap(ExceptionalBiConsumer fn) { + return (t, e) -> { + try { + fn.accept(t, e); + } catch (Exception ex) { + rethrow(ex); + } + }; + } + + protected static void rethrow(Throwable e) { + if (e == null) + return; + if (e instanceof ExecutionException || e instanceof CompletionException) { // including UncheckedException and UncheckedThrowable + rethrow(e.getCause()); + } else if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new CompletionException(e); + } + } + + protected static Throwable resolveException(Throwable e) { + if (e instanceof ExecutionException || e instanceof CompletionException) + return resolveException(e.getCause()); + else + return e; + } + + public static class CustomException extends RuntimeException {} + + protected static CompletableFuture breakable(CompletableFuture future) { + return future.thenApplyAsync(unused1 -> (Void) null).exceptionally(throwable -> { + if (resolveException(throwable) instanceof CustomException) return null; + else throw new CompletionException(throwable); + }); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/TaskCompletableFuture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/TaskCompletableFuture.java new file mode 100644 index 000000000..8f32ce459 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/TaskCompletableFuture.java @@ -0,0 +1,11 @@ +package org.jackhuang.hmcl.task; + +import java.util.Collection; +import java.util.concurrent.CompletableFuture; + +public interface TaskCompletableFuture { + + CompletableFuture one(Task task); + + CompletableFuture all(Collection> tasks); +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java index 424e64776..995693777 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java @@ -23,6 +23,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonSyntaxException; import java.io.File; +import java.lang.reflect.Type; import java.util.Date; import java.util.UUID; @@ -43,6 +44,13 @@ public final class JsonUtils { return parsed; } + public static T fromNonNullJson(String json, Type type) throws JsonParseException { + T parsed = GSON.fromJson(json, type); + if (parsed == null) + throw new JsonParseException("Json object cannot be null."); + return parsed; + } + public static T fromMaybeMalformedJson(String json, Class classOfT) throws JsonParseException { try { return GSON.fromJson(json, classOfT);