Files
LCE-Hub-LCE_Emerald_Launcher/src/plugins/PluginManager.ts
T
2026-06-14 18:59:35 +03:00

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());
}
}