mirror of
https://github.com/AutismDevelopment/Autism-Client.git
synced 2026-10-28 15:09:47 +00:00
530 lines
22 KiB
Java
530 lines
22 KiB
Java
package autismclient.addons;
|
|
|
|
import autismclient.AutismClientAddon;
|
|
import autismclient.api.ApiVersion;
|
|
import autismclient.api.AutismAddon;
|
|
import autismclient.util.AutismConfig;
|
|
import autismclient.util.AutismNotifications;
|
|
import net.fabricmc.loader.api.FabricLoader;
|
|
import net.fabricmc.loader.api.ModContainer;
|
|
import net.fabricmc.loader.api.entrypoint.EntrypointContainer;
|
|
import net.fabricmc.loader.api.metadata.CustomValue;
|
|
import net.fabricmc.loader.api.metadata.ModMetadata;
|
|
import net.fabricmc.loader.api.metadata.Person;
|
|
|
|
import java.io.File;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.stream.Collectors;
|
|
|
|
public final class AddonManager {
|
|
private static final int MAX_UI_ADDON_NAME_LENGTH = 22;
|
|
private static final List<AutismAddon> LOADED = new ArrayList<>();
|
|
private static final Map<String, AddonReport> REPORTS = new LinkedHashMap<>();
|
|
|
|
private record LoadingContext(String addonId, String addonName) {}
|
|
private static volatile LoadingContext currentContext = null;
|
|
|
|
private static boolean failuresSurfaced = false;
|
|
|
|
private AddonManager() {}
|
|
|
|
public static void init() {
|
|
LOADED.clear();
|
|
REPORTS.clear();
|
|
failuresSurfaced = false;
|
|
|
|
AutismConfig config = AutismConfig.getGlobal();
|
|
List<LoadedAddon> accepted = new ArrayList<>();
|
|
for (EntrypointContainer<AutismAddon> container : FabricLoader.getInstance()
|
|
.getEntrypointContainers("autism", AutismAddon.class)) {
|
|
ModContainer mod;
|
|
try {
|
|
mod = container.getProvider();
|
|
} catch (Throwable t) {
|
|
AutismClientAddon.LOG.error("[Addons] Failed to inspect an addon entrypoint", t);
|
|
continue;
|
|
}
|
|
|
|
String modId = mod.getMetadata().getId();
|
|
AddonReport report = ensureReport(mod.getMetadata());
|
|
if (isDisabledOnRestart(modId)) {
|
|
report.markDisabled("Disabled in AUTISM config. Enable it and restart to load.");
|
|
continue;
|
|
}
|
|
|
|
AutismAddon addon;
|
|
try {
|
|
addon = container.getEntrypoint();
|
|
} catch (Throwable t) {
|
|
report.markFailed("construct", "Failed to construct entrypoint: " + shortError(t));
|
|
AutismClientAddon.LOG.error("[Addons] Failed to construct addon '{}'", modId, t);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
int declared = addon.apiVersion();
|
|
report.setApiVersion(declared);
|
|
if (declared <= 0) {
|
|
report.markFailed("apiVersion", "Invalid API version " + declared + ".");
|
|
AutismClientAddon.LOG.warn("[Addons] Addon '{}' declared invalid API v{}; skipping it.", modId, declared);
|
|
continue;
|
|
}
|
|
if (declared > ApiVersion.CURRENT) {
|
|
report.markNeedsNewerApi("Needs API v" + declared + ", host provides v" + ApiVersion.CURRENT + ".");
|
|
AutismClientAddon.LOG.warn("[Addons] Addon '{}' needs API v{} but this client provides v{}; skipping it.",
|
|
modId, declared, ApiVersion.CURRENT);
|
|
continue;
|
|
}
|
|
if (declared < ApiVersion.CURRENT) {
|
|
AutismClientAddon.LOG.info("[Addons] Addon '{}' built against API v{} (current v{}); loading.",
|
|
modId, declared, ApiVersion.CURRENT);
|
|
}
|
|
applyMetadata(addon, mod.getMetadata());
|
|
report.copyRuntimeMetadata(addon);
|
|
accepted.add(new LoadedAddon(modId, addon));
|
|
} catch (AbstractMethodError e) {
|
|
report.markMissingApiVersion("Missing apiVersion() override.");
|
|
AutismClientAddon.LOG.error("[Addons] Addon '{}' does not declare apiVersion(); skipping it.", modId, e);
|
|
} catch (Throwable t) {
|
|
report.markFailed("prepare", "Failed to prepare: " + shortError(t));
|
|
AutismClientAddon.LOG.error("[Addons] Error preparing addon '{}'", modId, t);
|
|
}
|
|
}
|
|
|
|
List<LoadedAddon> categoryReady = new ArrayList<>();
|
|
for (LoadedAddon la : accepted) {
|
|
if (runPhase(la, "onRegisterCategories", la.addon::onRegisterCategories)) {
|
|
categoryReady.add(la);
|
|
} else {
|
|
invokeUnload(la);
|
|
rollbackRegistrations(la.modId);
|
|
}
|
|
}
|
|
|
|
for (LoadedAddon la : categoryReady) {
|
|
if (runPhase(la, "onInitialize", la.addon::onInitialize)) {
|
|
LOADED.add(la.addon);
|
|
AddonReport report = REPORTS.get(la.modId);
|
|
if (report != null) report.markLoaded();
|
|
} else {
|
|
invokeUnload(la);
|
|
rollbackRegistrations(la.modId);
|
|
}
|
|
}
|
|
|
|
if (!LOADED.isEmpty()) {
|
|
AutismClientAddon.LOG.info("[Addons] Loaded {} addon(s).", LOADED.size());
|
|
}
|
|
if (config.disabledAddonIds != null) {
|
|
for (String id : config.disabledAddonIds) {
|
|
if (id == null || id.isBlank() || REPORTS.containsKey(id)) continue;
|
|
AddonReport report = new AddonReport(id.trim(), id.trim(), "", "", 0xFFAA66FF);
|
|
report.markDisabled("Disabled in AUTISM config, but no matching addon entrypoint was discovered.");
|
|
REPORTS.put(report.modId(), report);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static boolean runPhase(LoadedAddon la, String phase, Runnable body) {
|
|
String name = la.addon.name == null || la.addon.name.isBlank() ? la.modId : la.addon.name;
|
|
currentContext = new LoadingContext(la.modId, name);
|
|
AddonReport report = REPORTS.get(la.modId);
|
|
if (report != null) report.setPhase(phase);
|
|
try {
|
|
body.run();
|
|
return true;
|
|
} catch (Throwable t) {
|
|
if (report != null) report.markFailed(phase, "Threw during " + phase + ": " + shortError(t));
|
|
AutismClientAddon.LOG.error("[Addons] Addon '{}' threw during {}", la.modId, phase, t);
|
|
return false;
|
|
} finally {
|
|
currentContext = null;
|
|
}
|
|
}
|
|
|
|
private static void invokeUnload(LoadedAddon la) {
|
|
if (la == null || la.addon == null) return;
|
|
try {
|
|
la.addon.onUnload();
|
|
} catch (Throwable t) {
|
|
AutismClientAddon.LOG.warn("[Addons] Addon '{}' threw during onUnload", la.modId, t);
|
|
}
|
|
}
|
|
|
|
private static void rollbackRegistrations(String addonId) {
|
|
if (addonId == null || addonId.isBlank()) return;
|
|
AddonReport report = REPORTS.get(addonId);
|
|
rollbackStep(addonId, report, "macro actions", () -> autismclient.api.macro.MacroActionRegistry.unregisterAddon(addonId));
|
|
rollbackStep(addonId, report, "custom menu adapters", () -> autismclient.api.custommenu.CustomMenuAdapterRegistry.unregisterAddon(addonId));
|
|
rollbackStep(addonId, report, "presets", () -> autismclient.api.macro.MacroPresetRegistry.unregisterAddon(addonId));
|
|
rollbackStep(addonId, report, "events", () -> autismclient.api.event.AddonEvents.unregisterAddon(addonId));
|
|
rollbackStep(addonId, report, "hud", () -> autismclient.api.hud.HudElements.unregisterAddon(addonId));
|
|
rollbackStep(addonId, report, "modules", () -> autismclient.modules.ModuleRegistry.unregisterAddonModules(addonId));
|
|
rollbackStep(addonId, report, "commands", () -> autismclient.commands.AutismCommands.unregisterAddonCommands(addonId));
|
|
if (report != null) report.addRollback("Rolled back partial registrations after load failure.");
|
|
}
|
|
|
|
private static void rollbackStep(String addonId, AddonReport report, String kind, Runnable body) {
|
|
try {
|
|
body.run();
|
|
} catch (Throwable t) {
|
|
String detail = "Failed to rollback " + kind + ": " + shortError(t);
|
|
if (report != null) report.addRollback(detail);
|
|
AutismClientAddon.LOG.warn("[Addons] Addon '{}' rollback failed for {}", addonId, kind, t);
|
|
}
|
|
}
|
|
|
|
private static AddonReport ensureReport(ModMetadata meta) {
|
|
String modId = meta.getId();
|
|
AddonReport existing = REPORTS.get(modId);
|
|
if (existing != null) return existing;
|
|
AddonReport report = new AddonReport(
|
|
modId,
|
|
meta.getName() == null || meta.getName().isBlank() ? modId : meta.getName(),
|
|
meta.getVersion().getFriendlyString(),
|
|
meta.getAuthors().stream().map(Person::getName).collect(Collectors.joining(", ")),
|
|
metadataColor(meta, 0xFFAA66FF)
|
|
);
|
|
REPORTS.put(modId, report);
|
|
return report;
|
|
}
|
|
|
|
private static void applyMetadata(AutismAddon addon, ModMetadata meta) {
|
|
addon.name = meta.getName();
|
|
addon.authors = meta.getAuthors().stream().map(Person::getName).collect(Collectors.joining(", "));
|
|
addon.color = metadataColor(meta, addon.color);
|
|
}
|
|
|
|
private static int metadataColor(ModMetadata meta, int fallback) {
|
|
CustomValue color = meta.containsCustomValue("autism:color") ? meta.getCustomValue("autism:color") : null;
|
|
if (color != null && color.getType() == CustomValue.CvType.STRING) {
|
|
return parseColor(color.getAsString(), fallback);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
private static int parseColor(String raw, int fallback) {
|
|
try {
|
|
String[] parts = raw.split(",");
|
|
if (parts.length != 3) return fallback;
|
|
int r = clamp(Integer.parseInt(parts[0].trim()));
|
|
int g = clamp(Integer.parseInt(parts[1].trim()));
|
|
int b = clamp(Integer.parseInt(parts[2].trim()));
|
|
return 0xFF000000 | (r << 16) | (g << 8) | b;
|
|
} catch (Exception e) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
private static int clamp(int v) { return Math.max(0, Math.min(255, v)); }
|
|
|
|
private static String currentId() {
|
|
LoadingContext ctx = currentContext;
|
|
return ctx == null ? null : ctx.addonId();
|
|
}
|
|
|
|
private static String currentName() {
|
|
LoadingContext ctx = currentContext;
|
|
return ctx == null ? null : ctx.addonName();
|
|
}
|
|
|
|
public static String currentAddonId() {
|
|
return currentId();
|
|
}
|
|
|
|
public static boolean isLifecycleActive() {
|
|
String id = currentId();
|
|
return id != null && !id.isBlank();
|
|
}
|
|
|
|
public static String currentAddonName() {
|
|
return currentName();
|
|
}
|
|
|
|
public static String normalizedAddonName(String name, String fallback) {
|
|
String value = name == null ? "" : name.replaceAll("\\s+", " ").trim();
|
|
if (value.isBlank()) value = fallback == null ? "" : fallback.replaceAll("\\s+", " ").trim();
|
|
return value.isBlank() ? "Addon" : value;
|
|
}
|
|
|
|
public static String compactAddonName(String name, String fallback) {
|
|
String value = normalizedAddonName(name, fallback);
|
|
if (value.length() <= MAX_UI_ADDON_NAME_LENGTH) return value;
|
|
return value.substring(0, Math.max(1, MAX_UI_ADDON_NAME_LENGTH - 3)).trim() + "...";
|
|
}
|
|
|
|
public static String scopedId(String rawId) {
|
|
String raw = rawId == null ? "" : rawId.trim();
|
|
if (raw.contains(":")) return raw;
|
|
String owner = currentId();
|
|
if (owner == null || owner.isBlank()) return raw;
|
|
return owner + ":" + raw;
|
|
}
|
|
|
|
public static String scopedCategoryLabel(String customLabel) {
|
|
LoadingContext ctx = currentContext;
|
|
String id = ctx == null ? null : ctx.addonId();
|
|
String name = ctx == null ? null : ctx.addonName();
|
|
if (name == null || name.isBlank()) name = id;
|
|
return compactAddonName(name, id);
|
|
}
|
|
|
|
public static List<AutismAddon> loaded() {
|
|
return Collections.unmodifiableList(LOADED);
|
|
}
|
|
|
|
public static List<AddonReport> reports() {
|
|
return Collections.unmodifiableList(new ArrayList<>(REPORTS.values()));
|
|
}
|
|
|
|
public static AddonReport report(String addonId) {
|
|
return addonId == null ? null : REPORTS.get(addonId);
|
|
}
|
|
|
|
public static File modsFolder() {
|
|
return FabricLoader.getInstance().getGameDir().resolve("mods").toFile();
|
|
}
|
|
|
|
public static boolean isDisabledOnRestart(String addonId) {
|
|
if (addonId == null || addonId.isBlank()) return false;
|
|
AutismConfig config = AutismConfig.getGlobal();
|
|
if (config.disabledAddonIds == null) return false;
|
|
String needle = addonId.trim().toLowerCase(Locale.ROOT);
|
|
for (String id : config.disabledAddonIds) {
|
|
if (id != null && needle.equals(id.trim().toLowerCase(Locale.ROOT))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public static void setDisabledOnRestart(String addonId, boolean disabled) {
|
|
if (addonId == null || addonId.isBlank()) return;
|
|
AutismConfig config = AutismConfig.getGlobal();
|
|
if (config.disabledAddonIds == null) config.disabledAddonIds = new ArrayList<>();
|
|
String normalized = addonId.trim();
|
|
boolean currently = isDisabledOnRestart(normalized);
|
|
if (disabled && !currently) {
|
|
config.disabledAddonIds.add(normalized);
|
|
} else if (!disabled && currently) {
|
|
config.disabledAddonIds.removeIf(id -> id != null && normalized.equalsIgnoreCase(id.trim()));
|
|
}
|
|
config.save();
|
|
}
|
|
|
|
public static void recordAcceptedRegistration(String kind, String id) {
|
|
AddonReport report = REPORTS.get(currentId());
|
|
if (report != null) report.recordAccepted(kind, id);
|
|
}
|
|
|
|
public static void recordRejectedRegistration(String addonId, String kind, String id, String reason) {
|
|
AddonReport report = REPORTS.get(addonId == null || addonId.isBlank() ? currentId() : addonId);
|
|
if (report != null) report.recordRejected(kind, id, reason);
|
|
}
|
|
|
|
public static void recordRuntimeError(String addonId, String detail) {
|
|
AddonReport report = REPORTS.get(addonId);
|
|
if (report != null) report.recordRuntimeError(detail);
|
|
}
|
|
|
|
public static void surfaceFailuresOnJoin() {
|
|
if (failuresSurfaced) return;
|
|
int issues = issueCount();
|
|
if (issues <= 0) return;
|
|
failuresSurfaced = true;
|
|
AutismNotifications.warning(issues + " addon issue(s). Open Addons for details.");
|
|
}
|
|
|
|
private static int issueCount() {
|
|
int count = 0;
|
|
for (AddonReport report : REPORTS.values()) {
|
|
if (report.status().isIssue()) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static String shortError(Throwable t) {
|
|
if (t == null) return "Unknown error";
|
|
String msg = t.getMessage();
|
|
return msg == null || msg.isBlank() ? t.getClass().getSimpleName() : t.getClass().getSimpleName() + ": " + msg;
|
|
}
|
|
|
|
private record LoadedAddon(String modId, AutismAddon addon) {}
|
|
|
|
public enum AddonLoadStatus {
|
|
LOADED("Loaded", false),
|
|
DISABLED("Disabled on restart", false),
|
|
FAILED("Failed", true),
|
|
NEEDS_NEWER_API("Needs newer API", true),
|
|
MISSING_API_VERSION("Missing apiVersion", true);
|
|
|
|
private final String label;
|
|
private final boolean issue;
|
|
|
|
AddonLoadStatus(String label, boolean issue) {
|
|
this.label = label;
|
|
this.issue = issue;
|
|
}
|
|
|
|
public String label() { return label; }
|
|
public boolean isIssue() { return issue; }
|
|
}
|
|
|
|
public static final class AddonReport {
|
|
private final String modId;
|
|
private String name;
|
|
private String version;
|
|
private String authors;
|
|
private int color;
|
|
private int apiVersion = -1;
|
|
private int hostApiVersion = ApiVersion.CURRENT;
|
|
private AddonLoadStatus status = AddonLoadStatus.FAILED;
|
|
private String phase = "discovered";
|
|
private String failureReason = "";
|
|
private final Map<String, Integer> accepted = new LinkedHashMap<>();
|
|
private final Map<String, Integer> rejected = new LinkedHashMap<>();
|
|
private final List<String> rejectionDetails = new ArrayList<>();
|
|
private final List<String> rollbackDetails = new ArrayList<>();
|
|
private int runtimeErrors;
|
|
private String lastRuntimeError = "";
|
|
|
|
private AddonReport(String modId, String name, String version, String authors, int color) {
|
|
this.modId = modId == null ? "" : modId;
|
|
this.name = name == null || name.isBlank() ? this.modId : name;
|
|
this.version = version == null ? "" : version;
|
|
this.authors = authors == null ? "" : authors;
|
|
this.color = color;
|
|
}
|
|
|
|
private void copyRuntimeMetadata(AutismAddon addon) {
|
|
if (addon == null) return;
|
|
if (addon.name != null && !addon.name.isBlank()) this.name = addon.name;
|
|
if (addon.authors != null && !addon.authors.isBlank()) this.authors = addon.authors;
|
|
this.color = addon.color;
|
|
}
|
|
|
|
private void setApiVersion(int apiVersion) {
|
|
this.apiVersion = apiVersion;
|
|
}
|
|
|
|
private void setPhase(String phase) {
|
|
this.phase = phase == null || phase.isBlank() ? this.phase : phase;
|
|
}
|
|
|
|
private void markLoaded() {
|
|
this.status = AddonLoadStatus.LOADED;
|
|
this.phase = "loaded";
|
|
this.failureReason = "";
|
|
}
|
|
|
|
private void markDisabled(String reason) {
|
|
this.status = AddonLoadStatus.DISABLED;
|
|
this.phase = "disabled";
|
|
this.failureReason = reason == null ? "" : reason;
|
|
}
|
|
|
|
private void markFailed(String phase, String reason) {
|
|
this.status = AddonLoadStatus.FAILED;
|
|
this.phase = phase == null || phase.isBlank() ? "failed" : phase;
|
|
this.failureReason = reason == null ? "" : reason;
|
|
}
|
|
|
|
private void markNeedsNewerApi(String reason) {
|
|
this.status = AddonLoadStatus.NEEDS_NEWER_API;
|
|
this.phase = "apiVersion";
|
|
this.failureReason = reason == null ? "" : reason;
|
|
}
|
|
|
|
private void markMissingApiVersion(String reason) {
|
|
this.status = AddonLoadStatus.MISSING_API_VERSION;
|
|
this.phase = "apiVersion";
|
|
this.failureReason = reason == null ? "" : reason;
|
|
}
|
|
|
|
private void recordAccepted(String kind, String id) {
|
|
String key = kind == null || kind.isBlank() ? "item" : kind;
|
|
accepted.merge(key, 1, Integer::sum);
|
|
}
|
|
|
|
private void recordRejected(String kind, String id, String reason) {
|
|
String key = kind == null || kind.isBlank() ? "item" : kind;
|
|
rejected.merge(key, 1, Integer::sum);
|
|
String detail = key + (id == null || id.isBlank() ? "" : " " + id) + ": "
|
|
+ (reason == null || reason.isBlank() ? "rejected" : reason);
|
|
rejectionDetails.add(detail);
|
|
}
|
|
|
|
private void addRollback(String detail) {
|
|
if (detail != null && !detail.isBlank()) rollbackDetails.add(detail);
|
|
}
|
|
|
|
private void recordRuntimeError(String detail) {
|
|
runtimeErrors++;
|
|
lastRuntimeError = detail == null || detail.isBlank() ? "Runtime error" : detail;
|
|
}
|
|
|
|
public String modId() { return modId; }
|
|
public String name() { return name; }
|
|
public String version() { return version; }
|
|
public String authors() { return authors; }
|
|
public int color() { return color; }
|
|
public int apiVersion() { return apiVersion; }
|
|
public int hostApiVersion() { return hostApiVersion; }
|
|
public AddonLoadStatus status() { return status; }
|
|
public String phase() { return phase; }
|
|
public String failureReason() { return failureReason; }
|
|
public int runtimeErrors() { return runtimeErrors; }
|
|
public String lastRuntimeError() { return lastRuntimeError; }
|
|
public Map<String, Integer> accepted() { return Collections.unmodifiableMap(accepted); }
|
|
public Map<String, Integer> rejected() { return Collections.unmodifiableMap(rejected); }
|
|
public List<String> rejectionDetails() { return Collections.unmodifiableList(rejectionDetails); }
|
|
public List<String> rollbackDetails() { return Collections.unmodifiableList(rollbackDetails); }
|
|
|
|
public int acceptedTotal() {
|
|
int total = 0;
|
|
for (int value : accepted.values()) total += value;
|
|
return total;
|
|
}
|
|
|
|
public int rejectedTotal() {
|
|
int total = 0;
|
|
for (int value : rejected.values()) total += value;
|
|
return total;
|
|
}
|
|
|
|
public String summaryCounts() {
|
|
if (accepted.isEmpty()) return "No registered extensions";
|
|
List<String> parts = new ArrayList<>();
|
|
for (Map.Entry<String, Integer> entry : accepted.entrySet()) {
|
|
parts.add(entry.getValue() + " " + entry.getKey());
|
|
}
|
|
return String.join(", ", parts);
|
|
}
|
|
|
|
public String copyReport() {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("Addon: ").append(name).append('\n');
|
|
sb.append("ID: ").append(modId).append('\n');
|
|
if (!version.isBlank()) sb.append("Version: ").append(version).append('\n');
|
|
if (!authors.isBlank()) sb.append("Authors: ").append(authors).append('\n');
|
|
sb.append("Status: ").append(status.label()).append('\n');
|
|
sb.append("Phase: ").append(phase).append('\n');
|
|
sb.append("API: addon v").append(apiVersion < 0 ? "unknown" : Integer.toString(apiVersion))
|
|
.append(", host v").append(hostApiVersion).append('\n');
|
|
sb.append("Disabled on restart: ").append(isDisabledOnRestart(modId)).append('\n');
|
|
if (!failureReason.isBlank()) sb.append("Reason: ").append(failureReason).append('\n');
|
|
sb.append("Accepted: ").append(accepted).append('\n');
|
|
if (!rejected.isEmpty()) sb.append("Rejected: ").append(rejected).append('\n');
|
|
for (String detail : rejectionDetails) sb.append("- ").append(detail).append('\n');
|
|
for (String detail : rollbackDetails) sb.append("- ").append(detail).append('\n');
|
|
if (runtimeErrors > 0) {
|
|
sb.append("Runtime errors: ").append(runtimeErrors).append('\n');
|
|
sb.append("Last runtime error: ").append(lastRuntimeError).append('\n');
|
|
}
|
|
return sb.toString();
|
|
}
|
|
}
|
|
}
|