mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-23 08:32:51 +00:00
376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import { buildPluginAPI } from "./PluginAPI";
|
|
import { PluginSandbox } from "./PluginSandbox";
|
|
import type {
|
|
PluginManifest,
|
|
LoadedPlugin,
|
|
HookEvent,
|
|
HookCallback,
|
|
UnsubscribeFn,
|
|
PluginComponentFactory,
|
|
ViewOptions,
|
|
PluginViewRegistration,
|
|
ActionSlot,
|
|
ActionDef,
|
|
ToastOptions,
|
|
StateSnapshot,
|
|
EventBus,
|
|
} from "./types";
|
|
type StateChangeCallback = (snapshot: StateSnapshot) => void;
|
|
type ViewChangeCallback = (views: PluginViewRegistration[]) => void;
|
|
type NavigateCallback = (viewId: string) => void;
|
|
type ToastCallback = (
|
|
pluginId: string,
|
|
message: string,
|
|
options?: ToastOptions,
|
|
) => void;
|
|
type SoundCallback = (name: string) => void;
|
|
type PluginEventHandler = (payload: unknown) => void;
|
|
|
|
export interface PluginInfo {
|
|
manifest: PluginManifest;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export class PluginManager {
|
|
static instance: PluginManager = new PluginManager();
|
|
plugins: Map<string, LoadedPlugin> = new Map();
|
|
private enabledMap: Map<string, boolean> = new Map();
|
|
private hooks: Map<HookEvent, Map<string, HookCallback>> = new Map();
|
|
private views: Map<string, PluginViewRegistration> = new Map();
|
|
private actions: Map<ActionSlot, Map<string, { action: ActionDef; pluginId: string }>> = new Map();
|
|
private stateSubs: Map<string, StateChangeCallback> = new Map();
|
|
private pluginEvents: Map<string, Map<string, Set<PluginEventHandler>>> = new Map();
|
|
private onViewsChanged: ViewChangeCallback | null = null;
|
|
private onNavigate: NavigateCallback | null = null;
|
|
private onToast: ToastCallback | null = null;
|
|
private onSound: SoundCallback | null = null;
|
|
private onEnabledChanged: (() => void) | null = null;
|
|
private configSnapshot: Record<string, unknown> = {};
|
|
private gameStateSnapshot: Record<string, unknown> = {};
|
|
private installsSnapshot: string[] = [];
|
|
private _initialized = false;
|
|
get initialized(): boolean {
|
|
return this._initialized;
|
|
}
|
|
|
|
setEnabledChangedCallback(cb: () => void): void {
|
|
this.onEnabledChanged = cb;
|
|
}
|
|
|
|
isPluginEnabled(id: string): boolean {
|
|
if (!this.enabledMap.has(id)) {
|
|
const stored = localStorage.getItem(`plugin:enabled:${id}`);
|
|
const enabled = stored === null ? true : stored === "true";
|
|
this.enabledMap.set(id, enabled);
|
|
}
|
|
return this.enabledMap.get(id) ?? true;
|
|
}
|
|
|
|
setPluginEnabled(id: string, enabled: boolean): void {
|
|
this.enabledMap.set(id, enabled);
|
|
localStorage.setItem(`plugin:enabled:${id}`, String(enabled));
|
|
this.notifyViewsChanged();
|
|
this.onEnabledChanged?.();
|
|
}
|
|
|
|
getPluginInfoList(): PluginInfo[] {
|
|
return Array.from(this.plugins.values()).map((p) => ({
|
|
manifest: p.manifest,
|
|
enabled: this.isPluginEnabled(p.manifest.id),
|
|
}));
|
|
}
|
|
|
|
setViewsChangedCallback(cb: ViewChangeCallback): void {
|
|
this.onViewsChanged = cb;
|
|
}
|
|
|
|
setNavigateCallback(cb: NavigateCallback): void {
|
|
this.onNavigate = cb;
|
|
}
|
|
|
|
setToastCallback(cb: ToastCallback): void {
|
|
this.onToast = cb;
|
|
}
|
|
|
|
setSoundCallback(cb: SoundCallback): void {
|
|
this.onSound = cb;
|
|
}
|
|
|
|
updateSnapshots(
|
|
config: Record<string, unknown>,
|
|
game: Record<string, unknown>,
|
|
installs: string[],
|
|
): void {
|
|
this.configSnapshot = config;
|
|
this.gameStateSnapshot = game;
|
|
this.installsSnapshot = installs;
|
|
const snapshot: StateSnapshot = { config, game, installs };
|
|
this.stateSubs.forEach((cb) => {
|
|
try {
|
|
cb(snapshot);
|
|
} catch (err) {
|
|
console.error("[PluginManager] state sub error:", err);
|
|
}
|
|
});
|
|
}
|
|
|
|
getConfigSnapshot(): Record<string, unknown> {
|
|
return { ...this.configSnapshot };
|
|
}
|
|
|
|
getGameStateSnapshot(): Record<string, unknown> {
|
|
return { ...this.gameStateSnapshot };
|
|
}
|
|
|
|
getInstallsSnapshot(): string[] {
|
|
return [...this.installsSnapshot];
|
|
}
|
|
|
|
subscribeToState(subId: string, cb: StateChangeCallback): UnsubscribeFn {
|
|
this.stateSubs.set(subId, cb);
|
|
return () => {
|
|
this.stateSubs.delete(subId);
|
|
};
|
|
}
|
|
|
|
async reload(): Promise<void> {
|
|
this._initialized = false;
|
|
this.hooks.clear();
|
|
this.views.clear();
|
|
this.actions.clear();
|
|
this.plugins.clear();
|
|
this.enabledMap.clear();
|
|
this.pluginEvents.clear();
|
|
await this.init();
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this._initialized) return;
|
|
let pluginsDir: string;
|
|
try {
|
|
pluginsDir = await invoke<string>("get_plugins_dir");
|
|
} catch {
|
|
console.warn("[PluginManager] Could not get plugins directory");
|
|
return;
|
|
}
|
|
|
|
let entries: Array<{ name: string; is_dir: boolean }>;
|
|
try {
|
|
entries = await invoke("list_directory", { path: pluginsDir });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const dirs = entries.filter((e) => e.is_dir);
|
|
|
|
for (const dir of dirs) {
|
|
await this.loadPlugin(pluginsDir, dir.name);
|
|
}
|
|
|
|
this._initialized = true;
|
|
this.emit("app:ready", {});
|
|
this.notifyViewsChanged();
|
|
}
|
|
|
|
private async loadPlugin(pluginsDir: string, dirName: string): Promise<void> {
|
|
const manifestPath = `${pluginsDir}/${dirName}/plugin.json`;
|
|
let manifestRaw: number[];
|
|
try {
|
|
manifestRaw = await invoke<number[]>("read_binary_file", {
|
|
path: manifestPath,
|
|
});
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
let manifest: PluginManifest;
|
|
try {
|
|
manifest = JSON.parse(
|
|
new TextDecoder().decode(new Uint8Array(manifestRaw)),
|
|
);
|
|
} catch {
|
|
console.warn(`[PluginManager] Invalid plugin.json in ${dirName}`);
|
|
return;
|
|
}
|
|
|
|
if (!manifest.id || !manifest.main || !manifest.name) {
|
|
console.warn(`[PluginManager] Invalid manifest in ${dirName}`);
|
|
return;
|
|
}
|
|
|
|
if (this.plugins.has(manifest.id)) {
|
|
console.warn(`[PluginManager] Duplicate plugin id: ${manifest.id}`);
|
|
return;
|
|
}
|
|
|
|
const mainPath = `${pluginsDir}/${dirName}/${manifest.main}`;
|
|
let mainCodeRaw: number[];
|
|
try {
|
|
mainCodeRaw = await invoke<number[]>("read_binary_file", {
|
|
path: mainPath,
|
|
});
|
|
} catch {
|
|
console.warn(
|
|
`[PluginManager] Could not read main file for ${manifest.id}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const mainCode = new TextDecoder().decode(new Uint8Array(mainCodeRaw));
|
|
const api = buildPluginAPI(manifest, this);
|
|
try {
|
|
await PluginSandbox.evaluateAsync(api, mainCode);
|
|
} catch (err) {
|
|
console.error(
|
|
`[PluginManager] Error loading plugin ${manifest.id}:`,
|
|
err,
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.plugins.set(manifest.id, { manifest, api });
|
|
}
|
|
|
|
registerHook(
|
|
pluginId: string,
|
|
event: HookEvent,
|
|
callback: HookCallback,
|
|
): void {
|
|
if (!this.hooks.has(event)) {
|
|
this.hooks.set(event, new Map());
|
|
}
|
|
this.hooks.get(event)!.set(pluginId, callback);
|
|
}
|
|
|
|
emit(event: HookEvent, payload: unknown): void {
|
|
const handlers = this.hooks.get(event);
|
|
if (!handlers) return;
|
|
handlers.forEach((cb, pid) => {
|
|
if (!this.isPluginEnabled(pid)) return;
|
|
try {
|
|
cb(payload);
|
|
} catch (err) {
|
|
console.error(`[PluginManager] Error in hook ${event}:`, err);
|
|
}
|
|
});
|
|
}
|
|
|
|
emitPluginEvent(event: string, _sourcePluginId: string, payload: unknown): void {
|
|
const eventHandlers = this.pluginEvents.get(event);
|
|
if (!eventHandlers) return;
|
|
eventHandlers.forEach((handlers, pid) => {
|
|
if (!this.isPluginEnabled(pid)) return;
|
|
handlers.forEach((cb) => {
|
|
try {
|
|
cb(payload);
|
|
} catch (err) {
|
|
console.error(`[PluginManager] Error in plugin event "${event}":`, err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
onPluginEvent(pluginId: string, event: string, callback: PluginEventHandler): UnsubscribeFn {
|
|
if (!this.pluginEvents.has(event)) {
|
|
this.pluginEvents.set(event, new Map());
|
|
}
|
|
const eventHandlers = this.pluginEvents.get(event)!;
|
|
if (!eventHandlers.has(pluginId)) {
|
|
eventHandlers.set(pluginId, new Set());
|
|
}
|
|
eventHandlers.get(pluginId)!.add(callback);
|
|
return () => {
|
|
eventHandlers.get(pluginId)?.delete(callback);
|
|
};
|
|
}
|
|
|
|
offPluginEvent(pluginId: string, event: string, callback: PluginEventHandler): void {
|
|
const eventHandlers = this.pluginEvents.get(event);
|
|
if (!eventHandlers) return;
|
|
eventHandlers.get(pluginId)?.delete(callback);
|
|
}
|
|
|
|
buildEventBus(pluginId: string): EventBus {
|
|
const self = this;
|
|
return {
|
|
emit(event: string, payload?: unknown): void {
|
|
self.emitPluginEvent(event, pluginId, payload);
|
|
},
|
|
on(event: string, callback: (payload: unknown) => void): UnsubscribeFn {
|
|
return self.onPluginEvent(pluginId, event, callback);
|
|
},
|
|
once(event: string, callback: (payload: unknown) => void): UnsubscribeFn {
|
|
const inner: PluginEventHandler = (payload) => {
|
|
callback(payload);
|
|
unsub();
|
|
};
|
|
const unsub = self.onPluginEvent(pluginId, event, inner);
|
|
return unsub;
|
|
},
|
|
off(event: string, callback: (payload: unknown) => void): void {
|
|
self.offPluginEvent(pluginId, event, callback);
|
|
},
|
|
};
|
|
}
|
|
|
|
registerView(
|
|
_pluginId: string,
|
|
id: string,
|
|
factory: PluginComponentFactory,
|
|
options: ViewOptions,
|
|
): void {
|
|
this.views.set(id, { id, factory, options, pluginId: _pluginId });
|
|
this.notifyViewsChanged();
|
|
}
|
|
|
|
unregisterView(id: string): void {
|
|
this.views.delete(id);
|
|
this.notifyViewsChanged();
|
|
}
|
|
|
|
getViews(): PluginViewRegistration[] {
|
|
return Array.from(this.views.values()).filter((v) =>
|
|
this.isPluginEnabled(v.pluginId),
|
|
);
|
|
}
|
|
|
|
requestNavigate(viewId: string): void {
|
|
this.onNavigate?.(viewId);
|
|
}
|
|
|
|
registerAction(
|
|
pluginId: string,
|
|
slot: ActionSlot,
|
|
action: ActionDef,
|
|
): UnsubscribeFn {
|
|
if (!this.actions.has(slot)) {
|
|
this.actions.set(slot, new Map());
|
|
}
|
|
this.actions.get(slot)!.set(action.id, { action, pluginId });
|
|
return () => {
|
|
this.actions.get(slot)?.delete(action.id);
|
|
};
|
|
}
|
|
|
|
getActions(slot: ActionSlot): ActionDef[] {
|
|
const slotActions = this.actions.get(slot);
|
|
if (!slotActions) return [];
|
|
return Array.from(slotActions.values())
|
|
.filter((entry) => this.isPluginEnabled(entry.pluginId))
|
|
.map((entry) => entry.action);
|
|
}
|
|
|
|
showToast(pluginId: string, message: string, options?: ToastOptions): void {
|
|
this.onToast?.(pluginId, message, options);
|
|
}
|
|
|
|
playSound(name: string): void {
|
|
this.onSound?.(name);
|
|
}
|
|
|
|
private notifyViewsChanged(): void {
|
|
this.onViewsChanged?.(this.getViews());
|
|
}
|
|
}
|