From 40eb4302b8a65758002a8c99ac9a1a8275c89f7e Mon Sep 17 00:00:00 2001 From: John Smith Date: Sun, 13 Sep 2026 23:45:07 -0400 Subject: [PATCH 01/13] feat: generic plugin runtime SPI and desktop extension host --- desktop/main/components/PluginSlot.vue | 23 + desktop/main/composables/usePlugins.ts | 51 + .../internal/plugins/ClientPluginManager.ts | 513 +++++++ .../internal/plugins/PluginErrorBoundary.vue | 37 + desktop/main/internal/plugins/types.ts | 192 +++ desktop/main/pages/library/[id]/index.vue | 270 +++- desktop/main/pages/settings.vue | 16 +- desktop/main/pages/settings/plugins.vue | 497 ++++++ desktop/main/plugins/09.client-plugins.ts | 83 + desktop/src-tauri/Cargo.lock | 3 + desktop/src-tauri/Cargo.toml | 4 +- desktop/src-tauri/process/Cargo.toml | 3 + desktop/src-tauri/process/src/lib.rs | 1 + desktop/src-tauri/process/src/path_guard.rs | 264 ++++ desktop/src-tauri/src/lib.rs | 17 + desktop/src-tauri/src/plugins.rs | 414 +++++ desktop/src-tauri/src/remote.rs | 169 +- server/dev-tools/run-tests.mjs | 93 ++ .../dev-tools/sample-plugin/drop-plugin.json | 11 + server/dev-tools/sample-plugin/index.js | 16 + server/dev-tools/sign-plugin.mjs | 132 ++ server/package.json | 2 + server/pages/admin/settings.vue | 7 + server/pages/admin/settings/plugins.vue | 454 ++++++ .../api/v1/plugins/[pluginId]/[...slug].ts | 17 + .../v1/plugins/[pluginId]/bundle.delete.ts | 29 + .../[pluginId]/client/[...asset].get.ts | 42 + .../api/v1/plugins/[pluginId]/state.patch.ts | 37 + server/server/api/v1/plugins/index.get.ts | 33 + server/server/api/v1/plugins/install.post.ts | 58 + server/server/api/v1/plugins/reload.post.ts | 18 + server/server/api/v1/plugins/updates.get.ts | 23 + server/server/api/v1/plugins/ws.get.ts | 220 +++ server/server/internal/acls/index.ts | 64 +- .../internal/plugins/__tests__/auth.test.ts | 104 ++ .../plugins/__tests__/plugins.test.ts | 1358 +++++++++++++++++ .../__tests__/upstream_compatibility.test.ts | 166 ++ server/server/internal/plugins/auth.ts | 119 ++ .../internal/plugins/builtin/hello-world.ts | 26 + server/server/internal/plugins/errors.ts | 43 + server/server/internal/plugins/index.ts | 17 + server/server/internal/plugins/manager.ts | 1272 +++++++++++++++ server/server/internal/plugins/registry.ts | 119 ++ server/server/internal/plugins/storage.ts | 138 ++ server/server/internal/plugins/types.ts | 220 +++ server/server/plugins/08.plugin-system.ts | 12 + sites/docs/src/content/docs/admin/plugins.md | 162 ++ 47 files changed, 7483 insertions(+), 86 deletions(-) create mode 100644 desktop/main/components/PluginSlot.vue create mode 100644 desktop/main/composables/usePlugins.ts create mode 100644 desktop/main/internal/plugins/ClientPluginManager.ts create mode 100644 desktop/main/internal/plugins/PluginErrorBoundary.vue create mode 100644 desktop/main/internal/plugins/types.ts create mode 100644 desktop/main/pages/settings/plugins.vue create mode 100644 desktop/main/plugins/09.client-plugins.ts create mode 100644 desktop/src-tauri/process/src/path_guard.rs create mode 100644 desktop/src-tauri/src/plugins.rs create mode 100644 server/dev-tools/run-tests.mjs create mode 100644 server/dev-tools/sample-plugin/drop-plugin.json create mode 100644 server/dev-tools/sample-plugin/index.js create mode 100644 server/dev-tools/sign-plugin.mjs create mode 100644 server/pages/admin/settings/plugins.vue create mode 100644 server/server/api/v1/plugins/[pluginId]/[...slug].ts create mode 100644 server/server/api/v1/plugins/[pluginId]/bundle.delete.ts create mode 100644 server/server/api/v1/plugins/[pluginId]/client/[...asset].get.ts create mode 100644 server/server/api/v1/plugins/[pluginId]/state.patch.ts create mode 100644 server/server/api/v1/plugins/index.get.ts create mode 100644 server/server/api/v1/plugins/install.post.ts create mode 100644 server/server/api/v1/plugins/reload.post.ts create mode 100644 server/server/api/v1/plugins/updates.get.ts create mode 100644 server/server/api/v1/plugins/ws.get.ts create mode 100644 server/server/internal/plugins/__tests__/auth.test.ts create mode 100644 server/server/internal/plugins/__tests__/plugins.test.ts create mode 100644 server/server/internal/plugins/__tests__/upstream_compatibility.test.ts create mode 100644 server/server/internal/plugins/auth.ts create mode 100644 server/server/internal/plugins/builtin/hello-world.ts create mode 100644 server/server/internal/plugins/errors.ts create mode 100644 server/server/internal/plugins/index.ts create mode 100644 server/server/internal/plugins/manager.ts create mode 100644 server/server/internal/plugins/registry.ts create mode 100644 server/server/internal/plugins/storage.ts create mode 100644 server/server/internal/plugins/types.ts create mode 100644 server/server/plugins/08.plugin-system.ts create mode 100644 sites/docs/src/content/docs/admin/plugins.md diff --git a/desktop/main/components/PluginSlot.vue b/desktop/main/components/PluginSlot.vue new file mode 100644 index 000000000..44f38abb8 --- /dev/null +++ b/desktop/main/components/PluginSlot.vue @@ -0,0 +1,23 @@ + + + diff --git a/desktop/main/composables/usePlugins.ts b/desktop/main/composables/usePlugins.ts new file mode 100644 index 000000000..cc71e6d60 --- /dev/null +++ b/desktop/main/composables/usePlugins.ts @@ -0,0 +1,51 @@ +import { ref, computed, watch, onMounted } from "vue"; +import type { UISlotName, PlayAction } from "~/internal/plugins/types"; +import { clientPluginManager } from "~/internal/plugins/ClientPluginManager"; + +export function usePluginManager() { + return clientPluginManager; +} + +export function usePluginSlots(name: UISlotName) { + return computed(() => clientPluginManager.slots[name] || []); +} + +export function usePlayActions(gameId: string | (() => string)) { + const actions = ref([]); + const isLoading = ref(false); + + const resolveId = () => (typeof gameId === "function" ? gameId() : gameId); + + const refreshActions = async () => { + const id = resolveId(); + if (!id) { + actions.value = []; + return; + } + isLoading.value = true; + try { + actions.value = await clientPluginManager.getPlayActions(id); + } catch (err) { + console.error(`Failed to load play actions for game ${id}:`, err); + actions.value = []; + } finally { + isLoading.value = false; + } + }; + + onMounted(() => { + refreshActions(); + }); + + if (typeof gameId === "function") { + watch(gameId, () => { + refreshActions(); + }); + } + + return { + actions, + isLoading, + refreshActions, + }; +} diff --git a/desktop/main/internal/plugins/ClientPluginManager.ts b/desktop/main/internal/plugins/ClientPluginManager.ts new file mode 100644 index 000000000..92a1c4924 --- /dev/null +++ b/desktop/main/internal/plugins/ClientPluginManager.ts @@ -0,0 +1,513 @@ +import { reactive, ref } from "vue"; +import { invoke } from "@tauri-apps/api/core"; +import type { + AntiCheatReport, + ClientPlugin, + ClientPluginContext, + ClientPluginStorage, + ClientPluginWebSocket, + CommandResult, + GameMenuItem, + HttpMethod, + LaunchContext, + LaunchHook, + PlayAction, + ScopedGameFs, + ScopedGameScanner, + SidebarItem, + TopBarItem, + UISlotName, + UISlotRegistration, +} from "./types"; + +export function isTauri(): boolean { + return ( + typeof window !== "undefined" && + ("__TAURI_INTERNALS__" in window || "__TAURI__" in window) + ); +} + +async function safeInvoke( + cmd: string, + args?: Record, + fallback?: T, +): Promise { + if (!isTauri()) { + console.debug( + `[ClientPluginManager] Browser mode: invoke('${cmd}') bypassed`, + ); + return fallback !== undefined ? fallback : (null as unknown as T); + } + return await invoke(cmd, args); +} + +class BrowserLocalStorage implements ClientPluginStorage { + constructor(private readonly pluginId: string) {} + + private prefixKey(key: string): string { + return `drop:plugin:${this.pluginId}:${key}`; + } + + async get(key: string): Promise { + try { + const val = localStorage.getItem(this.prefixKey(key)); + return val ? JSON.parse(val) : null; + } catch { + return null; + } + } + + async set(key: string, value: T): Promise { + localStorage.setItem(this.prefixKey(key), JSON.stringify(value)); + } + + async delete(key: string): Promise { + localStorage.removeItem(this.prefixKey(key)); + } + + async listKeys(): Promise { + const prefix = `drop:plugin:${this.pluginId}:`; + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(prefix)) { + keys.push(k.slice(prefix.length)); + } + } + return keys; + } +} + +class TauriScopedGameFs implements ScopedGameFs { + async readFile(gameId: string, relativePath: string): Promise { + const res = await safeInvoke( + "plugin_game_fs_read", + { gameId, relativePath }, + [], + ); + return new Uint8Array(res); + } + + async writeFile( + gameId: string, + relativePath: string, + data: Uint8Array | string, + ): Promise { + const bytes = + typeof data === "string" + ? Array.from(new TextEncoder().encode(data)) + : Array.from(data); + await safeInvoke("plugin_game_fs_write", { + gameId, + relativePath, + data: bytes, + }); + } + + async backupFile(gameId: string, relativePath: string): Promise { + return ( + (await safeInvoke("plugin_game_fs_backup", { + gameId, + relativePath, + })) || "" + ); + } + + async restoreFile(gameId: string, relativePath: string): Promise { + await safeInvoke("plugin_game_fs_restore", { + gameId, + relativePath, + }); + } + + async fileExists(gameId: string, relativePath: string): Promise { + return ( + (await safeInvoke( + "plugin_game_fs_exists", + { gameId, relativePath }, + false, + )) ?? false + ); + } + + async deleteFile(gameId: string, relativePath: string): Promise { + await safeInvoke("plugin_game_fs_delete", { + gameId, + relativePath, + }); + } +} + +class TauriScopedGameScanner implements ScopedGameScanner { + async scanExecutables( + gameId: string, + ): Promise> { + return ( + (await safeInvoke< + Array<{ relativePath: string; sha256: string; size: number }> + >("plugin_game_scan_executables", { gameId }, [])) || [] + ); + } + + async checkAntiCheat(gameId: string): Promise { + return ( + (await safeInvoke( + "plugin_game_check_anticheat", + { gameId }, + { detected: false, files: [] }, + )) || { detected: false, files: [] } + ); + } +} + +class TauriPluginWebSocket implements ClientPluginWebSocket { + async send(channel: string, data: unknown): Promise { + return await safeInvoke("plugin_request_ws", { channel, data }); + } + + subscribe(channel: string, listener: (data: unknown) => void): () => void { + if (isTauri()) { + safeInvoke("plugin_subscribe", { channel }).catch((err) => { + console.error(`Failed to subscribe to plugin channel ${channel}:`, err); + }); + } + + const handler = (event: Event) => { + const custom = event as CustomEvent<{ channel?: string; data?: unknown }>; + if (custom?.detail?.channel === channel) { + listener(custom.detail.data); + } + }; + + window.addEventListener("plugin:event", handler); + return () => { + window.removeEventListener("plugin:event", handler); + }; + } +} + +export class ClientPluginManager { + private readonly plugins = new Map(); + public readonly slots = reactive>({ + "game-detail:actions": [], + "game-detail:panels": [], + "game-detail:badges": [], + "settings:tabs": [], + "topbar:status": [], + "sidebar:nav": [], + }); + + public readonly playActionProviders: Array< + (gameId: string) => Promise | PlayAction[] + > = []; + public readonly gameMenuItems = reactive([]); + public readonly sidebarItems = reactive([]); + public readonly topBarItems = reactive([]); + public readonly launchHooks: LaunchHook[] = []; + + public readonly isInitialized = ref(false); + + /** + * Register and initialize a client plugin instance. + */ + async registerPlugin( + plugin: ClientPlugin, + id?: string, + commands: string[] = [], + ): Promise { + const pluginId = id || plugin.metadata?.id || "anonymous-plugin"; + if (this.plugins.has(pluginId)) { + await this.unregisterPlugin(pluginId); + } + + // Register the native command allowlist before init so `ctx.system.run` + // works. Enforcement lives in the Tauri command layer, not here, so a + // plugin cannot bypass it by calling invoke directly. + try { + await safeInvoke("plugin_register_commands", { pluginId, commands }); + } catch (err) { + console.warn( + `Failed to register command allowlist for ${pluginId}:`, + err, + ); + } + + const context: ClientPluginContext = { + id: pluginId, + logger: { + info: (msg, ...args) => + console.log(`[Plugin:${pluginId}] ${msg}`, ...args), + warn: (msg, ...args) => + console.warn(`[Plugin:${pluginId}] ${msg}`, ...args), + error: (msg, ...args) => + console.error(`[Plugin:${pluginId}] ${msg}`, ...args), + debug: (msg, ...args) => + console.debug(`[Plugin:${pluginId}] ${msg}`, ...args), + }, + storage: new BrowserLocalStorage(pluginId), + registerSlot: (slot, component, options) => { + this.slots[slot].push({ + id: `${pluginId}-${slot}-${this.slots[slot].length}`, + pluginId, + slot, + component, + order: options?.order ?? 0, + label: options?.label, + icon: options?.icon, + }); + this.slots[slot].sort((a, b) => a.order - b.order); + }, + registerPlayAction: (provider) => { + this.playActionProviders.push(provider); + return () => { + const idx = this.playActionProviders.indexOf(provider); + if (idx !== -1) this.playActionProviders.splice(idx, 1); + }; + }, + registerGameMenuItem: (item) => { + this.gameMenuItems.push(item); + return () => { + const idx = this.gameMenuItems.indexOf(item); + if (idx !== -1) this.gameMenuItems.splice(idx, 1); + }; + }, + registerSidebarItem: (item) => { + this.sidebarItems.push(item); + return () => { + const idx = this.sidebarItems.indexOf(item); + if (idx !== -1) this.sidebarItems.splice(idx, 1); + }; + }, + registerTopBarItem: (item) => { + this.topBarItems.push(item); + return () => { + const idx = this.topBarItems.indexOf(item); + if (idx !== -1) this.topBarItems.splice(idx, 1); + }; + }, + registerLaunchHook: (hook) => { + this.launchHooks.push(hook); + return () => { + const idx = this.launchHooks.indexOf(hook); + if (idx !== -1) this.launchHooks.splice(idx, 1); + }; + }, + gameFs: new TauriScopedGameFs(), + gameScanner: new TauriScopedGameScanner(), + serverWs: new TauriPluginWebSocket(), + system: { + run: ( + bin: string, + args?: string[], + options?: { cwd?: string; timeoutMs?: number }, + ) => + safeInvoke( + "plugin_system_run", + { + pluginId, + bin, + args, + cwd: options?.cwd, + timeoutMs: options?.timeoutMs, + }, + { + code: 1, + stdout: "", + stderr: "Host native execution not available in browser mode", + }, + ), + }, + serverRequest: (method: HttpMethod, path = "", body?: unknown) => + safeInvoke("plugin_request", { + pluginId, + method, + path, + body, + }), + }; + + try { + await plugin.init(context); + this.plugins.set(pluginId, plugin); + console.log(`Initialized client plugin: ${pluginId}`); + } catch (e) { + console.error(`Failed to initialize client plugin ${pluginId}:`, e); + } + } + + async unregisterPlugin(pluginId: string): Promise { + const plugin = this.plugins.get(pluginId); + if (plugin) { + if (plugin.teardown) { + try { + await plugin.teardown(); + } catch (e) { + console.error(`Error during plugin teardown for ${pluginId}:`, e); + } + } + this.plugins.delete(pluginId); + } + + // Drop the native command allowlist for this plugin. + await safeInvoke("plugin_register_commands", { + pluginId, + commands: [], + }).catch(() => { + // Best effort: the plugin may never have registered a allowlist. + }); + + // Clean up UI slots registered by this plugin + for (const slotName of Object.keys(this.slots) as UISlotName[]) { + this.slots[slotName] = this.slots[slotName].filter( + (s) => s.pluginId !== pluginId, + ); + } + } + + /** + * Load client plugin bundle from a URL (e.g. served by Drop server). + */ + async loadFromUrl( + pluginId: string, + bundleUrl: string, + cssUrl?: string, + commands: string[] = [], + ): Promise { + if (cssUrl) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = cssUrl; + link.dataset.pluginId = pluginId; + document.head.appendChild(link); + } + + const mod = await import(/* @vite-ignore */ bundleUrl); + const candidates: Array = [ + mod.default, + mod.plugin, + ]; + const pluginExport = candidates.find((candidate) => candidate?.init); + + if (!pluginExport) { + throw new Error( + `Module at ${bundleUrl} does not export a valid ClientPlugin`, + ); + } + + await this.registerPlugin(pluginExport, pluginId, commands); + } + + /** + * Query all registered Play Actions for a given game. + */ + async getPlayActions(gameId: string): Promise { + const actions: PlayAction[] = []; + for (const provider of this.playActionProviders) { + try { + const result = await provider(gameId); + actions.push(...result); + } catch (err) { + console.error( + `Error querying play action provider for game ${gameId}:`, + err, + ); + } + } + return actions; + } + + /** + * Playnite-style Game Launch Pipeline Coordinator. + * Executes pre-launch hooks sorted by stage and priority. + * If any pre-launch hook aborts, completed stages are rolled back in reverse order. + * If launch succeeds, executes post-exit cleanup hooks. + */ + private sortHooks(stages: LaunchHook["stage"][]): LaunchHook[] { + return this.launchHooks + .filter((h) => stages.includes(h.stage)) + .sort((a, b) => { + const stageDiff = stages.indexOf(a.stage) - stages.indexOf(b.stage); + if (stageDiff !== 0) return stageDiff; + return (a.order ?? 0) - (b.order ?? 0); + }); + } + + private rollbackCompletedStages(completedHooks: LaunchHook[]): void { + for (let i = completedHooks.length - 1; i >= 0; i--) { + const rollbackHook = completedHooks[i]; + if (!rollbackHook) continue; + console.log(`Rolling back stage: ${rollbackHook.stage}`); + } + } + + private async runPreLaunchPipeline( + activePreHooks: LaunchHook[], + context: LaunchContext, + ): Promise { + const completedHooks: LaunchHook[] = []; + for (const hook of activePreHooks) { + try { + await hook.execute(context); + completedHooks.push(hook); + } catch (error) { + console.error( + `Pre-launch hook [${hook.stage}] failed. Rolling back completed stages...`, + error, + ); + this.rollbackCompletedStages(completedHooks); + throw new Error( + `Launch aborted during stage '${hook.stage}': ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + } + } + + private async runPostExitPipeline( + activePostHooks: LaunchHook[], + context: LaunchContext, + ): Promise { + for (const hook of activePostHooks) { + try { + await hook.execute(context); + } catch (postErr) { + console.warn(`Post-exit hook [${hook.stage}] warning:`, postErr); + } + } + } + + /** + * Playnite-style Game Launch Pipeline Coordinator. + * Executes pre-launch hooks sorted by stage and priority. + * If any pre-launch hook aborts, completed stages are rolled back in reverse order. + * If launch succeeds, executes post-exit cleanup hooks. + */ + async executeLaunchPipeline( + context: LaunchContext, + launchFn: () => Promise, + ): Promise { + const preLaunchStages: LaunchHook["stage"][] = [ + "pre-launch:validate", + "pre-launch:prepare", + "pre-launch:stage", + "pre-launch:network", + ]; + + const postExitStages: LaunchHook["stage"][] = [ + "post-exit:cleanup", + "post-exit:restore", + "post-exit:sync", + ]; + + await this.runPreLaunchPipeline(this.sortHooks(preLaunchStages), context); + const launchResult = await launchFn(); + await this.runPostExitPipeline(this.sortHooks(postExitStages), context); + + return launchResult; + } +} + +// Global Singleton Instance +export const clientPluginManager = new ClientPluginManager(); diff --git a/desktop/main/internal/plugins/PluginErrorBoundary.vue b/desktop/main/internal/plugins/PluginErrorBoundary.vue new file mode 100644 index 000000000..4db78f865 --- /dev/null +++ b/desktop/main/internal/plugins/PluginErrorBoundary.vue @@ -0,0 +1,37 @@ + + + diff --git a/desktop/main/internal/plugins/types.ts b/desktop/main/internal/plugins/types.ts new file mode 100644 index 000000000..8ea4a03a8 --- /dev/null +++ b/desktop/main/internal/plugins/types.ts @@ -0,0 +1,192 @@ +export type UISlotName = + | "game-detail:actions" + | "game-detail:panels" + | "game-detail:badges" + | "settings:tabs" + | "topbar:status" + | "sidebar:nav"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "ALL"; + +export interface UISlotRegistration { + id: string; + pluginId: string; + slot: UISlotName; + component: unknown; + order: number; + label?: string; + icon?: string; +} + +export interface LaunchContext { + gameId: string; + gameTitle: string; + gameDir: string; + actionId?: string; + metadata?: Record; +} + +export type LaunchStage = + | "pre-launch:validate" + | "pre-launch:prepare" + | "pre-launch:stage" + | "pre-launch:network" + | "launch" + | "post-exit:cleanup" + | "post-exit:restore" + | "post-exit:sync"; + +export interface LaunchHook { + stage: LaunchStage; + order?: number; + execute: (ctx: LaunchContext) => Promise | void; +} + +export interface PlayAction { + id: string; + name: string; + icon?: string; + isDefault?: boolean; + execute: (context: LaunchContext) => Promise | void; +} + +export interface GameMenuItem { + id: string; + label: string; + icon?: string; + execute: (gameId: string) => Promise | void; +} + +export interface SidebarItem { + id: string; + title: string; + icon?: string; + type: "button" | "view"; + progressValue?: number; + activated?: () => void; + component?: unknown; +} + +export interface TopBarItem { + id: string; + title: string; + icon?: string; + component?: unknown; + activated?: () => void; +} + +export interface ScopedGameFs { + readFile(gameId: string, relativePath: string): Promise; + writeFile( + gameId: string, + relativePath: string, + data: Uint8Array | string, + ): Promise; + backupFile(gameId: string, relativePath: string): Promise; + restoreFile(gameId: string, relativePath: string): Promise; + fileExists(gameId: string, relativePath: string): Promise; + deleteFile(gameId: string, relativePath: string): Promise; +} + +export interface AntiCheatReport { + detected: boolean; + reason?: string; + provider?: string; + binaries?: string[]; + files?: string[]; +} + +export interface ScopedGameScanner { + scanExecutables( + gameId: string, + ): Promise>; + checkAntiCheat(gameId: string): Promise; +} + +export interface ClientPluginStorage { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; + listKeys(): Promise; +} + +export interface ClientPluginWebSocket { + send(channel: string, data: unknown): Promise; + subscribe(channel: string, listener: (data: unknown) => void): () => void; +} + +/** Result of a native command run through the client host. */ +export interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export interface CommandOptions { + cwd?: string; + timeoutMs?: number; +} + +/** + * Native command execution for client plugins. The host runs the binary + * directly (no shell) and enforces the per-plugin allowlist registered from + * `manifest.client.commands`. + */ +export interface ClientPluginSystem { + run( + bin: string, + args?: string[], + options?: CommandOptions, + ): Promise; +} + +export interface ClientPluginContext { + id: string; + logger: { + info(msg: string, ...args: unknown[]): void; + warn(msg: string, ...args: unknown[]): void; + error(msg: string, ...args: unknown[]): void; + debug(msg: string, ...args: unknown[]): void; + }; + storage: ClientPluginStorage; + registerSlot( + slot: UISlotName, + component: unknown, + options?: { order?: number; label?: string; icon?: string }, + ): void; + registerPlayAction( + provider: (gameId: string) => Promise | PlayAction[], + ): () => void; + registerGameMenuItem(item: GameMenuItem): () => void; + registerSidebarItem(item: SidebarItem): () => void; + registerTopBarItem(item: TopBarItem): () => void; + registerLaunchHook(hook: LaunchHook): () => void; + gameFs: ScopedGameFs; + gameScanner: ScopedGameScanner; + serverWs: ClientPluginWebSocket; + /** Native command execution. Requires the `system:command` capability. */ + system: ClientPluginSystem; + /** + * Call this plugin's own server-side REST routes through the desktop host. + * The webview cannot reach the Drop server directly, so the host proxies the + * request. `path` is relative to `/api/v1/plugins/`. + */ + serverRequest( + method: HttpMethod, + path?: string, + body?: unknown, + ): Promise; +} + +export interface ClientPlugin { + metadata?: { + id: string; + name: string; + version: string; + description?: string; + author?: string; + category?: string; + }; + init(ctx: ClientPluginContext): Promise | void; + teardown?(): Promise | void; +} diff --git a/desktop/main/pages/library/[id]/index.vue b/desktop/main/pages/library/[id]/index.vue index 498078739..d58566cde 100644 --- a/desktop/main/pages/library/[id]/index.vue +++ b/desktop/main/pages/library/[id]/index.vue @@ -6,6 +6,8 @@
{{ game.mName }} -
+
Update available
+
+ +
+ + +
@@ -110,23 +144,26 @@
-
+ + +
+ >
@@ -143,8 +181,9 @@
@@ -168,13 +207,14 @@
@@ -216,7 +256,7 @@
- + Version @@ -288,9 +328,9 @@ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" >
  • -
    + Loading... -
    +
  • -
      +
      • @@ -528,8 +569,9 @@

      -
    1. +