Enhance instance manifest handling and add failed task creation method (#7163)

This commit is contained in:
Glavo authored and GitHub committed 2026-11-02 21:27:50 +08:00
1 parent f81ba320da
commit 75d070d8c9
4 files changed
+84 -17

No files matched your search

@@ -5,6 +5,7 @@ package org.jackhuang.hmcl.ui.wizard;
import javafx.scene.Node;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.SettingsMap;
import org.jetbrains.annotations.Nullable;
import java.util.*;
@@ -117,9 +118,17 @@ public class WizardController implements Navigation {
return pages.size() > 1;
}
/// Finishes the provider and displays its result. Runtime exceptions thrown while preparing
/// the result are passed to the displayer as failed tasks, using its normal failure and cleanup handling.
@Override
public void onFinish() {
Object result = provider.finish(settings);
WizardProvider currentProvider = Objects.requireNonNull(provider);
@Nullable Object result;
try {
result = currentProvider.finish(settings);
} catch (RuntimeException failure) {
result = Task.failed(failure);
}
if (result instanceof Summary) displayer.navigateTo(((Summary) result).getComponent(), NavigationDirection.NEXT);
else if (result instanceof Task<?>) displayer.handleTask(settings, ((Task<?>) result));
else if (result != null) throw new IllegalStateException("Unrecognized wizard result: " + result);
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.game;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
@@ -319,7 +320,7 @@ public abstract class DefaultGameRepository implements GameRepository {
json = conventionalJson;
} else {
List<Path> jsons = FileUtils.listFilesByExtension(dir, "json");
@Nullable Path candidate = jsons.size() == 1 ? jsons.get(0) : findInstanceManifest(jsons);
@Nullable Path candidate = jsons.size() == 1 ? jsons.get(0) : findInstanceManifest(jsons, id);
if (candidate == null) {
LOG.info("No available json file found, ignoring instance " + id);
return null;
@@ -335,17 +336,12 @@ public abstract class DefaultGameRepository implements GameRepository {
GameInstanceManifest manifest;
try {
manifest = readInstanceManifest(json);
manifest = readInstanceManifest(json, id);
} catch (Exception e) {
LOG.warning("Malformed instance json " + id + " (" + json + ")", e);
return null;
}
// Directory name is the repository identity; keep the on-disk files untouched.
if (!id.equals(manifest.id())) {
manifest = manifest.withId(id);
}
return createInstance(snapshot, id, manifest, manifestFileOverride);
}
@@ -353,11 +349,12 @@ public abstract class DefaultGameRepository implements GameRepository {
/// a parent instance, or non-empty patches, skipping unreadable and malformed candidates.
///
/// @param jsons the candidate JSON files; the list is not modified
/// @param id the directory-derived instance identity
/// @return the selected path, or `null` if no candidate matches
private static @Nullable Path findInstanceManifest(List<Path> jsons) {
private static @Nullable Path findInstanceManifest(List<Path> jsons, GameInstanceID id) {
for (Path json : jsons.stream().sorted(Comparator.comparing(FileUtils::getName)).toList()) {
try {
GameInstanceManifest manifest = readInstanceManifest(json);
GameInstanceManifest manifest = readInstanceManifest(json, id);
@Nullable String mainClass = manifest.mainClass();
if ((mainClass != null && !mainClass.isBlank())
|| manifest.inheritsFrom() != null
@@ -370,18 +367,24 @@ public abstract class DefaultGameRepository implements GameRepository {
return null;
}
/// Reads a non-null instance manifest from a JSON file.
/// Reads an instance manifest using the directory-derived identity.
///
/// Replaces the JSON object's `id` in memory before parsing the manifest, including when
/// the original member is absent, null, or invalid. The file is not modified.
///
/// @param json the manifest file
/// @return the parsed manifest
/// @param id the directory-derived instance identity
/// @return the parsed manifest with the supplied identity
/// @throws IOException if the file cannot be read
/// @throws JsonParseException if the JSON is malformed or does not describe a manifest
private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException {
@Nullable GameInstanceManifest manifest = JsonUtils.fromJsonFile(json, GameInstanceManifest.class);
if (manifest == null) {
/// @throws JsonParseException if the JSON is malformed, is not an object, or cannot be parsed as a manifest
private static GameInstanceManifest readInstanceManifest(Path json, GameInstanceID id) throws IOException, JsonParseException {
@Nullable JsonObject object = JsonUtils.fromJsonFile(json, JsonObject.class);
if (object == null) {
throw new JsonParseException("Manifest is null");
}
return manifest;
// Supply the repository identity before the manifest parser validates the required ID.
object.addProperty("id", id.toString());
return GameInstanceManifest.fromJson(object, false);
}
static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException {
@@ -13,6 +13,7 @@ import org.jackhuang.hmcl.util.function.ExceptionalConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.invoke.MethodHandles;
@@ -964,6 +965,19 @@ public abstract class Task<T> {
return fromCompletableFuture(CompletableFuture.completedFuture(value));
}
/// Creates a task backed by a future that has already completed exceptionally.
///
/// The failure is processed by the task executor when the task is submitted;
/// this method does not start execution or notify task listeners.
///
/// @param <V> the task's result type
/// @param failure the exception completing the backing future
/// @return a new task backed by the failed future
/// @throws NullPointerException if failure is null
public static <V> @NotNull Task<V> failed(@NotNull Exception failure) {
return fromCompletableFuture(CompletableFuture.failedFuture(failure));
}
/**
* Returns a new Task that is completed when all of the given Tasks
* complete. If any of the given Tasks complete exceptionally,
@@ -61,6 +61,47 @@ public final class DefaultGameRepositoryDraftTest {
assertFalse(Files.exists(repository.getLayout().getInstanceJson(id)));
}
/// Supplies missing IDs for conventional, single-candidate, and multiple-candidate manifests without rewriting files.
@ParameterizedTest
@ValueSource(strings = {"conventional", "single", "multiple"})
public void testRefreshSuppliesMissingManifestId(String layout, @TempDir Path tempDirectory) throws IOException {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
Path root = Files.createDirectories(repository.getLayout().getInstanceRoot(id));
Path manifestFile = root.resolve(layout.equals("conventional") ? "instance.json" : "game.json");
String originalJson = "{\"mainClass\":\"example.Main\"}";
Files.writeString(manifestFile, originalJson);
if (layout.equals("multiple")) {
Files.writeString(root.resolve("a-settings.json"), "{}");
Files.writeString(root.resolve("b-broken.json"), "{");
}
repository.refresh();
DefaultGameInstance instance = repository.getInstance(id);
assertEquals(id, instance.getManifest().id());
assertEquals("example.Main", instance.getManifest().mainClass());
assertEquals(manifestFile, instance.getManifestFile());
assertEquals(originalJson, Files.readString(manifestFile));
}
/// Uses the directory identity even when the JSON ID is null, invalid, or different.
@ParameterizedTest
@ValueSource(strings = {"null", "42", "{}", "\"..\"", "\"original\""})
public void testRefreshOverridesManifestId(String jsonId, @TempDir Path tempDirectory) throws IOException {
TestRepository repository = new TestRepository(tempDirectory);
GameInstanceID id = new GameInstanceID("instance");
Files.createDirectories(repository.getLayout().getInstanceRoot(id));
Path manifestFile = repository.getLayout().getInstanceJson(id);
String originalJson = "{\"id\":" + jsonId + ",\"mainClass\":\"example.Main\"}";
Files.writeString(manifestFile, originalJson);
repository.refresh();
assertEquals(id, repository.getInstance(id).getManifest().id());
assertEquals(originalJson, Files.readString(manifestFile));
}
/// Ignores directories whose multiple JSON files do not contain an instance manifest.
@Test
public void testRefreshIgnoresUnrelatedJsonFiles(@TempDir Path tempDirectory) throws IOException {