mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-25 01:20:55 +00:00
feat: initial plugin support
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import { PluginStorage } from "./PluginStorage";
|
||||
import { PluginManager } from "./PluginManager";
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginAPI as PluginAPIType,
|
||||
HookEvent,
|
||||
HookCallback,
|
||||
UnsubscribeFn,
|
||||
PluginComponentFactory,
|
||||
ViewOptions,
|
||||
ActionSlot,
|
||||
ActionDef,
|
||||
ToastOptions,
|
||||
StateSnapshot,
|
||||
EventBus,
|
||||
} from "./types";
|
||||
const PERMISSION_HOOK_PREFIX = "hooks:";
|
||||
const PERMISSION_STORAGE = "storage:plugin";
|
||||
const PERMISSION_TAURI_ALL = "tauri:*";
|
||||
const PERMISSION_TAURI_PREFIX = "tauri:";
|
||||
export function buildPluginAPI(
|
||||
manifest: PluginManifest,
|
||||
manager: PluginManager,
|
||||
): PluginAPIType {
|
||||
const pluginId = manifest.id;
|
||||
const perms = new Set(manifest.permissions ?? []);
|
||||
const storage = new PluginStorage(pluginId);
|
||||
function hasPermission(perm: string): boolean {
|
||||
return perms.has(perm);
|
||||
}
|
||||
|
||||
function checkHookPermission(event: HookEvent): void {
|
||||
if (perms.size === 0) return;
|
||||
if (hasPermission(`${PERMISSION_HOOK_PREFIX}${event}`)) return;
|
||||
if (hasPermission(`${PERMISSION_HOOK_PREFIX}*`)) return;
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" lacks permission for hook "${event}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const hookHandlers = new Map<HookEvent, Set<HookCallback>>();
|
||||
const eventBus: EventBus = manager.buildEventBus(pluginId);
|
||||
|
||||
return {
|
||||
id: pluginId,
|
||||
manifest,
|
||||
events: eventBus,
|
||||
hooks: {
|
||||
on(event: HookEvent, callback: HookCallback): UnsubscribeFn {
|
||||
checkHookPermission(event);
|
||||
if (!hookHandlers.has(event)) {
|
||||
hookHandlers.set(event, new Set());
|
||||
manager.registerHook(pluginId, event, (payload: unknown) => {
|
||||
const handlers = hookHandlers.get(event);
|
||||
if (handlers) {
|
||||
handlers.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[Plugin ${pluginId}] hook error on ${event}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
hookHandlers.get(event)!.add(callback);
|
||||
return () => {
|
||||
hookHandlers.get(event)?.delete(callback);
|
||||
};
|
||||
},
|
||||
|
||||
once(event: HookEvent, callback: HookCallback): UnsubscribeFn {
|
||||
const inner: HookCallback = (payload) => {
|
||||
callback(payload);
|
||||
unsub();
|
||||
};
|
||||
const unsub = this.on(event, inner);
|
||||
return unsub;
|
||||
},
|
||||
|
||||
off(event: HookEvent, callback: HookCallback): void {
|
||||
hookHandlers.get(event)?.delete(callback);
|
||||
},
|
||||
},
|
||||
|
||||
views: {
|
||||
register(
|
||||
id: string,
|
||||
factory: PluginComponentFactory,
|
||||
options: ViewOptions,
|
||||
): void {
|
||||
manager.registerView(pluginId, id, factory, options);
|
||||
},
|
||||
|
||||
unregister(id: string): void {
|
||||
manager.unregisterView(id);
|
||||
},
|
||||
|
||||
navigate(id: string): void {
|
||||
manager.requestNavigate(id);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
register(slot: ActionSlot, action: ActionDef): UnsubscribeFn {
|
||||
return manager.registerAction(pluginId, slot, action);
|
||||
},
|
||||
},
|
||||
|
||||
tauri: {
|
||||
async invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (perms.size > 0 && !hasPermission(PERMISSION_TAURI_ALL)) {
|
||||
if (!hasPermission(`${PERMISSION_TAURI_PREFIX}${cmd}`)) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" lacks permission for tauri command "${cmd}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return invoke<T>(cmd, args);
|
||||
},
|
||||
|
||||
async listen<T>(
|
||||
event: string,
|
||||
callback: (payload: T) => void,
|
||||
): Promise<UnsubscribeFn> {
|
||||
if (perms.size > 0 && !hasPermission(PERMISSION_TAURI_ALL)) {
|
||||
if (!hasPermission(`${PERMISSION_TAURI_PREFIX}listen:${event}`)) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" lacks permission to listen for event "${event}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return tauriListen<T>(event, (evt) => callback(evt.payload));
|
||||
},
|
||||
},
|
||||
|
||||
state: {
|
||||
getConfig(): Record<string, unknown> {
|
||||
return manager.getConfigSnapshot();
|
||||
},
|
||||
|
||||
getGameState(): Record<string, unknown> {
|
||||
return manager.getGameStateSnapshot();
|
||||
},
|
||||
|
||||
getInstalls(): string[] {
|
||||
return manager.getInstallsSnapshot();
|
||||
},
|
||||
|
||||
subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn {
|
||||
return manager.subscribeToState(pluginId, cb);
|
||||
},
|
||||
},
|
||||
|
||||
storage: {
|
||||
get<T>(key: string): T | undefined {
|
||||
if (!hasPermission(PERMISSION_STORAGE)) return undefined;
|
||||
return storage.get<T>(key);
|
||||
},
|
||||
set<T>(key: string, value: T): void {
|
||||
if (!hasPermission(PERMISSION_STORAGE)) return;
|
||||
storage.set(key, value);
|
||||
},
|
||||
remove(key: string): void {
|
||||
if (!hasPermission(PERMISSION_STORAGE)) return;
|
||||
storage.remove(key);
|
||||
},
|
||||
clear(): void {
|
||||
if (!hasPermission(PERMISSION_STORAGE)) return;
|
||||
storage.clear();
|
||||
},
|
||||
},
|
||||
|
||||
React,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
ui: {
|
||||
showToast(message: string, options?: ToastOptions): void {
|
||||
manager.showToast(pluginId, message, options);
|
||||
},
|
||||
playSound(name: string): void {
|
||||
manager.playSound(name);
|
||||
},
|
||||
async openUrl(url: string): Promise<void> {
|
||||
if (
|
||||
perms.size > 0 &&
|
||||
!hasPermission(PERMISSION_TAURI_ALL) &&
|
||||
!hasPermission("tauri:open_url")
|
||||
) {
|
||||
throw new Error(`Plugin "${pluginId}" lacks permission for open_url`);
|
||||
}
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(url);
|
||||
},
|
||||
},
|
||||
|
||||
log: {
|
||||
info(...args: unknown[]): void {
|
||||
console.log(`[Plugin ${pluginId}]`, ...args);
|
||||
},
|
||||
warn(...args: unknown[]): void {
|
||||
console.warn(`[Plugin ${pluginId}]`, ...args);
|
||||
},
|
||||
error(...args: unknown[]): void {
|
||||
console.error(`[Plugin ${pluginId}]`, ...args);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { PluginManager } from "./PluginManager";
|
||||
import type {
|
||||
PluginViewRegistration,
|
||||
ActionSlot,
|
||||
ActionDef,
|
||||
ToastOptions,
|
||||
} from "./types";
|
||||
interface PluginContextType {
|
||||
views: PluginViewRegistration[];
|
||||
getActions: (slot: ActionSlot) => ActionDef[];
|
||||
navigateToView: (viewId: string) => void;
|
||||
}
|
||||
|
||||
const PluginContext = createContext<PluginContextType>({
|
||||
views: [],
|
||||
getActions: () => [],
|
||||
navigateToView: () => {},
|
||||
});
|
||||
|
||||
export function usePluginViews(): PluginViewRegistration[] {
|
||||
return useContext(PluginContext).views;
|
||||
}
|
||||
|
||||
export function usePluginActions(slot: ActionSlot): ActionDef[] {
|
||||
return useContext(PluginContext).getActions(slot);
|
||||
}
|
||||
|
||||
export function usePluginNavigate(): (viewId: string) => void {
|
||||
return useContext(PluginContext).navigateToView;
|
||||
}
|
||||
|
||||
interface PluginProviderProps {
|
||||
children: ReactNode;
|
||||
onNavigate?: (viewId: string) => void;
|
||||
onToast?: (pluginId: string, message: string, options?: ToastOptions) => void;
|
||||
onSound?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function PluginProvider({
|
||||
children,
|
||||
onNavigate,
|
||||
onToast,
|
||||
onSound,
|
||||
}: PluginProviderProps) {
|
||||
const [views, setViews] = useState<PluginViewRegistration[]>([]);
|
||||
const pm = PluginManager.instance;
|
||||
useEffect(() => {
|
||||
pm.setViewsChangedCallback((updatedViews) => {
|
||||
setViews(updatedViews);
|
||||
});
|
||||
|
||||
if (onNavigate) {
|
||||
pm.setNavigateCallback(onNavigate);
|
||||
}
|
||||
|
||||
if (onToast) {
|
||||
pm.setToastCallback(onToast);
|
||||
}
|
||||
|
||||
if (onSound) {
|
||||
pm.setSoundCallback(onSound);
|
||||
}
|
||||
|
||||
pm.init().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const getActions = useCallback((slot: ActionSlot): ActionDef[] => {
|
||||
return pm.getActions(slot);
|
||||
}, []);
|
||||
|
||||
const navigateToView = useCallback(
|
||||
(viewId: string) => {
|
||||
onNavigate?.(viewId);
|
||||
},
|
||||
[onNavigate],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ views, getActions, navigateToView }),
|
||||
[views, getActions, navigateToView],
|
||||
);
|
||||
|
||||
return (
|
||||
<PluginContext.Provider value={value}>{children}</PluginContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PluginAPI } from "./types";
|
||||
export class PluginSandbox {
|
||||
static evaluate(api: PluginAPI, code: string): void {
|
||||
const fn = new Function("api", code);
|
||||
fn(api);
|
||||
}
|
||||
|
||||
static evaluateAsync(api: PluginAPI, code: string): Promise<void> {
|
||||
try {
|
||||
const asyncFn = new Function("api", `return (async () => { ${code} })()`);
|
||||
return Promise.resolve(asyncFn(api));
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const PLUGIN_PREFIX = "plugin:";
|
||||
export class PluginStorage {
|
||||
private namespace: string;
|
||||
constructor(pluginId: string) {
|
||||
this.namespace = `${PLUGIN_PREFIX}${pluginId}:`;
|
||||
}
|
||||
|
||||
private prefixKey(key: string): string {
|
||||
return `${this.namespace}${key}`;
|
||||
}
|
||||
|
||||
get<T>(key: string): T | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.prefixKey(key));
|
||||
if (raw === null) return undefined;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T): void {
|
||||
localStorage.setItem(this.prefixKey(key), JSON.stringify(value));
|
||||
}
|
||||
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(this.prefixKey(key));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const prefix = this.prefixKey("");
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith(prefix)) {
|
||||
keysToRemove.push(k);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type React from "react";
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
main: string;
|
||||
views?: PluginViewDeclaration[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface PluginViewDeclaration {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type HookEvent =
|
||||
| "app:ready"
|
||||
| "app:before-quit"
|
||||
| "game:before-launch"
|
||||
| "game:after-launch"
|
||||
| "game:before-stop"
|
||||
| "game:after-stop"
|
||||
| "game:install-start"
|
||||
| "game:install-complete"
|
||||
| "game:install-progress"
|
||||
| "game:uninstall"
|
||||
| "config:change"
|
||||
| "view:mount"
|
||||
| "view:unmount";
|
||||
|
||||
export type HookCallback = (payload: unknown) => void;
|
||||
export type UnsubscribeFn = () => void;
|
||||
export type PluginComponentFactory = (
|
||||
api: PluginAPI,
|
||||
) => React.ComponentType<Record<string, unknown>>;
|
||||
|
||||
export interface ViewOptions {
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type ActionSlot =
|
||||
| "home-menu"
|
||||
| "version-toolbar"
|
||||
| "devtools-list"
|
||||
| "settings-tab";
|
||||
|
||||
export interface ActionDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
title?: string;
|
||||
variant?: "error" | "update" | "steam";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
config: Record<string, unknown>;
|
||||
game: Record<string, unknown>;
|
||||
installs: string[];
|
||||
}
|
||||
|
||||
export interface PluginViewRegistration {
|
||||
id: string;
|
||||
factory: PluginComponentFactory;
|
||||
options: ViewOptions;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface LoadedPlugin {
|
||||
manifest: PluginManifest;
|
||||
api: PluginAPI;
|
||||
}
|
||||
|
||||
export interface EventBus {
|
||||
emit(event: string, payload?: unknown): void;
|
||||
on(event: string, callback: (payload: unknown) => void): UnsubscribeFn;
|
||||
once(event: string, callback: (payload: unknown) => void): UnsubscribeFn;
|
||||
off(event: string, callback: (payload: unknown) => void): void;
|
||||
}
|
||||
|
||||
export interface PluginAPI {
|
||||
id: string;
|
||||
manifest: PluginManifest;
|
||||
events: EventBus;
|
||||
hooks: {
|
||||
on(event: HookEvent, callback: HookCallback): UnsubscribeFn;
|
||||
once(event: HookEvent, callback: HookCallback): UnsubscribeFn;
|
||||
off(event: HookEvent, callback: HookCallback): void;
|
||||
};
|
||||
views: {
|
||||
register(
|
||||
id: string,
|
||||
factory: PluginComponentFactory,
|
||||
options: ViewOptions,
|
||||
): void;
|
||||
unregister(id: string): void;
|
||||
navigate(id: string): void;
|
||||
};
|
||||
actions: {
|
||||
register(slot: ActionSlot, action: ActionDef): UnsubscribeFn;
|
||||
};
|
||||
tauri: {
|
||||
invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
|
||||
listen<T>(
|
||||
event: string,
|
||||
callback: (payload: T) => void,
|
||||
): Promise<UnsubscribeFn>;
|
||||
};
|
||||
state: {
|
||||
getConfig(): Record<string, unknown>;
|
||||
getGameState(): Record<string, unknown>;
|
||||
getInstalls(): string[];
|
||||
subscribe(cb: (snapshot: StateSnapshot) => void): UnsubscribeFn;
|
||||
};
|
||||
storage: {
|
||||
get<T>(key: string): T | undefined;
|
||||
set<T>(key: string, value: T): void;
|
||||
remove(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
React: typeof React;
|
||||
useState: typeof React.useState;
|
||||
useEffect: typeof React.useEffect;
|
||||
useMemo: typeof React.useMemo;
|
||||
useCallback: typeof React.useCallback;
|
||||
ui: {
|
||||
showToast(message: string, options?: ToastOptions): void;
|
||||
playSound(name: string): void;
|
||||
openUrl(url: string): Promise<void>;
|
||||
};
|
||||
log: {
|
||||
info(...args: unknown[]): void;
|
||||
warn(...args: unknown[]): void;
|
||||
error(...args: unknown[]): void;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user