From 4d2a922184b67f3c8c6544d2ee04fe7e686f42f0 Mon Sep 17 00:00:00 2001 From: ONEDAY <62494140+fatelove42@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:15:08 +0800 Subject: [PATCH 1/2] feat(curse): add API retries and preserve file hashes for CurseForge completion --- .../CurseForgeRemoteAddonRepository.java | 10 +++- .../modpack/curse/CurseCompletionTask.java | 53 +++++++++++++++---- .../hmcl/modpack/curse/CurseManifestFile.java | 30 +++++++++-- .../server/ServerModpackCompletionTask.java | 2 +- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index 632c79d3a93..3cac1bf2470 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -594,7 +594,15 @@ public RemoteAddon.Version toVersion() { fileName(), fileDate(), versionType, - new RemoteAddon.File(Collections.emptyMap(), downloadUrl(), fileName()), + new RemoteAddon.File(hashes == null ? Collections.emptyMap() : hashes.stream().collect(Collectors.toMap( + hash -> switch (hash.algo()) { + case 1 -> "sha1"; + case 2 -> "md5"; + default -> "algo" + hash.algo(); + }, + LatestFileHash::value, + (a, b) -> a + )), downloadUrl(), fileName()), dependencies.stream().map(dependency -> { if (!RELATION_TYPE.containsKey(dependency.relationType())) { throw new IllegalStateException("Broken datas."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index d48570f82a4..3671a0a46bf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -122,15 +122,32 @@ public void execute() throws Exception { .map(file -> { updateProgress(finished.incrementAndGet(), manifest.files().size()); if (StringUtils.isBlank(file.fileName()) || file.url() == null) { - try { - RemoteAddon.File remoteFile = CurseForgeRemoteAddonRepository.MODS.getAddonFile(Integer.toString(file.projectID()), Integer.toString(file.fileID())); - return file.withFileName(remoteFile.filename()).withURL(remoteFile.url()); - } catch (FileNotFoundException fof) { - LOG.warning("Could not query api.curseforge.com for deleted mods: " + file.projectID() + ", " + file.fileID(), fof); - notFound.set(true); - return file; - } catch (IOException | JsonParseException e) { - LOG.warning("Unable to fetch the file name projectID=" + file.projectID() + ", fileID=" + file.fileID(), e); + RemoteAddon.File remoteFile = null; + Exception lastException = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + remoteFile = CurseForgeRemoteAddonRepository.MODS.getAddonFile(Integer.toString(file.projectID()), Integer.toString(file.fileID())); + break; + } catch (FileNotFoundException fof) { + LOG.warning("Could not query api.curseforge.com for deleted mods: " + file.projectID() + ", " + file.fileID(), fof); + notFound.set(true); + return file; + } catch (IOException | JsonParseException e) { + lastException = e; + if (attempt < 2) { + try { + Thread.sleep(500L * (attempt + 1)); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + if (remoteFile != null) { + return file.withFileName(remoteFile.filename()).withURL(remoteFile.url()).withHashes(remoteFile.hashes()); + } else { + LOG.warning("Unable to fetch the file name projectID=" + file.projectID() + ", fileID=" + file.fileID(), lastException); allNameKnown.set(false); return file; } @@ -150,12 +167,26 @@ public void execute() throws Exception { .filter(f -> f.fileName() != null) .flatMap(f -> { try { - Path path = guessFilePath(f, dependency.getDownloadProvider(), resourcePacksRoot, shaderPacksRoot); + Path path = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + path = guessFilePath(f, dependency.getDownloadProvider(), resourcePacksRoot, shaderPacksRoot); + break; + } catch (IOException e) { + if (attempt == 2) throw e; + try { + Thread.sleep(500L * (attempt + 1)); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + } if (path == null) { return Stream.empty(); } - var task = new FileDownloadTask(f.url(), path); + var task = new FileDownloadTask(f.url(), path, f.getIntegrityCheck()); task.setCacheRepository(dependency.getCacheRepository()); task.setCaching(true); return Stream.of(task.withCounter("hmcl.modpack.download")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java index b8edc77f56f..bd86bdb91cc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java @@ -31,7 +31,12 @@ public record CurseManifestFile(@SerializedName("projectID") int projectID, @SerializedName("fileID") int fileID, @SerializedName("fileName") String fileName, @SerializedName("url") String url, - @SerializedName("required") boolean required) implements Validation { + @SerializedName("required") boolean required, + @SerializedName("hashes") java.util.Map hashes) implements Validation { + + public CurseManifestFile(int projectID, int fileID, String fileName, String url, boolean required) { + this(projectID, fileID, fileName, url, required, null); + } @Override public void validate() throws JsonParseException { @@ -51,12 +56,31 @@ public String url() { } } + @Nullable + public org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck getIntegrityCheck() { + if (hashes == null || hashes.isEmpty()) return null; + if (hashes.containsKey("sha1")) { + return new org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck("SHA-1", hashes.get("sha1")); + } else if (hashes.containsKey("md5")) { + return new org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck("MD5", hashes.get("md5")); + } else if (hashes.containsKey("sha256")) { + return new org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck("SHA-256", hashes.get("sha256")); + } else if (hashes.containsKey("sha512")) { + return new org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck("SHA-512", hashes.get("sha512")); + } + return null; + } + public CurseManifestFile withFileName(String fileName) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); } public CurseManifestFile withURL(String url) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); + } + + public CurseManifestFile withHashes(java.util.Map hashes) { + return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java index 2caca9ab70e..64f57a1082a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java @@ -121,7 +121,7 @@ public void execute() throws Exception { dependencies.add(builder.buildAsync()); } - Path rootPath = repository.getInstanceRoot(instanceId).toAbsolutePath().normalize(); + Path rootPath = repository.getRunDirectory(instanceId).toAbsolutePath().normalize(); Map files = manifest.getManifest().getFiles().stream() .collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath, Function.identity())); From 7e0f3ebb0d9752092b0d7f36e8fc4da39a20b453 Mon Sep 17 00:00:00 2001 From: ONEDAY <62494140+fatelove42@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:55:07 +0800 Subject: [PATCH 2/2] fix(curse): make hash enrichment optional when fileName and url are known --- .../hmcl/modpack/curse/CurseCompletionTask.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index 3671a0a46bf..a1b23a5f68c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -121,7 +121,9 @@ public void execute() throws Exception { manifest.files().parallelStream() .map(file -> { updateProgress(finished.incrementAndGet(), manifest.files().size()); - if (StringUtils.isBlank(file.fileName()) || file.url() == null) { + boolean mandatory = StringUtils.isBlank(file.fileName()) || file.url() == null; + boolean needHashes = file.hashes() == null || file.hashes().isEmpty(); + if (mandatory || needHashes) { RemoteAddon.File remoteFile = null; Exception lastException = null; for (int attempt = 0; attempt < 3; attempt++) { @@ -130,7 +132,9 @@ public void execute() throws Exception { break; } catch (FileNotFoundException fof) { LOG.warning("Could not query api.curseforge.com for deleted mods: " + file.projectID() + ", " + file.fileID(), fof); - notFound.set(true); + if (mandatory) { + notFound.set(true); + } return file; } catch (IOException | JsonParseException e) { lastException = e; @@ -147,8 +151,10 @@ public void execute() throws Exception { if (remoteFile != null) { return file.withFileName(remoteFile.filename()).withURL(remoteFile.url()).withHashes(remoteFile.hashes()); } else { - LOG.warning("Unable to fetch the file name projectID=" + file.projectID() + ", fileID=" + file.fileID(), lastException); - allNameKnown.set(false); + LOG.warning("Unable to fetch the file info projectID=" + file.projectID() + ", fileID=" + file.fileID(), lastException); + if (mandatory) { + allNameKnown.set(false); + } return file; } } else {