diff --git a/desktop/main/components/GameStatusButton.vue b/desktop/main/components/GameStatusButton.vue index f7ac7650a..2fe56d478 100644 --- a/desktop/main/components/GameStatusButton.vue +++ b/desktop/main/components/GameStatusButton.vue @@ -61,6 +61,26 @@ + + emit('play-action', action)" + :class="[ + active + ? 'bg-zinc-800 text-zinc-100 outline-none' + : 'text-zinc-300', + 'w-full px-4 py-2 text-sm inline-flex justify-between items-center', + ]" + > + {{ action.name }} + + + + emit('options')" @@ -113,14 +133,19 @@ import { InstalledType, type GameStatus, } from "~/types.js"; +import type { PlayAction } from "~/internal/plugins/types"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline"; import { ArrowsRightLeftIcon, ArrowUpTrayIcon } from "@heroicons/vue/24/solid"; -const props = defineProps<{ status: GameStatus }>(); +const props = defineProps<{ + status: GameStatus; + playActions?: PlayAction[]; +}>(); const emit = defineEmits<{ (e: "install"): void; (e: "launch"): void; + (e: "play-action", action: PlayAction): void; (e: "queue"): void; (e: "uninstall"): void; (e: "kill"): void; diff --git a/desktop/main/components/LibrarySearch.vue b/desktop/main/components/LibrarySearch.vue index 38f927aa4..a989ae8a5 100644 --- a/desktop/main/components/LibrarySearch.vue +++ b/desktop/main/components/LibrarySearch.vue @@ -36,7 +36,12 @@ as="div" v-for="(nav, navIndex) in filteredNavigation" :key="nav.id" - :class="['first:pt-0 last:pb-0', nav.tools && !filteredNavigation[navIndex - 1].tools ? 'mt-auto' : '']" + :class="[ + 'first:pt-0 last:pb-0', + nav.tools && !filteredNavigation[navIndex - 1]?.tools + ? 'mt-auto' + : '', + ]" v-slot="{ open }" :default-open="nav.deft" > @@ -90,10 +95,10 @@ - {{ getGameStatusStyleText(games[item.id].status.value)[1] }} + {{ getGameStatusStyleText(games[item.id]!.status.value)[1] }} @@ -295,7 +300,7 @@ await new Promise((r) => { const navigation = computed(() => collections.value.map((collection) => { const items = collection.entries.map(({ game }) => { - const status = games[game.id].status; + const status = games[game.id]!.status; const isInstalled = computed(() => status.value.type != "Remote"); 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/game.ts b/desktop/main/composables/game.ts index 93e97af2d..0a73c2d66 100644 --- a/desktop/main/composables/game.ts +++ b/desktop/main/composables/game.ts @@ -14,7 +14,6 @@ const gameRegistry: { [key: string]: { game: Game; version: Ref } = {}; export const parseStatus = (status: RawGameStatus): GameStatus => { - console.log(status[0]); if (status[0]) { return status[0]; } @@ -33,16 +32,18 @@ export const useGame = async (gameId: string) => { } = await invoke("fetch_game", { gameId, }); - gameRegistry[gameId] = { game: data.game, version: ref(data.version) }; + const entry = { game: data.game, version: ref(data.version) }; + gameRegistry[gameId] = entry; if (!gameStatusRegistry[gameId]) { - gameStatusRegistry[gameId] = ref(parseStatus(data.status)); + const statusRef = ref(parseStatus(data.status)); + gameStatusRegistry[gameId] = statusRef; listen(`update_game/${gameId}`, (event) => { const payload: { status: RawGameStatus; version?: GameVersion; } = event.payload as any; - gameStatusRegistry[gameId].value = parseStatus(payload.status); + statusRef.value = parseStatus(payload.status); /** * I am not super happy about this. @@ -52,14 +53,14 @@ export const useGame = async (gameId: string) => { * on transient state updates. */ if (payload.version) { - gameRegistry[gameId].version.value = payload.version; + entry.version.value = payload.version; } }); } } - const game = gameRegistry[gameId]; - const status = gameStatusRegistry[gameId]; + const game = gameRegistry[gameId]!; + const status = gameStatusRegistry[gameId]!; return { ...game, status }; }; 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..418a53fce --- /dev/null +++ b/desktop/main/internal/plugins/ClientPluginManager.ts @@ -0,0 +1,555 @@ +import { reactive, ref } from "vue"; +import type { + ClientPlugin, + ClientPluginContext, + ClientPluginWebSocket, + CloudSavePathResolver, + GameMenuItem, + HttpMethod, + LaunchContext, + LaunchHook, + LaunchOverrides, + MetadataProvider, + PlayAction, + RunnerProvider, + SidebarItem, + Sidecar, + StoreScanner, + TopBarItem, + UISlotName, + UISlotRegistration, +} from "./types"; + +/** Platform data used to pick the matching sidecar target. */ +export interface SidecarPlatform { + os: "linux" | "macos" | "windows"; + arch: "x64" | "arm64"; +} + +/** + * Best-effort host platform detection for sidecar target selection. Runs in + * the desktop webview, where Tauri provides a real UA; wrong guesses never + * break anything because staging is sha256-verified per target and failures + * fall back to PATH resolution. + */ +export function detectSidecarPlatform(): SidecarPlatform | null { + if (typeof navigator === "undefined") return null; + const ua = navigator.userAgent ?? ""; + let os: SidecarPlatform["os"] | null = null; + if (/windows/i.test(ua)) os = "windows"; + else if (/macintosh|mac os x/i.test(ua)) os = "macos"; + else if (/linux/i.test(ua)) os = "linux"; + if (!os) return null; + const arch: SidecarPlatform["arch"] = /aarch64|arm64|apple m[123]/i.test(ua) + ? "arm64" + : "x64"; + return { os, arch }; +} +import { safeInvoke } from "./host"; +import { TauriPluginStorage } from "./host/storage"; +import { TauriScopedGameFs } from "./host/game-fs"; +import { TauriScopedGameScanner } from "./host/game-scanner"; +import { TauriPluginWebSocket } from "./host/websocket"; +import { createPluginSystem } from "./host/system"; +import { executeLaunchPipeline as runLaunchPipeline } from "./launch-pipeline"; + +export { isTauri, safeInvoke } from "./host"; + +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": [], + "overlay:panel": [], + "overlay:quick-access": [], + }); + + 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 storeScanners = reactive< + Array<{ pluginId: string; scanner: StoreScanner }> + >([]); + public readonly metadataProviders = reactive< + Array<{ pluginId: string; provider: MetadataProvider }> + >([]); + public readonly cloudSaveResolvers = reactive< + Array<{ pluginId: string; resolver: CloudSavePathResolver }> + >([]); + public readonly runnerProviders = reactive< + Array<{ pluginId: string; provider: RunnerProvider }> + >([]); + + /** + * Host launch delegate wired by the library view so `ctx.launchGame` can + * reuse the same launch-index resolution as the Play button. Falls back to a + * direct native launch (index 0) when unset. + */ + private gameLaunchHandler?: ( + gameId: string, + overrides?: LaunchOverrides, + ) => Promise; + + /** Local (client-side) plugin event bus, keyed by event name. */ + private readonly localEvents = new Map< + string, + Set<(data: unknown) => void> + >(); + + public readonly serverWs: ClientPluginWebSocket = new TauriPluginWebSocket(); + + public readonly isInitialized = ref(false); + + /** + * Register the host launch delegate used by `ctx.launchGame`. The library + * view wires this so plugin-triggered launches resolve the same launch option + * index as the Play button; without it, launches fall back to index 0. + */ + setGameLaunchHandler( + handler: (gameId: string, overrides?: LaunchOverrides) => Promise, + ): void { + this.gameLaunchHandler = handler; + } + + /** + * Stage the sidecar binary for the current platform that a plugin bundle + * declares. The Tauri command verifies the SHA-256, stages the binary under + * the plugin's app-data bin dir and makes it executable, so `ctx.system.run` + * later resolves the allowlisted bare name against it. Non-fatal: hosts + * without a matching target stay on PATH/fallbacks. + */ + async stageSidecars( + pluginId: string, + commands: string[], + sidecars: Sidecar[] | undefined, + ): Promise { + if (!Array.isArray(sidecars) || sidecars.length === 0) return; + const platform = detectSidecarPlatform(); + if (!platform) { + console.debug("Unknown sidecar platform; skipping staging"); + return; + } + const commandSet = new Set(commands); + for (const sidecar of sidecars) { + if (!commandSet.has(sidecar.name)) continue; + const target = sidecar.targets.find( + (t) => t.os === platform.os && t.arch === platform.arch, + ); + if (!target) continue; + try { + await safeInvoke("plugin_sidecar_stage", { + pluginId, + name: sidecar.name, + asset: target.path, + sha256: target.sha256, + }); + console.debug("Staged sidecar:", pluginId, sidecar.name); + } catch (err) { + console.warn( + "Failed to stage sidecar; falling back to PATH resolution:", + pluginId, + sidecar.name, + err, + ); + } + } + } + + /** + * Register and initialize a client plugin instance. + */ + async registerPlugin( + plugin: ClientPlugin, + id?: string, + commands: string[] = [], + capabilities: string[] = [], + sidecars: Sidecar[] = [], + ): 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, + ); + } + + // Stage declared sidecar binaries (sha256-verified) before init so + // `ctx.system.run` can resolve their allowlisted bare names. + await this.stageSidecars(pluginId, commands, sidecars); + + 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 TauriPluginStorage(pluginId), + // Client-side declarative settings are optional; the key is always + // present for surface parity with the server context but stays undefined + // until the host injects a schema-backed snapshot. + settings: undefined, + registerSlot: (slot, component, options) => { + if (!this.slots[slot]) { + this.slots[slot] = []; + } + 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); + }; + }, + registerStoreScanner: (scanner: StoreScanner) => { + if ( + capabilities.length > 0 && + !capabilities.includes("client:library-scan") + ) { + throw new Error( + `Client plugin '${pluginId}' attempted 'registerStoreScanner' without the 'client:library-scan' capability`, + ); + } + if (!scanner || typeof scanner.id !== "string" || !scanner.id.trim()) { + throw new Error("Store scanner must have a valid non-empty id"); + } + const entry = { pluginId, scanner }; + this.storeScanners.push(entry); + return () => { + const idx = this.storeScanners.indexOf(entry); + if (idx !== -1) this.storeScanners.splice(idx, 1); + }; + }, + registerMetadataProvider: (provider: MetadataProvider) => { + if ( + capabilities.length > 0 && + !capabilities.includes("metadata:provider") + ) { + throw new Error( + `Client plugin '${pluginId}' attempted 'registerMetadataProvider' without the 'metadata:provider' capability`, + ); + } + if ( + !provider || + typeof provider.id !== "string" || + !provider.id.trim() + ) { + throw new Error("Metadata provider must have a valid non-empty id"); + } + const entry = { pluginId, provider }; + this.metadataProviders.push(entry); + return () => { + const idx = this.metadataProviders.indexOf(entry); + if (idx !== -1) this.metadataProviders.splice(idx, 1); + }; + }, + registerCloudSaveResolver: (resolver: CloudSavePathResolver) => { + if ( + capabilities.length > 0 && + !capabilities.includes("cloudsave:provider") + ) { + throw new Error( + `Client plugin '${pluginId}' attempted 'registerCloudSaveResolver' without the 'cloudsave:provider' capability`, + ); + } + if ( + !resolver || + typeof resolver.id !== "string" || + !resolver.id.trim() + ) { + throw new Error("Cloud save resolver must have a valid non-empty id"); + } + const entry = { pluginId, resolver }; + this.cloudSaveResolvers.push(entry); + return () => { + const idx = this.cloudSaveResolvers.indexOf(entry); + if (idx !== -1) this.cloudSaveResolvers.splice(idx, 1); + }; + }, + registerRunnerProvider: (provider: RunnerProvider) => { + if (capabilities.length > 0 && !capabilities.includes("game:runner")) { + throw new Error( + `Client plugin '${pluginId}' attempted 'registerRunnerProvider' without the 'game:runner' capability`, + ); + } + if ( + !provider || + typeof provider.id !== "string" || + !provider.id.trim() + ) { + throw new Error("Runner provider must have a valid non-empty id"); + } + const entry = { pluginId, provider }; + this.runnerProviders.push(entry); + return () => { + const idx = this.runnerProviders.indexOf(entry); + if (idx !== -1) this.runnerProviders.splice(idx, 1); + }; + }, + launchGame: async (gameId: string, overrides?: LaunchOverrides) => { + if (this.gameLaunchHandler) { + await this.gameLaunchHandler(gameId, overrides); + return; + } + // Best-effort fallback: launch option 0. The library view wires + // `setGameLaunchHandler` so launch index + overrides resolve correctly. + await safeInvoke("launch_game", { id: gameId, index: 0 }); + }, + ui: { + openExternal: (url: string) => + safeInvoke("plugin_open_external", { url }), + }, + events: { + on: (event: string, listener: (data: unknown) => void) => { + let listeners = this.localEvents.get(event); + if (!listeners) { + listeners = new Set(); + this.localEvents.set(event, listeners); + } + listeners.add(listener); + return () => { + const current = this.localEvents.get(event); + if (!current) return; + current.delete(listener); + if (current.size === 0) this.localEvents.delete(event); + }; + }, + emit: (event: string, data: unknown) => { + const listeners = this.localEvents.get(event); + if (!listeners) return; + for (const listener of [...listeners]) { + try { + listener(data); + } catch (err) { + console.error(`Plugin event listener for '${event}' threw:`, err); + } + } + }, + }, + gameFs: new TauriScopedGameFs(), + gameScanner: new TauriScopedGameScanner(), + serverWs: this.serverWs, + system: createPluginSystem(pluginId), + 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. + }); + + // Drop any staged sidecar binaries for this plugin. + await safeInvoke("plugin_sidecar_clear", { pluginId }).catch(() => { + // Best effort: the plugin may never have staged a sidecar. + }); + + // 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, + ); + } + + // Clean up store scanners and metadata providers registered by this plugin + this.purgeOwned(this.storeScanners, pluginId); + this.purgeOwned(this.metadataProviders, pluginId); + this.purgeOwned(this.cloudSaveResolvers, pluginId); + this.purgeOwned(this.runnerProviders, pluginId); + } + + /** Removes every reactive entry owned by `pluginId` from `entries`. */ + private purgeOwned( + entries: T[], + pluginId: string, + ): void { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.pluginId === pluginId) { + entries.splice(i, 1); + } + } + } + + getStoreScanners(): StoreScanner[] { + return this.storeScanners.map((e) => e.scanner); + } + + getMetadataProviders(): MetadataProvider[] { + return this.metadataProviders.map((e) => e.provider); + } + + getCloudSaveResolvers(): CloudSavePathResolver[] { + return this.cloudSaveResolvers.map((e) => e.resolver); + } + + getRunnerProviders(): RunnerProvider[] { + return this.runnerProviders.map((e) => e.provider); + } + + /** + * Load client plugin bundle from a URL (e.g. served by Drop server). + */ + async loadFromUrl( + pluginId: string, + bundleUrl: string, + cssUrl?: string, + commands: string[] = [], + capabilities: string[] = [], + sidecars: Sidecar[] = [], + ): 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, + capabilities, + sidecars, + ); + } + + /** + * 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. + */ + async executeLaunchPipeline( + context: LaunchContext, + launchFn: () => Promise, + ): Promise { + return runLaunchPipeline(this.launchHooks, context, launchFn); + } +} + +// 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 @@ + + + Plugin Error + ({{ pluginId }}) + + + + + diff --git a/desktop/main/internal/plugins/__tests__/client_plugin_manager.test.ts b/desktop/main/internal/plugins/__tests__/client_plugin_manager.test.ts new file mode 100644 index 000000000..b35959dfe --- /dev/null +++ b/desktop/main/internal/plugins/__tests__/client_plugin_manager.test.ts @@ -0,0 +1,383 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ClientPluginManager } from "../ClientPluginManager"; +import { executeLaunchPipeline } from "../launch-pipeline"; +import type { + ClientPlugin, + ClientPluginContext, + CloudSavePathResolver, + LaunchHook, + MetadataProvider, + RunnerProvider, + StoreScanner, +} from "../types"; + +test("ClientPluginManager registers overlay slots without crashing", async () => { + const manager = new ClientPluginManager(); + + const overlayPlugin: ClientPlugin = { + metadata: { + id: "overlay-hud", + name: "Overlay HUD", + version: "1.0.0", + }, + init(ctx: ClientPluginContext) { + ctx.registerSlot("overlay:panel", { template: "Panel" }); + ctx.registerSlot("overlay:quick-access", { + template: "Access", + }); + }, + }; + + await manager.registerPlugin(overlayPlugin, "overlay-hud", [], ["ui:slot"]); + + assert.equal(manager.slots["overlay:panel"].length, 1); + assert.equal(manager.slots["overlay:panel"][0]?.pluginId, "overlay-hud"); + assert.equal(manager.slots["overlay:quick-access"].length, 1); + assert.equal( + manager.slots["overlay:quick-access"][0]?.pluginId, + "overlay-hud", + ); + + await manager.unregisterPlugin("overlay-hud"); + assert.equal(manager.slots["overlay:panel"].length, 0); + assert.equal(manager.slots["overlay:quick-access"].length, 0); +}); + +test("ClientPluginManager registers StoreScanner and MetadataProvider SPIs", async () => { + const manager = new ClientPluginManager(); + + const gogScanner: StoreScanner = { + id: "gog", + name: "GOG Galaxy", + store: "gog", + scan: async () => [ + { + externalId: "gog-101", + store: "gog", + title: "Cyberpunk 2077", + installPath: "/games/cyberpunk", + }, + ], + }; + + const exampleMetadataProvider: MetadataProvider = { + id: "example-metadata", + name: "Example Metadata", + search: async (query) => [ + { id: "meta-1", title: query, provider: "example-metadata" }, + ], + getDetails: async (id) => ({ + id, + title: "Super Mario World", + provider: "example-metadata", + }), + }; + + let unregisterScanner: (() => void) | undefined; + let unregisterProvider: (() => void) | undefined; + let unregisterCloudSave: (() => void) | undefined; + + const exampleCloudsaveResolver: CloudSavePathResolver = { + id: "example-cloudsave", + name: "Example Cloud Save", + resolveSavePaths: async (gameContext) => [ + { pattern: `%APPDATA%/${gameContext.gameTitle}` }, + ], + }; + + const aggregatorPlugin: ClientPlugin = { + metadata: { + id: "aggregator", + name: "Aggregator Plugin", + version: "1.0.0", + }, + init(ctx: ClientPluginContext) { + unregisterScanner = ctx.registerStoreScanner(gogScanner); + if (ctx.registerMetadataProvider) { + unregisterProvider = ctx.registerMetadataProvider( + exampleMetadataProvider, + ); + } + unregisterCloudSave = ctx.registerCloudSaveResolver?.( + exampleCloudsaveResolver, + ); + }, + }; + + await manager.registerPlugin( + aggregatorPlugin, + "aggregator", + [], + ["client:library-scan", "metadata:provider", "cloudsave:provider"], + ); + + // Assert registered + const scanners = manager.getStoreScanners(); + assert.equal(scanners.length, 1); + const scanner = scanners[0]; + assert.ok(scanner); + assert.equal(scanner.id, "gog"); + const games = await scanner.scan(); + assert.equal(games.length, 1); + assert.equal(games[0]?.title, "Cyberpunk 2077"); + + const providers = manager.getMetadataProviders(); + assert.equal(providers.length, 1); + assert.equal(providers[0]?.id, "example-metadata"); + + const resolvers = manager.getCloudSaveResolvers(); + assert.equal(resolvers.length, 1); + assert.equal(resolvers[0]?.id, "example-cloudsave"); + + // Capability enforcement + const restrictedPlugin: ClientPlugin = { + metadata: { + id: "restricted-client", + name: "Restricted", + version: "1.0.0", + }, + init(ctx: ClientPluginContext) { + ctx.registerStoreScanner({ + id: "epic", + name: "Epic", + store: "epic", + scan: async () => [], + }); + }, + }; + + await manager.registerPlugin( + restrictedPlugin, + "restricted-client", + [], + ["ui:slot"], + ); + // Registration should fail inside init and not add scanner + assert.equal(manager.getStoreScanners().length, 1); + + // Manual unregister hooks work + unregisterScanner?.(); + assert.equal(manager.getStoreScanners().length, 0); + + unregisterProvider?.(); + assert.equal(manager.getMetadataProviders().length, 0); + + unregisterCloudSave?.(); + assert.equal(manager.getCloudSaveResolvers().length, 0); +}); + +test("ClientPluginManager cleans up SPI entries and slots on unregisterPlugin", async () => { + const manager = new ClientPluginManager(); + + const multiPlugin: ClientPlugin = { + metadata: { + id: "multi-test", + name: "Multi Test", + version: "1.0.0", + }, + init(ctx: ClientPluginContext) { + ctx.registerSlot("overlay:panel", { template: "test" }); + ctx.registerStoreScanner({ + id: "scanner-1", + name: "Scanner 1", + store: "custom", + scan: async () => [], + }); + ctx.registerMetadataProvider?.({ + id: "meta-1", + name: "Meta 1", + search: async () => [], + getDetails: async () => null, + }); + }, + }; + + await manager.registerPlugin( + multiPlugin, + "multi-test", + [], + ["ui:slot", "client:library-scan", "metadata:provider"], + ); + + assert.equal(manager.slots["overlay:panel"].length, 1); + assert.equal(manager.getStoreScanners().length, 1); + assert.equal(manager.getMetadataProviders().length, 1); + + await manager.unregisterPlugin("multi-test"); + + assert.equal(manager.slots["overlay:panel"].length, 0); + assert.equal(manager.getStoreScanners().length, 0); + assert.equal(manager.getMetadataProviders().length, 0); +}); + +test("ClientPluginContext exposes settings for surface parity", async () => { + const manager = new ClientPluginManager(); + let captured: ClientPluginContext | undefined; + + const pluginWithSettings: ClientPlugin = { + metadata: { + id: "settings-test", + name: "Settings Test", + version: "1.0.0", + }, + init(ctx: ClientPluginContext) { + captured = ctx; + }, + }; + + await manager.registerPlugin(pluginWithSettings, "settings-test", [], []); + assert.ok(captured); + // The key is always present for parity with the server context; the value is + // undefined until the host injects a schema-backed snapshot. + assert.ok(Object.hasOwn(captured as ClientPluginContext, "settings")); + assert.equal(captured.settings, undefined); + + await manager.unregisterPlugin("settings-test"); +}); + +test("ClientPluginManager registers and gates RunnerProvider SPI", async () => { + const manager = new ClientPluginManager(); + + const runner: RunnerProvider = { + id: "proton-runner", + name: "Proton", + supportedPlatforms: ["windows"], + detect: async () => ({ available: true, version: "9.0" }), + resolveLaunch: async () => ({ wrapperBin: "proton", wrapperArgs: ["run"] }), + }; + + const plugin: ClientPlugin = { + metadata: { id: "runner-plugin", name: "Runner", version: "1.0.0" }, + init(ctx: ClientPluginContext) { + ctx.registerRunnerProvider?.(runner); + }, + }; + + await manager.registerPlugin(plugin, "runner-plugin", [], ["game:runner"]); + assert.equal(manager.getRunnerProviders().length, 1); + assert.equal(manager.getRunnerProviders()[0]?.name, "Proton"); + + // Missing capability fails closed (init errors are swallowed, so the + // provider must simply not be registered). + const denied: ClientPlugin = { + metadata: { id: "runner-denied", name: "Denied", version: "1.0.0" }, + init(ctx: ClientPluginContext) { + ctx.registerRunnerProvider?.({ + id: "denied", + name: "Denied", + supportedPlatforms: ["linux"], + detect: async () => ({ available: false }), + resolveLaunch: async () => ({}), + }); + }, + }; + await manager.registerPlugin(denied, "runner-denied", [], ["ui:slot"]); + assert.equal(manager.getRunnerProviders().length, 1); + + await manager.unregisterPlugin("runner-plugin"); + assert.equal(manager.getRunnerProviders().length, 0); +}); + +test("ClientPluginContext exposes the SDK client surface", async () => { + const manager = new ClientPluginManager(); + let captured: ClientPluginContext | undefined; + + const plugin: ClientPlugin = { + metadata: { id: "surface", name: "Surface", version: "1.0.0" }, + init(ctx: ClientPluginContext) { + captured = ctx; + }, + }; + + await manager.registerPlugin(plugin, "surface", [], []); + assert.ok(captured); + const members = [ + "id", + "logger", + "storage", + "settings", + "registerSlot", + "registerPlayAction", + "registerGameMenuItem", + "registerSidebarItem", + "registerTopBarItem", + "registerLaunchHook", + "registerStoreScanner", + "registerMetadataProvider", + "registerCloudSaveResolver", + "registerRunnerProvider", + "launchGame", + "ui", + "events", + "gameFs", + "gameScanner", + "serverWs", + "system", + "serverRequest", + ] as const; + for (const member of members) { + assert.ok( + Object.hasOwn(captured as ClientPluginContext, member), + `ClientPluginContext is missing '${member}'`, + ); + } + + await manager.unregisterPlugin("surface"); +}); + +test("executeLaunchPipeline executes pre-launch:network-post in sequence", async () => { + const executionOrder: string[] = []; + const hooks: LaunchHook[] = [ + { + stage: "pre-launch:network-post", + order: 10, + execute: async () => { + executionOrder.push("network-post"); + }, + }, + { + stage: "pre-launch:network", + order: 0, + execute: async () => { + executionOrder.push("network"); + }, + }, + { + stage: "pre-launch:validate", + order: 0, + execute: async () => { + executionOrder.push("validate"); + }, + }, + { + stage: "post-exit:cleanup", + order: 0, + execute: async () => { + executionOrder.push("cleanup"); + }, + }, + ]; + + const result = await executeLaunchPipeline( + hooks, + { + gameId: "test-game", + gameTitle: "Test Game", + gameDir: "/games/test", + }, + async () => { + executionOrder.push("launch"); + return "launched-ok"; + }, + ); + + assert.equal(result, "launched-ok"); + assert.deepEqual(executionOrder, [ + "validate", + "network", + "network-post", + "launch", + "cleanup", + ]); +}); diff --git a/desktop/main/internal/plugins/__tests__/storeImport.test.ts b/desktop/main/internal/plugins/__tests__/storeImport.test.ts new file mode 100644 index 000000000..bb827cb3d --- /dev/null +++ b/desktop/main/internal/plugins/__tests__/storeImport.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { StoreScanner } from "../types"; +import { collectStoreGames } from "../storeImport"; + +const steam: StoreScanner = { + id: "steam", + name: "Steam", + store: "steam", + scan: async () => [ + { + externalId: "570", + store: "steam", + title: "Team Fortress 2", + installPath: "/games/tf2", + }, + ], +}; + +const broken: StoreScanner = { + id: "epic", + name: "Epic", + store: "epic", + scan: async () => { + throw new Error("launcher not found"); + }, +}; + +test("collectStoreGames aggregates scanners and records failures", async () => { + const result = await collectStoreGames([steam, broken]); + assert.equal(result.games.length, 1); + assert.equal(result.games[0]?.title, "Team Fortress 2"); + assert.deepEqual(result.failures, [ + { store: "epic", error: "launcher not found" }, + ]); +}); + +test("collectStoreGames with no scanners returns empty", async () => { + const result = await collectStoreGames([]); + assert.deepEqual(result, { games: [], failures: [] }); +}); diff --git a/desktop/main/internal/plugins/host.ts b/desktop/main/internal/plugins/host.ts new file mode 100644 index 000000000..4f33abd41 --- /dev/null +++ b/desktop/main/internal/plugins/host.ts @@ -0,0 +1,30 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Whether the client is running inside a Tauri webview. Browser/dev builds + * fall back to in-memory implementations so the UI can still boot. + */ +export function isTauri(): boolean { + return ( + typeof window !== "undefined" && + ("__TAURI_INTERNALS__" in window || "__TAURI__" in window) + ); +} + +/** + * Invoke a Tauri command, returning `fallback` (or `null`) in browser mode + * instead of throwing so plugin code degrades gracefully outside the desktop. + */ +export 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); +} diff --git a/desktop/main/internal/plugins/host/game-fs.ts b/desktop/main/internal/plugins/host/game-fs.ts new file mode 100644 index 000000000..1ac645d67 --- /dev/null +++ b/desktop/main/internal/plugins/host/game-fs.ts @@ -0,0 +1,62 @@ +import type { ScopedGameFs } from "../types"; +import { safeInvoke } from "../host"; + +export 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, + }); + } +} diff --git a/desktop/main/internal/plugins/host/game-scanner.ts b/desktop/main/internal/plugins/host/game-scanner.ts new file mode 100644 index 000000000..02dd09281 --- /dev/null +++ b/desktop/main/internal/plugins/host/game-scanner.ts @@ -0,0 +1,24 @@ +import type { ScopedGameScanner } from "../types"; +import { safeInvoke } from "../host"; + +export 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 findFiles(gameId: string, patterns: string[]): Promise { + return ( + (await safeInvoke( + "plugin_game_find_files", + { gameId, patterns }, + [], + )) || [] + ); + } +} diff --git a/desktop/main/internal/plugins/host/storage.ts b/desktop/main/internal/plugins/host/storage.ts new file mode 100644 index 000000000..539ed4119 --- /dev/null +++ b/desktop/main/internal/plugins/host/storage.ts @@ -0,0 +1,59 @@ +import type { ClientPluginStorage } from "../types"; +import { isTauri, safeInvoke } from "../host"; + +export class TauriPluginStorage implements ClientPluginStorage { + // Browser/dev fallback. Plugin state lives in the Rust-side database when + // Tauri is available so frontend and backend never split their state. + private readonly memory = new Map(); + + constructor(private readonly pluginId: string) {} + + async get(key: string): Promise { + if (!isTauri()) { + return this.memory.has(key) ? (this.memory.get(key) as T) : null; + } + return ( + (await safeInvoke( + "plugin_storage_get", + { pluginId: this.pluginId, key }, + null, + )) ?? null + ); + } + + async set(key: string, value: T): Promise { + if (!isTauri()) { + this.memory.set(key, value); + return; + } + await safeInvoke("plugin_storage_set", { + pluginId: this.pluginId, + key, + value, + }); + } + + async delete(key: string): Promise { + if (!isTauri()) { + this.memory.delete(key); + return; + } + await safeInvoke("plugin_storage_delete", { + pluginId: this.pluginId, + key, + }); + } + + async listKeys(): Promise { + if (!isTauri()) { + return Array.from(this.memory.keys()); + } + return ( + (await safeInvoke( + "plugin_storage_list_keys", + { pluginId: this.pluginId }, + [], + )) || [] + ); + } +} diff --git a/desktop/main/internal/plugins/host/system.ts b/desktop/main/internal/plugins/host/system.ts new file mode 100644 index 000000000..303c0b425 --- /dev/null +++ b/desktop/main/internal/plugins/host/system.ts @@ -0,0 +1,32 @@ +import type { ClientPluginSystem, CommandResult } from "../types"; +import { safeInvoke } from "../host"; + +/** + * Build the `ctx.system` native-command surface for `pluginId`. The allowlist + * is registered separately (`plugin_register_commands`); enforcement lives in + * the Tauri command layer, not here, so a plugin cannot bypass it. + */ +export function createPluginSystem(pluginId: string): ClientPluginSystem { + return { + 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", + }, + ), + }; +} diff --git a/desktop/main/internal/plugins/host/websocket.ts b/desktop/main/internal/plugins/host/websocket.ts new file mode 100644 index 000000000..8b2882b1a --- /dev/null +++ b/desktop/main/internal/plugins/host/websocket.ts @@ -0,0 +1,61 @@ +import { listen } from "@tauri-apps/api/event"; +import type { ClientPluginWebSocket } from "../types"; +import { isTauri, safeInvoke } from "../host"; + +export 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()) { + let disposed = false; + let unlisten: (() => void) | undefined; + + safeInvoke("plugin_subscribe", { channel }).catch((err) => { + console.error(`Failed to subscribe to plugin channel ${channel}:`, err); + }); + + // The Rust host forwards decoded WebSocket frames as the Tauri event + // `plugin:event`. Tauri events are not DOM events, so the payload must be + // consumed with `listen` rather than `window.addEventListener`. + void listen<{ channel?: string; data?: unknown }>( + "plugin:event", + (event) => { + if (event.payload?.channel === channel) { + listener(event.payload.data); + } + }, + ) + .then((off) => { + if (disposed) off(); + else unlisten = off; + }) + .catch((err) => { + console.error( + `Failed to listen for plugin events on ${channel}:`, + err, + ); + }); + + return () => { + disposed = true; + unlisten?.(); + unlisten = undefined; + }; + } + + // Browser/dev fallback: a host harness can dispatch a DOM CustomEvent. + 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); + }; + } +} diff --git a/desktop/main/internal/plugins/launch-pipeline.ts b/desktop/main/internal/plugins/launch-pipeline.ts new file mode 100644 index 000000000..e2fc167c9 --- /dev/null +++ b/desktop/main/internal/plugins/launch-pipeline.ts @@ -0,0 +1,98 @@ +import type { LaunchContext, LaunchHook, LaunchStage } from "./types"; + +/** + * Pure launch-hook helpers backing `ClientPluginManager.executeLaunchPipeline`. + * Kept separate from the manager so the pipeline can be unit tested without + * constructing a full plugin host. + */ + +export function sortHooks( + stages: LaunchStage[], + hooks: LaunchHook[], +): LaunchHook[] { + return hooks + .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); + }); +} + +export function 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}`); + } +} + +export async function 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, + ); + rollbackCompletedStages(completedHooks); + throw new Error( + `Launch aborted during stage '${hook.stage}': ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ); + } + } +} + +export async function 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. + */ +export async function executeLaunchPipeline( + hooks: LaunchHook[], + context: LaunchContext, + launchFn: () => Promise, +): Promise { + const preLaunchStages: LaunchStage[] = [ + "pre-launch:validate", + "pre-launch:prepare", + "pre-launch:stage", + "pre-launch:network", + "pre-launch:network-post", + ]; + + const postExitStages: LaunchStage[] = [ + "post-exit:cleanup", + "post-exit:restore", + "post-exit:sync", + ]; + + await runPreLaunchPipeline(sortHooks(preLaunchStages, hooks), context); + const launchResult = await launchFn(); + await runPostExitPipeline(sortHooks(postExitStages, hooks), context); + + return launchResult; +} diff --git a/desktop/main/internal/plugins/storeImport.ts b/desktop/main/internal/plugins/storeImport.ts new file mode 100644 index 000000000..38a5cf510 --- /dev/null +++ b/desktop/main/internal/plugins/storeImport.ts @@ -0,0 +1,32 @@ +import type { ScannedGame, StoreScanner } from "./types"; + +export interface StoreImportResult { + games: ScannedGame[]; + failures: Array<{ store: string; error: string }>; +} + +/** + * Runs every plugin-registered `StoreScanner` SPI and aggregates the results. + * A scanner that throws is recorded as a failure and does not abort the scan, + * so one broken store integration cannot block a library import. + */ +export async function collectStoreGames( + scanners: StoreScanner[], +): Promise { + const games: ScannedGame[] = []; + const failures: Array<{ store: string; error: string }> = []; + + for (const scanner of scanners) { + try { + const discovered = await scanner.scan(); + games.push(...discovered); + } catch (error) { + failures.push({ + store: scanner.store, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { games, failures }; +} diff --git a/desktop/main/internal/plugins/types.ts b/desktop/main/internal/plugins/types.ts new file mode 100644 index 000000000..964d86c0e --- /dev/null +++ b/desktop/main/internal/plugins/types.ts @@ -0,0 +1 @@ +export type * from "@drop/plugin-api"; diff --git a/desktop/main/nuxt.config.ts b/desktop/main/nuxt.config.ts index 72098882c..ee1aa191e 100644 --- a/desktop/main/nuxt.config.ts +++ b/desktop/main/nuxt.config.ts @@ -1,5 +1,7 @@ // https://nuxt.com/docs/api/configuration/nuxt-config -export default defineNuxtConfig({ +import type { NuxtConfig } from "nuxt/schema"; + +export default { compatibilityDate: "2024-04-03", postcss: { @@ -18,5 +20,10 @@ export default defineNuxtConfig({ app: { baseURL: "/main", - } -}); + head: { + meta: [ + { name: "viewport", content: "width=device-width, initial-scale=1.0" }, + ], + }, + }, +} satisfies NuxtConfig; diff --git a/desktop/main/package.json b/desktop/main/package.json index 64032752b..6ff9c0122 100644 --- a/desktop/main/package.json +++ b/desktop/main/package.json @@ -8,9 +8,11 @@ "dev": "nuxt dev", "postinstall": "nuxt prepare", "tauri": "tauri", - "typecheck": "nuxt typecheck" + "typecheck": "nuxt typecheck", + "test": "node --test --import jiti/register internal/**/__tests__/*.test.ts" }, "dependencies": { + "@drop/plugin-api": "file:../../libraries/plugin-api", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@nuxtjs/tailwindcss": "^6.12.2", @@ -32,6 +34,7 @@ "@tailwindcss/typography": "^0.5.15", "@types/markdown-it": "^14.1.2", "autoprefixer": "^10.4.20", + "jiti": "^2.7.0", "postcss": "^8.5.18", "sass-embedded": "^1.79.4", "tailwindcss": "^3.4.13", 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 + + + installFlow()" @launch="() => launch()" + @play-action="(action) => executeCustomPlayAction(action)" @queue="() => queue()" @uninstall="() => uninstall()" @kill="() => kill()" @options="() => (configureModalOpen = true)" @resume="() => resumeDownload()" - :status="status" /> Store + + + + + + + + Multiplayer + + + + @@ -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... - + - + (installDepsDisabled[content.versionId] = @@ -492,19 +533,19 @@ install()" :disabled="!(versionOptions && versionOptions.length > 0)" :loading="installLoading" type="submit" class="ml-2 w-full sm:w-fit" + @click="() => install()" > Install Cancel @@ -528,8 +569,9 @@ - + launchIndex(launchIdx)" > @@ -543,10 +585,10 @@ Cancel @@ -570,6 +612,15 @@ :game-id="game.id" /> + launch()" + @fallback="() => launchIndex(0)" + /> + + @@ -612,8 +670,7 @@ {{ currentImageIndex + 1 }} / @@ -641,6 +698,45 @@ v-if="dependencyRequiredModal" v-model="dependencyRequiredModal" /> + + + + + + + Extend {{ game.mName }} + + + + This game has no extensions installed. Optional features such as + multiplayer are provided by Drop plugins. + + + Open the Plugin Manager to browse the registry or install a + .dropplugin bundle. + + + + + + + + Open Plugin Manager + + + Close + + +
- {{ getGameStatusStyleText(games[item.id].status.value)[1] }} + {{ getGameStatusStyleText(games[item.id]!.status.value)[1] }}
(installDepsDisabled[content.versionId] = @@ -492,19 +533,19 @@ install()" :disabled="!(versionOptions && versionOptions.length > 0)" :loading="installLoading" type="submit" class="ml-2 w-full sm:w-fit" + @click="() => install()" > Install Cancel @@ -528,8 +569,9 @@
{{ currentImageIndex + 1 }} / @@ -641,6 +698,45 @@ v-if="dependencyRequiredModal" v-model="dependencyRequiredModal" /> + + + + + + + Extend {{ game.mName }} + + + + This game has no extensions installed. Optional features such as + multiplayer are provided by Drop plugins. + + + Open the Plugin Manager to browse the registry or install a + .dropplugin bundle. + + + + + + + + Open Plugin Manager + + + Close + + +
+ This game has no extensions installed. Optional features such as + multiplayer are provided by Drop plugins. +
+ Open the Plugin Manager to browse the registry or install a + .dropplugin bundle. +
.dropplugin