Fix #6833: Automatically synchronize client JARs when updating game instances (#6843)

This commit is contained in:
Glavo authored and GitHub committed 2026-09-10 10:14:11 +08:00
1 parent 886a25a935
commit 24702dc5a0
5 files changed
+289 -62

No files matched your search

@@ -65,6 +65,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
public void start(SettingsMap settings) {
}
/// Creates a task that updates components and synchronizes the client JAR with the resulting manifest.
@Override
public Object finish(SettingsMap settings) {
settings.put("title", i18n("install.change_version.process"));
@@ -83,7 +84,7 @@ public final class UpdateInstallerWizardProvider implements WizardProvider {
}
}
return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), publishedInstance -> {
return dependencyManager.updateInstanceAsync(gameInstance.getId(), publishedInstance -> {
Task<GameInstanceManifest> update = Task.supplyAsync(publishedInstance::getManifest);
for (Object value : settings.asStringMap().values()) {
if (value instanceof ComponentRemoteVersion remoteVersion) {
@@ -66,8 +66,9 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
return new InstallerListPageSkin();
}
/// Rebuilds the component controls for the selected instance, or clears them if none is selected.
public void loadInstance(HMCLGameInstance.Optional instance) {
HMCLGameInstance gameInstance = instance.instance();
@Nullable HMCLGameInstance gameInstance = instance.instance();
this.gameInstance = gameInstance;
if (gameInstance == null) {
itemsProperty().clear();
@@ -103,7 +104,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion));
});
component.setOnRemove(() -> repository.updateInstanceAsync(
component.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
@@ -123,7 +124,7 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM);
installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false));
installerItem.setOnRemove(() -> repository.updateInstanceAsync(
installerItem.setOnRemove(() -> repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().removeComponentAsync(
publishedInstance,
@@ -148,13 +149,14 @@ public class InstallerListPage extends ListPageBase<InstallerItem> {
if (file != null) doInstallOffline(file);
}
/// Runs a local component installer for the current modifiable instance and displays its result.
private void doInstallOffline(Path file) {
if (gameInstance == null || !gameInstance.getManifest().isModifiable()) {
return;
}
HMCLGameRepository repository = gameInstance.getRepository();
Task<?> task = repository.updateInstanceAsync(
Task<?> task = repository.getDependency().updateInstanceAsync(
gameInstance.getId(),
publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file));
task.setName(i18n("install.installer.install_offline"));
@@ -26,6 +26,7 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
import org.jackhuang.hmcl.game.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
@@ -36,6 +37,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
/// Provides downloads and game-component installation for one game repository.
@@ -163,6 +165,69 @@ public class DefaultDependencyManager extends AbstractDependencyManager {
}
}
/// Creates a task that updates an instance's manifest and its own client JAR in one draft.
///
/// The updater receives the published instance and must return a manifest with the same id.
/// If the updated manifest uses this instance's own JAR, that JAR is replaced when the resolved
/// client download changes or the previous manifest used another instance's JAR. Downloads are
/// compared by SHA-1 when either declares a checksum, otherwise by URL and size. Matching
/// checksums identify the same client even when the URLs differ. Referenced JARs are not modified.
///
/// A replacement is obtained through this manager's download provider and cache before the
/// manifest and JAR are committed together. A task failure aborts the draft and retains the
/// previously published instance files. Shared cache files written by the tasks are retained.
///
/// @param <E> the checked exception type thrown while creating the update task
/// @param instanceId the instance to update
/// @param updater the asynchronous manifest update
/// @return the task that commits the updated instance
public <E extends Exception> Task<Void> updateInstanceAsync(
GameInstanceID instanceId,
ExceptionalFunction<GameInstance, Task<GameInstanceManifest>, E> updater) {
return Task.composeAsync(() -> {
DefaultGameRepositoryDraft draft = repository.openDraft();
try {
GameInstance instance = draft.getBaseSnapshot().getInstance(instanceId);
return Objects.requireNonNull(updater.apply(instance), "Instance updater returned null")
.thenComposeAsync(manifest -> {
if (!instanceId.equals(manifest.id())) {
throw new IllegalArgumentException(
"Instance updater changed id from " + instanceId + " to " + manifest.id());
}
GameInstanceManifest previous = instance.getResolvedManifest();
GameInstanceManifest resolved = draft.getBaseSnapshot().resolve(manifest);
draft.put(manifest);
if (instanceId.equals(resolved.jar())
&& (!instanceId.equals(previous.jar())
|| !isSameClientDownload(previous.getDownloadInfo(), resolved.getDownloadInfo()))) {
return new GameDownloadTask(this, resolved).thenAcceptAsync(
jar -> draft.putPrimaryJar(instanceId, jar));
}
return null;
}).thenRunAsync(() -> {
draft.commit();
}).whenComplete(exception -> {
if (draft.isOpen()) {
draft.abort();
}
});
} catch (Throwable exception) {
abortDraftAfterFailure(draft, exception);
throw exception;
}
});
}
/// Compares client identity by checksum, falling back to URL and size when neither has one.
private static boolean isSameClientDownload(DownloadInfo previous, DownloadInfo updated) {
@Nullable String previousSha1 = previous.getSha1();
@Nullable String updatedSha1 = updated.getSha1();
if (previousSha1 != null || updatedSha1 != null) {
return previousSha1 != null && previousSha1.equalsIgnoreCase(updatedSha1);
}
return previous.getUrl().equals(updated.getUrl()) && previous.getSize() == updated.getSize();
}
@Override
public Task<?> checkGameCompletionAsync(
GameInstance instance,
@@ -24,7 +24,6 @@ import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNullByDefault;
@@ -525,57 +524,6 @@ public abstract class DefaultGameRepository implements GameRepository {
return Task.supplyAsync(() -> save(instanceManifest));
}
/// Creates a task that updates one registered instance inside an exclusive draft.
///
/// The updater receives the instance from the immutable published snapshot and must return a
/// working manifest with the same id. Its result is staged and committed exactly once. Failure
/// or cancellation aborts the draft; shared cache files written by the updater are retained.
///
/// @param <E> the checked exception type thrown while creating the update task
/// @param instanceId the instance to update
/// @param updater the asynchronous manifest update
/// @return the task that commits the updated manifest
public <E extends Exception> Task<Void> updateInstanceAsync(
GameInstanceID instanceId,
ExceptionalFunction<GameInstance, Task<GameInstanceManifest>, E> updater) {
return Task.composeAsync(() -> {
DefaultGameRepositoryDraft draft = openDraft();
try {
return Objects.requireNonNull(
updater.apply(draft.getBaseSnapshot().getInstance(instanceId)),
"Instance updater returned null")
.thenApplyAsync(manifest -> {
if (!instanceId.equals(manifest.id())) {
throw new IllegalArgumentException(
"Instance updater changed id from " + instanceId + " to " + manifest.id());
}
draft.put(manifest);
draft.commit();
return manifest;
}).whenComplete(exception -> {
if (draft.isOpen()) {
draft.abort();
}
});
} catch (Throwable exception) {
abortDraftAfterFailure(draft, exception);
throw exception;
}
});
}
/// Aborts a draft after task construction fails and preserves any cleanup failure.
///
/// @param draft the draft to abort
/// @param failure the failure that prevented task construction
private static void abortDraftAfterFailure(DefaultGameRepositoryDraft draft, Throwable failure) {
try {
draft.abort();
} catch (Exception cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
/// Creates an empty unsealed snapshot for the given layout.
///
/// @param layout the layout for the new snapshot
@@ -17,17 +17,29 @@
*/
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DefaultCacheRepository;
import org.jackhuang.hmcl.download.DefaultDependencyManager;
import org.jackhuang.hmcl.download.MojangDownloadProvider;
import org.jackhuang.hmcl.download.game.GameDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpHeaders;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -277,18 +289,211 @@ public final class DefaultGameRepositoryDraftTest {
assertEquals(GameRepositoryDraft.State.FAILED, draft.getState());
}
/// Runs an asynchronous update using the published instance as immutable context.
/// A manifest-only update preserves an existing client JAR.
@Test
public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
repository.save(new GameInstanceManifest(id).withMainClass("original.Main"));
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "custom client");
Task<?> update = repository.updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() ->
Task<?> update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() ->
workingInstance.getManifest().withMainClass("updated.Main")));
assertTrue(update.executor().test());
assertEquals("updated.Main", repository.getInstance(id).getManifest().mainClass());
assertEquals("custom client", Files.readString(target));
}
/// A version change replaces an existing client JAR with the file selected by the new manifest.
@Test
public void testUpdateInstanceAsyncReplacesClientJar(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory.resolve("game"));
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main");
repository.save(original);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "old client");
Path source = tempDirectory.resolve("new-client.jar");
Files.writeString(source, "new client");
String sha1 = DigestUtils.digestToString("SHA-1", source);
DefaultCacheRepository cache = new DefaultCacheRepository(tempDirectory.resolve("cache"));
Path cached = cache.cacheFile(source, "SHA-1", sha1);
DefaultDependencyManager manager = new DefaultDependencyManager(
repository, new MojangDownloadProvider(), cache);
GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/client.jar", sha1)));
Task<?> update = manager.updateInstanceAsync(id, instance ->
new GameDownloadTask(manager, replacement).thenApplyAsync(jar -> {
assertEquals("old client", Files.readString(target));
assertEquals(original, repository.getInstance(id).getManifest());
return replacement;
}));
assertTrue(update.executor().test());
assertEquals(replacement, repository.getInstance(id).getManifest());
assertTrue(replacement.getDownloadInfo().validateChecksum(target, false));
assertEquals("new client", Files.readString(cached));
}
/// Equivalent checksums preserve a customized client even when the download URL changes.
@Test
public void testUpdateInstanceAsyncPreservesClientWithSameChecksum(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id).withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/old.jar", "a".repeat(40))));
repository.save(original);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "custom client");
GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/new.jar", "A".repeat(40))));
assertTrue(createDependencyManager(repository).updateInstanceAsync(id,
instance -> Task.completed(replacement)).executor().test());
assertEquals(replacement, repository.getInstance(id).getManifest());
assertEquals("custom client", Files.readString(target));
}
/// A changed URL selects a replacement when neither client download declares a checksum.
@Test
public void testUpdateInstanceAsyncReplacesClientWithoutChecksum(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id).withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/old.jar")));
repository.save(original);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "old client");
String newUrl = "https://example.invalid/new.jar";
DefaultDependencyManager manager = createDependencyManager(repository);
manager.getCacheRepository().cacheText(new UrlResponseInfo(200, URI.create(newUrl),
HttpHeaders.of(Map.of("etag", List.of("new-client"),
"cache-control", List.of("max-age=3600")), (name, value) -> true)), "new client");
GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT, new DownloadInfo(newUrl)));
assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test());
assertEquals(replacement, repository.getInstance(id).getManifest());
assertEquals("new client", Files.readString(target));
}
/// Client changes supplied only by patches are resolved before choosing the replacement JAR.
@Test
public void testUpdateInstanceAsyncResolvesClientPatch(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id);
repository.save(original);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "old client");
Path source = tempDirectory.resolve("new-client.jar");
Files.writeString(source, "new client");
String sha1 = DigestUtils.digestToString("SHA-1", source);
DefaultDependencyManager manager = createDependencyManager(repository);
manager.getCacheRepository().cacheFile(source, "SHA-1", sha1);
GameInstanceManifest replacement = original.withPatches(List.of(GameInstancePatch.fromManifest(
original.withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/client.jar", sha1))),
GameComponentType.GAME.getPatchId(), "new-version", GameInstancePatch.PRIORITY_MC)));
assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test());
assertEquals(replacement, repository.getInstance(id).getManifest());
assertEquals("new client", Files.readString(target));
}
/// Switching JAR ownership materializes an owned client but never replaces a referenced client.
@ParameterizedTest
@ValueSource(booleans = {false, true})
public void testUpdateInstanceAsyncSwitchesJarOwnership(boolean useOwnJar, @TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
GameInstanceID sharedId = new GameInstanceID("shared");
repository.save(new GameInstanceManifest(sharedId));
Path sharedJar = repository.getLayout().getInstanceJarFile(sharedId);
Files.writeString(sharedJar, "shared client");
String sha1 = DigestUtils.digestToString("SHA-1", sharedJar);
DownloadInfo download = new DownloadInfo("https://example.invalid/client.jar", sha1);
GameInstanceManifest original = new GameInstanceManifest(id)
.withJar(useOwnJar ? sharedId : id)
.withDownloads(useOwnJar ? Map.of(DownloadType.CLIENT, download) : null);
repository.save(original);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "custom client");
DefaultDependencyManager manager = createDependencyManager(repository);
manager.getCacheRepository().cacheFile(sharedJar, "SHA-1", sha1);
GameInstanceManifest replacement = original.withJar(useOwnJar ? id : sharedId)
.withDownloads(Map.of(DownloadType.CLIENT, download));
assertTrue(manager.updateInstanceAsync(id, instance -> Task.completed(replacement)).executor().test());
assertEquals(useOwnJar ? "shared client" : "custom client", Files.readString(target));
assertEquals("shared client", Files.readString(sharedJar));
assertEquals(useOwnJar ? target : sharedJar, repository.getInstance(id).getInstanceJarFile());
}
/// A failed JAR lookup leaves both the old manifest and its client JAR intact.
@Test
public void testUpdateInstanceAsyncPreservesFilesWhenJarTaskFails(@TempDir Path tempDirectory) throws Exception {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main");
repository.save(original);
Path manifestFile = repository.getLayout().getInstanceJson(id);
String originalJson = Files.readString(manifestFile);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "original");
GameInstanceManifest replacement = original.withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("file:///missing-client.jar", "0".repeat(40))));
Task<?> update = createDependencyManager(repository).updateInstanceAsync(id,
instance -> Task.completed(replacement));
assertFalse(update.executor().test());
assertEquals(original, repository.getInstance(id).getManifest());
assertEquals(originalJson, Files.readString(manifestFile));
assertEquals("original", Files.readString(target));
try (DefaultGameRepositoryDraft draft = repository.openDraft()) {
assertTrue(draft.isOpen());
}
}
/// Publication failure rolls back both files after an asynchronous update stages a new JAR.
@Test
public void testUpdateInstanceAsyncRollsBackManifestAndJar(@TempDir Path tempDirectory) throws Exception {
FailingRepository repository = new FailingRepository(tempDirectory.resolve("game"));
GameInstanceID id = new GameInstanceID("instance");
GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main");
repository.save(original);
Path manifestFile = repository.getLayout().getInstanceJson(id);
String originalJson = Files.readString(manifestFile);
Path target = repository.getLayout().getInstanceJarFile(id);
Files.writeString(target, "original");
Path source = tempDirectory.resolve("replacement.jar");
Files.writeString(source, "replacement");
repository.failDraftPublish = true;
DefaultDependencyManager manager = createDependencyManager(repository);
String sha1 = DigestUtils.digestToString("SHA-1", source);
Path cached = manager.getCacheRepository().cacheFile(source, "SHA-1", sha1);
GameInstanceManifest replacement = original.withMainClass("updated.Main")
.withDownloads(Map.of(DownloadType.CLIENT,
new DownloadInfo("https://example.invalid/client.jar", sha1)));
Task<?> update = manager.updateInstanceAsync(id, instance -> Task.completed(replacement));
assertFalse(update.executor().test());
assertEquals(original, repository.getInstance(id).getManifest());
assertEquals(originalJson, Files.readString(manifestFile));
assertEquals("original", Files.readString(target));
assertEquals("replacement", Files.readString(source));
assertEquals("replacement", Files.readString(cached));
try (DefaultGameRepositoryDraft draft = repository.openDraft()) {
assertTrue(draft.isOpen());
}
}
/// Rejects an asynchronous update that attempts to create a different instance id.
@@ -299,7 +504,7 @@ public final class DefaultGameRepositoryDraftTest {
GameInstanceID otherId = new GameInstanceID("other");
repository.save(new GameInstanceManifest(id).withMainClass("original.Main"));
Task<?> update = repository.updateInstanceAsync(id, workingInstance ->
Task<?> update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance ->
Task.supplyAsync(() -> workingInstance.getManifest().withId(otherId)));
assertFalse(update.executor().test());
@@ -316,7 +521,7 @@ public final class DefaultGameRepositoryDraftTest {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("missing");
Task<?> update = repository.updateInstanceAsync(id, workingInstance ->
Task<?> update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance ->
Task.completed(workingInstance.getManifest()));
assertFalse(update.executor().test());
@@ -332,7 +537,7 @@ public final class DefaultGameRepositoryDraftTest {
GameInstanceID id = new GameInstanceID("instance");
repository.save(new GameInstanceManifest(id));
Task<?> update = repository.updateInstanceAsync(id, workingInstance -> {
Task<?> update = createDependencyManager(repository).updateInstanceAsync(id, workingInstance -> {
throw new IOException("Simulated updater failure");
});
assertFalse(update.executor().test());
@@ -342,6 +547,12 @@ public final class DefaultGameRepositoryDraftTest {
}
}
/// Creates a dependency manager with a cache isolated inside the test repository.
private static DefaultDependencyManager createDependencyManager(TestRepository repository) {
return new DefaultDependencyManager(repository, new MojangDownloadProvider(),
new DefaultCacheRepository(repository.getBaseDirectory().resolve("cache")));
}
/// Minimal repository implementation for draft tests.
@NotNullByDefault
private static class TestRepository extends DefaultGameRepository {