diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 0dee48b41..6b0804f1d 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -134,11 +134,6 @@ app.whenReady().then(async () => { build: async () => setupMcpStdioManager(), }) - const pluginHost = injeca.provide('modules:plugin-host', { - dependsOn: { serverChannel }, - build: () => setupPluginHost(), - }) - const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService()) // BeatSync will create a background window to capture and process audio. @@ -161,6 +156,11 @@ app.whenReady().then(async () => { build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn), }) + const pluginHost = injeca.provide('modules:plugin-host', { + dependsOn: { serverChannel, widgetsManager }, + build: ({ dependsOn }) => setupPluginHost({ widgetsManager: dependsOn.widgetsManager }), + }) + const aboutWindow = injeca.provide('windows:about', { dependsOn: { autoUpdater, i18n, serverChannel }, build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn), diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts index c3f34a8f1..70d1c63d1 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts @@ -27,11 +27,12 @@ export interface ExtensionStaticAssetServer extends ServerManager { } /** - * Creates the standalone extension static asset server. + * Creates the low-level extension static asset transport server. * * Use when: * - Main process must serve plugin iframe assets via local loopback HTTP * - Tokenized auth is required for all plugin asset requests + * - A higher-level plugin asset service needs an HTTP transport adapter * * Expects: * - `getManifestEntryByName` returns up-to-date plugin root/version map diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts index b1ee76410..f543281a0 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts @@ -23,7 +23,7 @@ function normalizePathPrefix(pathPrefix: string) { return '' } - return normalized.endsWith('/') ? normalized : `${normalized}/` + return normalized } function createOpaqueToken() { @@ -103,8 +103,15 @@ export function createExtensionAssetTokenStore(options: { now?: () => number } = return unauthorized('EXTENSION_ASSET_PATH_EMPTY', 'asset path is empty') } - if (record.pathPrefix && !normalizedAssetPath.startsWith(record.pathPrefix)) { - return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') + if (record.pathPrefix) { + const isDirectoryPrefix = record.pathPrefix.endsWith('/') + const isAllowed = isDirectoryPrefix + ? normalizedAssetPath.startsWith(record.pathPrefix) + : normalizedAssetPath === record.pathPrefix + + if (!isAllowed) { + return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') + } } return { ok: true } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts index 518935413..7c82c2578 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts @@ -10,6 +10,7 @@ import { parsePluginAssetRequestPath, resolvePluginAssetFilePath, } from './asset-mount' +import { resolveWidgetAssetRoute } from './kits/widget' describe('asset-mount', () => { const tempRoots: string[] = [] @@ -54,4 +55,26 @@ describe('asset-mount', () => { await expect(resolvePluginAssetFilePath(root, 'dist/ui/index.html')).resolves.toContain('dist/ui/index.html') await expect(resolvePluginAssetFilePath(root, '../outside.txt')).resolves.toBeUndefined() }) + + it('derives widget route asset path and token prefix with /ui semantics', () => { + expect(resolveWidgetAssetRoute('./ui/index.html')).toEqual({ + routeAssetPath: 'index.html', + tokenPathPrefix: 'index.html', + }) + + expect(resolveWidgetAssetRoute('ui/index.html')).toEqual({ + routeAssetPath: 'index.html', + tokenPathPrefix: 'index.html', + }) + + expect(resolveWidgetAssetRoute('ui/assets/index.html')).toEqual({ + routeAssetPath: 'assets/index.html', + tokenPathPrefix: 'assets/', + }) + + expect(resolveWidgetAssetRoute('assets/index.html')).toEqual({ + routeAssetPath: 'assets/index.html', + tokenPathPrefix: 'assets/', + }) + }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts new file mode 100644 index 000000000..806e8e3c2 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts @@ -0,0 +1,118 @@ +import type { ExtensionStaticAssetManifestEntry } from '../../http-server/http/extension-static-assets' +import type { ServerManager } from '../../http-server/server-manager/types' + +import { createExtensionStaticAssetServer } from '../../http-server/http/extension-static-assets' + +/** + * Describes one plugin asset access token issuance request. + * + * Use when: + * - A plugin-owned asset URL must be mounted behind the local loopback server + * - Snapshot builders need a transport-agnostic way to authorize one plugin asset route + * + * Expects: + * - `pluginId` matches a manifest entry registered in the asset host + * - `pathPrefix` is scoped to the mounted route prefix accepted by the token store + * + * Returns: + * - N/A + */ +export interface PluginAssetAccessTokenInput { + pluginId: string + version: string + sessionId: string + pathPrefix: string + ttlMs: number +} + +/** + * Describes the plugin asset methods needed while building renderer-facing snapshots. + * + * Use when: + * - Snapshot builders must request route-scoped asset tokens without depending on HTTP server internals + * - Host bootstrap wants to layer caching or policy on top of the raw asset transport + * + * Expects: + * - `routeAssetPath` identifies the mounted asset file being exposed in the snapshot + * - Implementations may use `routeAssetPath` for caching even if the transport ignores it + * + * Returns: + * - N/A + */ +export interface PluginAssetSnapshotService { + getBaseUrl: () => string | undefined + issueAccessToken: (input: { + pluginId: string + version: string + sessionId: string + routeAssetPath: string + pathPrefix: string + }) => string +} + +/** + * Defines the plugin-owned asset hosting service used by the plugin host. + * + * Use when: + * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape + * - Host teardown must revoke plugin asset access independently from widget/gamelet logic + * + * Expects: + * - Implementations own the underlying transport and token lifecycle + * + * Returns: + * - A startable/stoppable asset-hosting service with generic plugin-facing methods + */ +export interface PluginAssetService extends ServerManager { + getBaseUrl: () => string | undefined + issueAccessToken: (input: PluginAssetAccessTokenInput) => string + revokeByPluginId: (pluginId: string) => void + revokeAll: () => void +} + +/** + * Creates the plugin asset host service backed by the extension static asset server. + * + * Use when: + * - The plugin host needs to expose mounted asset URLs to renderer snapshots + * - Asset token lifecycle should stay inside the plugin domain instead of the HTTP server layer + * + * Expects: + * - `getManifestEntryByName` returns the latest plugin root/version map + * + * Returns: + * - A plugin-facing asset host service with generic plugin asset methods + */ +export function createPluginAssetService(options: { + getManifestEntryByName: () => Map +}): PluginAssetService { + const server = createExtensionStaticAssetServer(options) + + return { + key: 'plugin-assets', + async start() { + await server.start() + }, + async stop() { + await server.stop() + }, + getBaseUrl() { + return server.getBaseUrl() + }, + issueAccessToken(input) { + return server.issueToken({ + extensionId: input.pluginId, + version: input.version, + sessionId: input.sessionId, + pathPrefix: input.pathPrefix, + ttlMs: input.ttlMs, + }) + }, + revokeByPluginId(pluginId) { + server.revokeByExtensionId(pluginId) + }, + revokeAll() { + server.revokeAll() + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md index 73bfa596a..a7a722f1a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/README.md @@ -4,7 +4,7 @@ This sample plugin is for validating plugin host behavior in the **Plugin Host I ## Files -- `devtools-sample-plugin.json`: plugin manifest (`ManifestV1`) +- `plugin.airi.json`: plugin manifest (`ManifestV1`) - `devtools-sample-plugin.mjs`: plugin implementation The manifest declares the protocol permissions required by `apis.providers.listProviders()`: invoke `capabilities:wait`, invoke `resources:providers:list-providers`, read the provider resource, and wait for the provider-list capability. diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json b/apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/plugin.airi.json similarity index 100% rename from apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/devtools-sample-plugin.json rename to apps/stage-tamagotchi/src/main/services/airi/plugins/examples/devtools-sample-plugin/plugin.airi.json diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts new file mode 100644 index 000000000..0691ddeda --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/auto-reload/index.ts @@ -0,0 +1,161 @@ +import type { FSWatcher } from 'node:fs' + +import type { useLogg } from '@guiiai/logg' + +import type { ManifestEntry, PluginConfig } from '../../types' + +import { watch as watchFile } from 'node:fs' + +/** + * Declares the host-owned callbacks needed by the plugin auto-reload feature. + * + * Use when: + * - Installing the optional auto-reload feature into the Electron plugin host + * - Keeping file-watcher ownership outside the core host bootstrap + * + * Expects: + * - `reload` unloads, refreshes, and loads the named plugin + * - `resolveWatchPaths` returns stable absolute file paths for the plugin + * - `getConfig`, `listEntries`, and `isLoaded` always reflect current host state + * + * Returns: + * - N/A + */ +export interface PluginAutoReloadFeatureOptions { + log: ReturnType + getConfig: () => PluginConfig + listEntries: () => ManifestEntry[] + isLoaded: (name: string) => boolean + resolveWatchPaths: (name: string) => string[] + reload: (name: string, changedPath: string) => Promise +} + +/** + * Manages optional plugin auto-reload watchers and debounce timers. + * + * Use when: + * - The Electron plugin host wants manifest and entrypoint file watching as an installable feature + * - Host bootstrap should delegate watcher lifecycle and reload scheduling out of `host/index.ts` + * + * Expects: + * - Call `sync()` after registry/config/load-state changes + * - Call `clearPlugin(name)` before unloading or disabling a plugin + * - Call `dispose()` during host shutdown + * + * Returns: + * - The installed auto-reload feature controller + */ +export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOptions) { + const autoReloadInFlight = new Set() + const autoReloadTimers = new Map>() + const autoReloadWatchers = new Map() + + const clearTimer = (name: string) => { + const timer = autoReloadTimers.get(name) + if (!timer) { + return + } + + clearTimeout(timer) + autoReloadTimers.delete(name) + } + + const closeWatchers = (name: string) => { + const watchers = autoReloadWatchers.get(name) + if (!watchers) { + return + } + + for (const watcher of watchers) { + watcher.close() + } + + autoReloadWatchers.delete(name) + } + + const reloadPluginByName = async (name: string, changedPath: string) => { + if (autoReloadInFlight.has(name)) { + return + } + + autoReloadInFlight.add(name) + try { + await options.reload(name, changedPath) + options.log.log('plugin auto-reloaded after file change', { plugin: name, path: changedPath }) + } + catch (error) { + options.log.withError(error).withFields({ plugin: name, path: changedPath }).error('plugin auto-reload failed') + } + finally { + autoReloadInFlight.delete(name) + } + } + + const scheduleReload = (name: string, changedPath: string) => { + clearTimer(name) + autoReloadTimers.set(name, setTimeout(() => { + autoReloadTimers.delete(name) + void reloadPluginByName(name, changedPath) + }, 180)) + } + + return { + sync() { + const enabledNames = new Set(options.getConfig().autoReload) + const desiredNames = new Set(options.listEntries() + .map(entry => entry.manifest.name) + .filter(name => enabledNames.has(name) && options.isLoaded(name))) + + for (const name of autoReloadWatchers.keys()) { + if (!desiredNames.has(name)) { + clearTimer(name) + closeWatchers(name) + } + } + + for (const name of desiredNames) { + if (autoReloadWatchers.has(name)) { + continue + } + + const watchPaths = options.resolveWatchPaths(name) + if (watchPaths.length === 0) { + continue + } + + const watchers: FSWatcher[] = [] + for (const watchPath of watchPaths) { + try { + const watcher = watchFile(watchPath, { persistent: false }, () => scheduleReload(name, watchPath)) + watcher.on('error', (error) => { + options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('plugin auto-reload watcher error') + }) + watchers.push(watcher) + } + catch (error) { + options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('failed to watch plugin file for auto-reload') + } + } + + if (watchers.length > 0) { + autoReloadWatchers.set(name, watchers) + } + } + }, + clearPlugin(name: string) { + clearTimer(name) + closeWatchers(name) + }, + dispose() { + const managedNames = new Set([ + ...autoReloadTimers.keys(), + ...autoReloadWatchers.keys(), + ]) + + for (const name of managedNames) { + clearTimer(name) + closeWatchers(name) + } + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts new file mode 100644 index 000000000..12e02ab4a --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/config.ts @@ -0,0 +1,72 @@ +import type { PluginConfig } from '../types' + +import { array, object, record, string } from 'valibot' + +import { createConfig } from '../../../../libs/electron/persistence' + +const pluginConfigSchema = object({ + enabled: array(string()), + autoReload: array(string()), + known: record(string(), object({ + path: string(), + })), +}) + +function createDefaultPluginConfig(): PluginConfig { + return { + enabled: [], + autoReload: [], + known: {}, + } +} + +/** + * Persists plugin host enablement and discovery metadata. + * + * Use when: + * - Bootstrapping the Electron plugin host + * - Reading or updating `plugins-v1.json` state + * + * Expects: + * - `setup()` runs before `get()` or `update()` + * - Consumers write complete `PluginConfig` snapshots + * + * Returns: + * - Accessors around the persisted plugin config document + */ +export interface PluginHostConfigStore { + setup: () => void + get: () => PluginConfig + update: (config: PluginConfig) => void +} + +/** + * Creates the persisted config store used by the plugin host bootstrap. + * + * Use when: + * - Host bootstrap modules need config persistence without inlining schema setup + * + * Expects: + * - Electron `app.getPath('userData')` is available through the persistence layer + * + * Returns: + * - A small config store that always falls back to the default plugin config + */ +export function createPluginHostConfigStore(): PluginHostConfigStore { + const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, { + default: createDefaultPluginConfig(), + autoHeal: true, + }) + + return { + setup() { + pluginConfig.setup() + }, + get() { + return pluginConfig.get() ?? createDefaultPluginConfig() + }, + update(config) { + pluginConfig.update(config) + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts new file mode 100644 index 000000000..21b30ce1b --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts @@ -0,0 +1,85 @@ +import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' + +import type { + PluginHostDebugSnapshot, + PluginHostModuleSummary, +} from '../../../../../shared/eventa/plugin/host' +import type { PluginAssetSnapshotService } from '../assets' +import type { ManifestEntry, PluginConfig } from '../types' + +import { rewriteWidgetModuleAssetUrl } from '../kits/widget' +import { buildPluginRegistrySnapshot } from './registry' + +/** + * Builds the debug snapshot exposed by the Electron plugin host inspector. + * + * Use when: + * - Renderer devtools need sessions, kits, modules, and capability state + * - Widget iframe asset URLs must be rewritten to mounted plugin asset URLs + * + * Expects: + * - `host` is the initialized plugin host instance + * - `manifestEntryByName` contains entries for any plugin-owned modules being inspected + * - `pluginAssetService` owns plugin asset URL/token lifecycle when mounted asset URLs are needed + * + * Returns: + * - A full debug snapshot with registry, sessions, kits, modules, and capabilities + */ +export function buildPluginHostDebugSnapshot(options: { + host: PluginHost + pluginsRoot: string + entries: ManifestEntry[] + config: PluginConfig + loaded: Set + manifestEntryByName: Map + pluginAssetService?: PluginAssetSnapshotService +}): PluginHostDebugSnapshot { + const pluginAssetService = options.pluginAssetService + + return { + registry: buildPluginRegistrySnapshot({ + pluginsRoot: options.pluginsRoot, + entries: options.entries, + config: options.config, + loaded: options.loaded, + }), + sessions: options.host.listSessions().map(session => ({ + id: session.id, + manifestName: session.manifest.name, + phase: session.phase, + runtime: session.runtime, + moduleId: session.identity.id, + })), + kits: options.host.listKits(), + modules: options.host + .listBindings() + .map(module => + rewriteWidgetModuleAssetUrl( + module as PluginHostModuleSummary, + options.manifestEntryByName, + { + pluginAssetBaseUrl: pluginAssetService?.getBaseUrl(), + ...(pluginAssetService + ? { + issueAssetToken: ({ extensionId, version, sessionId, routeAssetPath, tokenPathPrefix }: { + extensionId: string + version: string + sessionId: string + routeAssetPath: string + tokenPathPrefix: string + }) => pluginAssetService.issueAccessToken({ + pluginId: extensionId, + version, + sessionId, + routeAssetPath, + pathPrefix: tokenPathPrefix, + }), + } + : {}), + }, + ), + ) as PluginHostDebugSnapshot['modules'], + capabilities: options.host.listCapabilities(), + refreshedAt: Date.now(), + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts new file mode 100644 index 000000000..fb75c8027 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts @@ -0,0 +1,481 @@ +import type { + PluginHostDebugSnapshot, + PluginRegistrySnapshot, +} from '../../../../../shared/eventa/plugin/host' +import type { PluginAssetSnapshotService } from '../assets' +import type { + PluginHostService, + SetupPluginHostOptions, +} from '../types' + +import { dirname, join } from 'node:path' + +import { useLogg } from '@guiiai/logg' +import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import { app } from 'electron' + +import { createPluginAssetService } from '../assets' +import { createPluginAutoReloadFeature } from '../features/auto-reload' +import { createBuiltInPluginKitRuntime } from '../kits' +import { createPluginHostConfigStore } from './config' +import { buildPluginHostDebugSnapshot } from './debug' +import { + buildPluginRegistrySnapshot, + createManifestForLoad, + createPluginHostRegistry, + resolvePluginRuntimeEntrypointPath, +} from './registry' + +const extensionAssetTokenTtlMs = 30 * 24 * 60 * 60 * 1000 + +/** + * Internal plugin host bootstrap service used by the public `setupPluginHost(...)` facade. + * + * Use when: + * - `plugins/index.ts` needs a smaller orchestration layer with the same caller-facing API + * - Host wiring should stay separate from config, registry, and snapshot helpers + * + * Expects: + * - Consumers treat this as an internal bootstrap surface and keep the public facade unchanged + * - `widgetsManager` is ready before startup begins + * + * Returns: + * - The plain `PluginHostService` fields plus internal helpers for list/load/unload/inspect/dispose + */ +export interface PluginHostHostService extends PluginHostService { + /** + * Lists the current plugin registry snapshot. + * + * Use when: + * - IPC callers need the latest discovered plugin entries and enablement state + * - Host operations need a refreshed renderer-facing registry view + * + * Expects: + * - Manifest discovery can be refreshed before the snapshot is built + * + * Returns: + * - The latest plugin registry snapshot for renderer consumption + */ + list: () => Promise + + /** + * Persists whether one plugin is enabled. + * + * Use when: + * - Renderer controls toggle plugin enablement + * - Host state must remember a known manifest path for a plugin name + * + * Expects: + * - `payload.name` matches a discovered or previously known plugin + * - `payload.path` is only needed when the manifest is not currently discoverable + * + * Returns: + * - The updated plugin registry snapshot after persistence + */ + setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise + + /** + * Persists whether one loaded plugin should use auto-reload. + * + * Use when: + * - Renderer controls toggle plugin file watching during development + * - Host features need to resync optional watcher state after config changes + * + * Expects: + * - `payload.name` matches one plugin entry in config or discovery state + * + * Returns: + * - The updated plugin registry snapshot after persistence + */ + setAutoReload: (payload: { name: string, enabled: boolean }) => Promise + + /** + * Loads every plugin currently marked as enabled. + * + * Use when: + * - App startup wants to restore persisted enabled plugins + * - Renderer requests a bulk load after configuration changes + * + * Expects: + * - Discovery state is current before load begins + * + * Returns: + * - The plugin registry snapshot after load attempts finish + */ + loadEnabled: () => Promise + + /** + * Loads one plugin by manifest name. + * + * Use when: + * - Renderer explicitly requests one plugin to start + * - Host features need to restart a plugin after manifest or entrypoint changes + * + * Expects: + * - `name` resolves to a manifest entry in the current registry + * + * Returns: + * - The plugin registry snapshot after the load completes + */ + load: (name: string) => Promise + + /** + * Stops one loaded plugin by manifest name. + * + * Use when: + * - Renderer explicitly requests one plugin to stop + * - Host features need to stop a plugin before reload or disposal + * + * Expects: + * - `name` identifies a plugin that may or may not currently be loaded + * + * Returns: + * - The plugin registry snapshot after unload bookkeeping completes + */ + unload: (name: string) => PluginRegistrySnapshot + + /** + * Builds the full plugin host debug snapshot. + * + * Use when: + * - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs + * - Host debugging needs a fresh runtime snapshot after registry refresh + * + * Expects: + * - The host and plugin asset service are both initialized + * + * Returns: + * - The full debug snapshot exposed through plugin inspection IPC + */ + inspect: () => Promise + + /** + * Returns the mounted base URL for plugin-served assets. + * + * Use when: + * - Renderer code needs to construct extension asset URLs + * - Snapshot consumers need the current loopback asset mount base + * + * Expects: + * - The plugin asset service may be started before this is called + * + * Returns: + * - The current plugin asset base URL, or an empty string when unavailable + */ + getAssetBaseUrl: () => string + + /** + * Disposes optional host features and asset hosting resources. + * + * Use when: + * - Electron shutdown needs to stop plugin-owned background work + * - Tests need to release watchers and local asset servers deterministically + * + * Expects: + * - Disposal may be called after partial startup or after prior plugin failures + * + * Returns: + * - A promise that resolves after feature and asset cleanup finish + */ + dispose: () => Promise +} + +/** + * Builds the extracted Electron plugin host bootstrap used by the public facade. + * + * Use when: + * - The public plugin service wants one internal bootstrap entrypoint + * - Tests need direct access to the internal host bootstrap helper + * + * Expects: + * - Electron `app.getPath('userData')` is available + * - Plugin manifests live under `/plugins/v1` + * + * Returns: + * - The internal bootstrap service that powers the public plugin-host IPC facade + */ +export async function setupPluginHostHostService( + options: SetupPluginHostOptions, +): Promise { + const log = useLogg('main/plugin-host').useGlobalConfig() + const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1') + + // Config + const pluginConfig = createPluginHostConfigStore() + pluginConfig.setup() + + // Kit API, Host + const builtInKitRuntime = createBuiltInPluginKitRuntime(options) + const host = new PluginHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions }) + builtInKitRuntime.attachHost(host) // reverse dependency injection + log.withFields({ pluginsRoot }).log('loading plugin manifests') + // Once kit injected the host, then apply kits + builtInKitRuntime.registerHostKits(host) + + // plugin registry + const pluginRegistry = createPluginHostRegistry({ pluginsRoot, log }) + + await pluginRegistry.refresh() + log.withFields({ count: pluginRegistry.listEntries().length }).log('plugin manifests loaded') + for (const entry of pluginRegistry.listEntries()) { + log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found') + } + + // Plugin feature: Static Assets serving + const pluginAssetService = createPluginAssetService({ + getManifestEntryByName: () => pluginRegistry.getManifestEntryByName(), + }) + await pluginAssetService.start() + + const loaded = new Set() + const loadedSessionIds = new Map() + const moduleAssetTokenCache = new Map() + + const refreshManifests = async () => { + await pluginRegistry.refresh() + } + + const getConfig = () => pluginConfig.get() + + const listSnapshot = (): PluginRegistrySnapshot => { + return buildPluginRegistrySnapshot({ + pluginsRoot, + entries: pluginRegistry.listEntries(), + config: getConfig(), + loaded, + }) + } + + const issueModuleAssetToken = (input: { + pluginId: string + version: string + sessionId: string + routeAssetPath: string + pathPrefix: string + }) => { + const { pluginId, version, sessionId, routeAssetPath, pathPrefix } = input + const cacheKey = `${pluginId}:${version}:${sessionId}:${routeAssetPath}` + const cachedToken = moduleAssetTokenCache.get(cacheKey) + if (cachedToken) { + return cachedToken + } + + const token = pluginAssetService.issueAccessToken({ + pluginId, + version, + sessionId, + pathPrefix, + ttlMs: extensionAssetTokenTtlMs, + }) + moduleAssetTokenCache.set(cacheKey, token) + return token + } + + const pluginAssetSnapshotService: PluginAssetSnapshotService = { + getBaseUrl: pluginAssetService.getBaseUrl, + issueAccessToken: ({ pluginId, version, sessionId, routeAssetPath, pathPrefix }) => { + return issueModuleAssetToken({ + pluginId, + version, + sessionId, + routeAssetPath, + pathPrefix, + }) + }, + } + + const inspectSnapshot = (): PluginHostDebugSnapshot => { + return buildPluginHostDebugSnapshot({ + host, + pluginsRoot, + entries: pluginRegistry.listEntries(), + config: getConfig(), + loaded, + manifestEntryByName: pluginRegistry.getManifestEntryByName(), + pluginAssetService: pluginAssetSnapshotService, + }) + } + + const loadPluginByName = async ( + name: string, + loadOptions: { cacheBustKey?: string } = {}, + ) => { + if (loaded.has(name)) { + return + } + + const entry = pluginRegistry.findManifestEntry(name) + if (!entry) { + throw new Error(`Plugin manifest not found: ${name}`) + } + + const manifestForLoad = createManifestForLoad(entry, loadOptions) + const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) }) + loaded.add(name) + loadedSessionIds.set(name, session.id) + log.log('plugin loaded', { plugin: name, sessionId: session.id }) + } + + const stopLoadedPluginByName = (name: string) => { + const sessionId = loadedSessionIds.get(name) + if (!sessionId) { + loaded.delete(name) + return + } + + host.stop(sessionId) + loadedSessionIds.delete(name) + loaded.delete(name) + + for (const key of moduleAssetTokenCache.keys()) { + if (key.startsWith(`${name}:`)) { + moduleAssetTokenCache.delete(key) + } + } + + log.log('plugin unloaded', { plugin: name, sessionId }) + } + + const resolveAutoReloadWatchPaths = (name: string) => { + const entry = pluginRegistry.findManifestEntry(name) + if (!entry) { + return [] + } + + const entrypointPath = resolvePluginRuntimeEntrypointPath(entry) + return [...new Set([entry.path, entrypointPath].filter((path): path is string => Boolean(path)))] + } + + // Plugin feature: Auto-reload for plugins + const autoReloadFeature = createPluginAutoReloadFeature({ + log, + getConfig, + listEntries: () => pluginRegistry.listEntries(), + isLoaded: name => loaded.has(name), + resolveWatchPaths: resolveAutoReloadWatchPaths, + reload: async (name) => { + stopLoadedPluginByName(name) + await refreshManifests() + await loadPluginByName(name, { cacheBustKey: `auto-reload-${Date.now()}` }) + }, + }) + + const unloadPluginByName = (name: string) => { + autoReloadFeature.clearPlugin(name) + stopLoadedPluginByName(name) + } + + const loadEnabledPlugins = async () => { + const config = getConfig() + for (const entry of pluginRegistry.listEntries()) { + const name = entry.manifest.name + if (!config.enabled.includes(name)) { + continue + } + if (loaded.has(name)) { + continue + } + + try { + await loadPluginByName(name) + } + catch (error) { + log.withError(error).withFields({ plugin: name }).error('plugin failed to start') + } + } + + autoReloadFeature.sync() + } + + await refreshManifests() + await loadEnabledPlugins() + autoReloadFeature.sync() + + return { + host, + manifests: pluginRegistry.listManifests(), + async list() { + await refreshManifests() + autoReloadFeature.sync() + return listSnapshot() + }, + async setEnabled(payload) { + await refreshManifests() + + const config = getConfig() + const enabled = new Set(config.enabled) + if (payload.enabled) { + enabled.add(payload.name) + } + else { + enabled.delete(payload.name) + pluginAssetService.revokeByPluginId(payload.name) + } + + const entry = pluginRegistry.findManifestEntry(payload.name) + const manifestPath = entry?.path ?? payload.path ?? '' + pluginConfig.update({ + enabled: [...enabled], + autoReload: config.autoReload, + known: { + ...config.known, + [payload.name]: { path: manifestPath }, + }, + }) + + autoReloadFeature.sync() + return listSnapshot() + }, + async setAutoReload(payload) { + await refreshManifests() + + const config = getConfig() + const autoReload = new Set(config.autoReload) + if (payload.enabled) { + autoReload.add(payload.name) + } + else { + autoReload.delete(payload.name) + } + + pluginConfig.update({ + ...config, + autoReload: [...autoReload], + }) + + autoReloadFeature.sync() + return listSnapshot() + }, + async loadEnabled() { + await refreshManifests() + await loadEnabledPlugins() + autoReloadFeature.sync() + return listSnapshot() + }, + async load(name) { + await refreshManifests() + await loadPluginByName(name) + autoReloadFeature.sync() + return listSnapshot() + }, + unload(name) { + unloadPluginByName(name) + autoReloadFeature.sync() + return listSnapshot() + }, + async inspect() { + await refreshManifests() + autoReloadFeature.sync() + return inspectSnapshot() + }, + getAssetBaseUrl() { + return pluginAssetService.getBaseUrl() ?? '' + }, + async dispose() { + autoReloadFeature.dispose() + + pluginAssetService.revokeAll() + await pluginAssetService.stop() + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts new file mode 100644 index 000000000..26a714a44 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/registry.ts @@ -0,0 +1,349 @@ +import type { Dirent } from 'node:fs' + +import type { useLogg } from '@guiiai/logg' +import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' + +import type { + PluginManifestSummary, + PluginRegistrySnapshot, +} from '../../../../../shared/eventa/plugin/host' +import type { ManifestEntry, PluginConfig } from '../types' + +import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +import { manifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host' +import { safeParse } from 'valibot' + +export const pluginManifestFileName = 'plugin.airi.json' + +function isManifestV1(value: unknown): value is ManifestV1 { + return safeParse(manifestV1Schema, value).success +} + +async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { + if (!entry.isSymbolicLink()) { + return { resolved: false } + } + + try { + const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) + const stats = await stat(resolvedPath) + if (stats.isFile() || stats.isDirectory()) { + return { resolved: true, path: resolvedPath } + } + + return { resolved: false } + } + catch (error) { + return { resolved: false, error } + } +} + +/** + * Loads plugin manifests from plugin subdirectories under the configured root. + * + * Use when: + * - Refreshing the plugin registry state from disk + * - Resolving symlink-backed plugin directories before manifest parsing + * + * Expects: + * - Root directory may not exist yet + * - Each plugin is nested under its own child directory + * - Each plugin directory may include `plugin.airi.json` and optional `package.json` + * + * Returns: + * - Array of validated manifest entries with resolved paths and version metadata + */ +export async function loadManifestsFrom( + dir: string, + log: ReturnType, +): Promise { + await mkdir(dir, { recursive: true }) + const entries = await readdir(dir, { withFileTypes: true }) + const manifests: ManifestEntry[] = [] + const manifestPaths: Array<{ path: string, rootDir: string }> = [] + + for (const entry of entries) { + if (!entry.isDirectory()) { + if (entry.isSymbolicLink()) { + const { resolved, error } = await realPathOf(entry, { cwd: dir }) + if (error) { + log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping') + continue + } + if (!resolved) { + log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') + continue + } + } + else { + continue + } + } + + let pluginDir = join(dir, entry.name) + if (entry.isSymbolicLink()) { + const { path, resolved } = await realPathOf(entry, { cwd: dir }) + if (resolved) { + pluginDir = path + } + else { + log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') + continue + } + } + + const pluginEntries = await readdir(pluginDir, { withFileTypes: true }) + const manifestEntry = pluginEntries.find(candidate => candidate.name === pluginManifestFileName) + if (!manifestEntry) { + continue + } + + const manifestPath = join(pluginDir, pluginManifestFileName) + if (manifestEntry.isFile()) { + manifestPaths.push({ path: manifestPath, rootDir: pluginDir }) + continue + } + if (!manifestEntry.isSymbolicLink()) { + continue + } + + try { + const resolvedPath = await realpath(manifestPath) + const stats = await stat(resolvedPath) + if (!stats.isFile()) { + continue + } + manifestPaths.push({ path: manifestPath, rootDir: pluginDir }) + } + catch (error) { + log.withError(error).withFields({ name: manifestEntry.name }).warn('failed to resolve symlink, skipping') + } + } + + for (const manifestPath of manifestPaths) { + try { + const raw = await readFile(manifestPath.path, 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (!isManifestV1(parsed)) { + log.warn('invalid plugin manifest schema', { path: manifestPath.path }) + continue + } + + let version = '0.0.0' + try { + const packageJsonRaw = await readFile(join(manifestPath.rootDir, 'package.json'), 'utf-8') + const packageJson = JSON.parse(packageJsonRaw) as Record + if (typeof packageJson.version === 'string' && packageJson.version.trim()) { + version = packageJson.version.trim() + } + } + catch { + // Ignore package.json read failures; plugin manifests without package metadata + // still load with a deterministic fallback version. + } + + manifests.push({ + manifest: parsed, + path: manifestPath.path, + rootDir: manifestPath.rootDir, + version, + }) + } + catch (error) { + log.withError(error).withFields({ path: manifestPath.path }).error('failed to read plugin manifest') + } + } + + return manifests +} + +/** + * Builds a renderer-facing plugin summary from manifest, config, and runtime state. + * + * Use when: + * - Registry snapshots need one UI-friendly entry per discovered plugin + * + * Expects: + * - `entry` corresponds to a currently discovered manifest + * - `config` is the latest persisted plugin config + * - `loaded` tracks currently running plugin names + * + * Returns: + * - Stable manifest summary for UI consumption + */ +export function createPluginSummary( + entry: ManifestEntry, + config: PluginConfig, + loaded: Set, +): PluginManifestSummary { + const name = entry.manifest.name + return { + name, + entrypoints: entry.manifest.entrypoints, + path: entry.path, + enabled: config.enabled.includes(name), + autoReload: config.autoReload.includes(name), + loaded: loaded.has(name), + isNew: !config.known[name], + } +} + +/** + * Builds the renderer-facing plugin registry snapshot. + * + * Use when: + * - IPC clients request the plugin list + * - Internal host operations need a fresh registry view after config or load changes + * + * Expects: + * - `entries`, `config`, and `loaded` come from the latest in-memory host state + * + * Returns: + * - A stable registry snapshot for renderer consumption + */ +export function buildPluginRegistrySnapshot(options: { + pluginsRoot: string + entries: ManifestEntry[] + config: PluginConfig + loaded: Set +}): PluginRegistrySnapshot { + return { + root: options.pluginsRoot, + plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)), + } +} + +/** + * Resolves the absolute runtime entrypoint path used by load and auto-reload flows. + * + * Use when: + * - File watching needs the runtime entrypoint path + * - Host loading needs to reason about the resolved runtime file + * + * Expects: + * - Entrypoint is either absolute or relative to the manifest directory + * + * Returns: + * - Absolute file path when entrypoint exists; otherwise `undefined` + */ +export function resolvePluginRuntimeEntrypointPath(entry: ManifestEntry): string | undefined { + const entrypoint = entry.manifest.entrypoints.electron ?? entry.manifest.entrypoints.default + if (!entrypoint) { + return undefined + } + + const manifestDir = dirname(entry.path) + return isAbsolute(entrypoint) ? entrypoint : resolve(manifestDir, entrypoint) +} + +function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string { + const delimiter = entrypoint.includes('?') ? '&' : '?' + return `${entrypoint}${delimiter}cacheBust=${encodeURIComponent(cacheBustKey)}` +} + +/** + * Produces the manifest used for runtime loading, optionally with a cache-busted entrypoint. + * + * Use when: + * - Loading a plugin normally + * - Reloading a plugin after file changes to avoid stale module cache + * + * Expects: + * - `cacheBustKey` is omitted for standard loads + * - `cacheBustKey` is deterministic enough for one reload cycle when provided + * + * Returns: + * - Original manifest or cloned manifest with cache-busted runtime entrypoint + */ +export function createManifestForLoad( + entry: ManifestEntry, + options: { cacheBustKey?: string }, +): ManifestV1 { + if (!options.cacheBustKey) { + return entry.manifest + } + + const manifest = structuredClone(entry.manifest) + if (manifest.entrypoints.electron) { + manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey) + } + else if (manifest.entrypoints.default) { + manifest.entrypoints.default = appendCacheBustKey(manifest.entrypoints.default, options.cacheBustKey) + } + return manifest +} + +/** + * Tracks the manifest registry state used by the Electron plugin host. + * + * Use when: + * - Refreshing plugin manifests from disk + * - Looking up manifests by plugin name during load or inspect operations + * + * Expects: + * - `refresh()` is called before consumers read entries or manifests + * - `pluginsRoot` points at the plugin manifest root under user data + * + * Returns: + * - Read access to the current manifest entries, manifest list, and lookup map + */ +export interface PluginHostRegistry { + getRoot: () => string + refresh: () => Promise + listEntries: () => ManifestEntry[] + listManifests: () => ManifestV1[] + findManifestEntry: (name: string) => ManifestEntry | undefined + getManifestEntryByName: () => Map +} + +/** + * Creates the manifest registry store used by the plugin host bootstrap. + * + * Use when: + * - Host bootstrap needs in-memory manifest lookup and refresh operations + * + * Expects: + * - `log` is the plugin-host logger used for manifest loading diagnostics + * + * Returns: + * - A registry wrapper around the current manifest entry array and lookup map + */ +export function createPluginHostRegistry(options: { + pluginsRoot: string + log: ReturnType +}): PluginHostRegistry { + let entries: ManifestEntry[] = [] + let manifests: ManifestV1[] = [] + let manifestEntryByName = new Map() + + return { + getRoot() { + return options.pluginsRoot + }, + async refresh() { + entries = await loadManifestsFrom(options.pluginsRoot, options.log) + manifestEntryByName = new Map() + for (const entry of entries) { + if (!manifestEntryByName.has(entry.manifest.name)) { + manifestEntryByName.set(entry.manifest.name, entry) + } + } + manifests = entries.map(entry => entry.manifest) + return entries + }, + listEntries() { + return entries + }, + listManifests() { + return manifests + }, + findManifestEntry(name) { + return manifestEntryByName.get(name) + }, + getManifestEntryByName() { + return manifestEntryByName + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 5e997ac5a..d79dfea59 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -1,24 +1,58 @@ import type { createContext } from '@moeru/eventa' +import type { + BindingRecord, + HostDataRecord, + ManifestV1, + ModulePermissionDeclaration, +} from '@proj-airi/plugin-sdk/plugin-host' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import type { WidgetsAddPayload, WidgetSnapshot, WidgetsUpdatePayload } from '../../../../shared/eventa' +import type { PluginHostService } from './types' + +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { defineInvoke } from '@moeru/eventa' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { + electronPluginGetAssetBaseUrl, +} from '../../../../shared/eventa/plugin/assets' +import { + electronPluginUpdateCapability, +} from '../../../../shared/eventa/plugin/capabilities' import { electronPluginInspect, electronPluginList, electronPluginLoadEnabled, + electronPluginSetAutoReload, electronPluginSetEnabled, - electronPluginUpdateCapability, -} from '../../../../shared/eventa' -import { setupPluginHost } from './index' + electronPluginUnload, +} from '../../../../shared/eventa/plugin/host' +import { + electronPluginInvokeTool, + electronPluginListAgentTools, + electronPluginListXsaiTools, +} from '../../../../shared/eventa/plugin/tools' +import { setupPluginHostHostService } from './host' +import { setupPluginHost as setupPluginHostService } from './index' +import { + gameletPluginKitDescriptor, + pluginGameletApiCloseEventName, + pluginGameletApiConfigureEventName, + pluginGameletApiIsOpenEventName, + pluginGameletApiOpenEventName, +} from './kits/gamelet' +import { widgetPluginKitDescriptor } from './kits/widget' const appMock = vi.hoisted(() => ({ getPath: vi.fn(), })) +const protocolMock = vi.hoisted(() => ({ + handle: vi.fn(), +})) const contextState = vi.hoisted(() => ({ lastContext: undefined as ReturnType> | undefined, })) @@ -26,6 +60,7 @@ const contextState = vi.hoisted(() => ({ vi.mock('electron', () => ({ app: appMock, ipcMain: {}, + protocol: protocolMock, })) vi.mock('@moeru/eventa/adapters/electron/main', async () => { @@ -54,23 +89,40 @@ const testDataRoot = resolve( 'plugin-host', 'testdata', ) +const repoRoot = resolve( + import.meta.dirname, + '..', + '..', + '..', + '..', + '..', + '..', + '..', +) const samplePluginRoot = resolve( import.meta.dirname, 'examples', 'devtools-sample-plugin', ) +const chessLikePluginRoot = resolve( + repoRoot, + 'plugins', + 'airi-plugin-game-chess', +) +const pluginManifestFileName = 'plugin.airi.json' async function writeManifest(params: { dir: string, name: string, entrypoint: string }) { const manifest = { apiVersion: 'v1', kind: 'manifest.plugin.airi.moeru.ai', name: params.name, + permissions: {}, entrypoints: { electron: params.entrypoint, }, } - const path = join(params.dir, `${params.name}.json`) + const path = join(params.dir, pluginManifestFileName) await writeFile(path, JSON.stringify(manifest, null, 2)) return path } @@ -102,10 +154,224 @@ async function writeEntrypoint(params: { dir: string, name: string, contents: st return destination } +async function removeDirWithRetry(path: string, options: { attempts?: number, waitMs?: number } = {}) { + const attempts = Math.max(1, options.attempts ?? 5) + const waitMs = Math.max(1, options.waitMs ?? 20) + + for (let index = 0; index < attempts; index += 1) { + try { + await rm(path, { recursive: true, force: true }) + return + } + catch (error) { + if (index >= attempts - 1) { + throw error + } + await new Promise(resolve => setTimeout(resolve, waitMs)) + } + } +} + +function createDynamicModuleManifest(entrypoint: string): ManifestV1 { + const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' + const permissions: ModulePermissionDeclaration = { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: providersCapability, actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, + ], + resources: [ + { key: providersCapability, actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, + ], + capabilities: [ + { key: providersCapability, actions: ['wait'] }, + ], + } + + return { + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-dynamic-module', + permissions, + entrypoints: { + electron: entrypoint, + }, + } +} + +function createToolEnabledManifest(entrypoint: string): ManifestV1 { + const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' + + return { + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin-tools', + permissions: { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: providersCapability, actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, + ], + resources: [ + { key: providersCapability, actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:tools', actions: ['write'] }, + ], + capabilities: [ + { key: providersCapability, actions: ['wait'] }, + ], + }, + entrypoints: { + electron: entrypoint, + }, + } +} + +function createToolDrivenGameletManifest(entrypoint: string): ManifestV1 { + const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' + + return { + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin-gamelets', + permissions: { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: providersCapability, actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:update', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, + { key: pluginGameletApiOpenEventName, actions: ['invoke'] }, + { key: pluginGameletApiConfigureEventName, actions: ['invoke'] }, + { key: pluginGameletApiCloseEventName, actions: ['invoke'] }, + { key: pluginGameletApiIsOpenEventName, actions: ['invoke'] }, + ], + resources: [ + { key: providersCapability, actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits:kit.gamelet:bindings', actions: ['read', 'write'] }, + { key: 'proj-airi:plugin-sdk:resources:tools', actions: ['write'] }, + ], + capabilities: [ + { key: providersCapability, actions: ['wait'] }, + ], + }, + entrypoints: { + electron: entrypoint, + }, + } +} + +function createWidgetsManagerDouble() { + const widgetSnapshots = new Map() + const openWindow = vi.fn(async (_params?: { id?: string }) => {}) + const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => { + const snapshot: WidgetSnapshot = { + id: payload.id ?? Math.random().toString(36).slice(2, 10), + componentName: payload.componentName, + componentProps: payload.componentProps ?? {}, + size: payload.size ?? 'm', + windowSize: payload.windowSize, + ttlMs: payload.ttlMs ?? 0, + } + + widgetSnapshots.set(snapshot.id, snapshot) + return snapshot.id + }) + const updateWidget = vi.fn(async (payload: WidgetsUpdatePayload) => { + const existing = widgetSnapshots.get(payload.id) + if (!existing) { + return + } + + widgetSnapshots.set(payload.id, { + ...existing, + componentProps: payload.componentProps ?? existing.componentProps, + size: payload.size ?? existing.size, + windowSize: payload.windowSize ?? existing.windowSize, + ttlMs: payload.ttlMs ?? existing.ttlMs, + }) + }) + const removeWidget = vi.fn(async (id: string) => { + widgetSnapshots.delete(id) + }) + const getWidgetSnapshot = vi.fn((id: string) => widgetSnapshots.get(id)) + + return { + widgetSnapshots, + widgetsManager: { + openWindow, + pushWidget, + updateWidget, + removeWidget, + getWidgetSnapshot, + }, + } +} + +async function setupPluginHostForTest() { + const widgets = createWidgetsManagerDouble() + const service = await setupPluginHostService({ widgetsManager: widgets.widgetsManager }) + return { service, ...widgets } +} + +async function setupPluginHostHostServiceForTest() { + const widgets = createWidgetsManagerDouble() + const service = await setupPluginHostHostService({ widgetsManager: widgets.widgetsManager }) + return { service, ...widgets } +} + +async function setupPluginHost() { + return (await setupPluginHostForTest()).service +} + +function getGameletApis(session: { apis: Record }) { + return session.apis.gamelets as { + open: (id: string, params?: Record) => Promise + configure: (id: string, patch: Record) => Promise + close: (id: string) => Promise + isOpen: (id: string) => Promise + } +} + describe('setupPluginHost', () => { let userDataDir: string let pluginsDir: string + it('types the setup host service as the plain PluginHost surface', () => { + expectTypeOf().toMatchTypeOf() + }) + + it('types getBinding as an optional lookup on the plain PluginHost surface', () => { + expectTypeOf>().toMatchTypeOf | undefined>() + }) + + it('loads manifests through the internal host bootstrap helper', async () => { + const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') + await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-host-helper', + pluginName: 'test-host-helper', + entrypointPath: normalEntrypoint, + }) + + const { service } = await setupPluginHostHostServiceForTest() + + expect(service.host).toBeInstanceOf(PluginHost) + expect(service.manifests).toEqual([ + expect.objectContaining({ name: 'test-host-helper' }), + ]) + }) + beforeEach(async () => { userDataDir = await mkdtemp(join(tmpdir(), 'airi-plugins-')) pluginsDir = join(userDataDir, 'plugins', 'v1') @@ -114,8 +380,9 @@ describe('setupPluginHost', () => { }) afterEach(async () => { - await rm(userDataDir, { recursive: true, force: true }) + await removeDirWithRetry(userDataDir) contextState.lastContext = undefined + vi.restoreAllMocks() vi.clearAllMocks() }) @@ -225,6 +492,121 @@ describe('setupPluginHost', () => { expect(error).toEqual(expect.objectContaining({ enabled: true, loaded: false })) }) + it('loads the first matching manifest when duplicate plugin names exist', async () => { + const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts') + + const firstPluginDir = join(pluginsDir, 'duplicate-plugin-first') + await mkdir(firstPluginDir, { recursive: true }) + await writeEntrypoint({ + dir: firstPluginDir, + name: 'test-normal-plugin.ts', + contents: 'export async function init() {}', + }) + await writeManifest({ + dir: firstPluginDir, + name: 'duplicate-plugin', + entrypoint: './test-normal-plugin.ts', + }) + await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'duplicate-plugin-second', + pluginName: 'duplicate-plugin', + entrypointPath: errorEntrypoint, + }) + + const { service } = await setupPluginHostForTest() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + + await invokeSetEnabled({ name: 'duplicate-plugin', enabled: true }) + await invokeLoadEnabled() + + const duplicateSession = service.host + .listSessions() + .find(session => session.manifest.name === 'duplicate-plugin') + + expect(duplicateSession).toBeDefined() + expect(duplicateSession?.manifest.entrypoints.electron).toBe('./test-normal-plugin.ts') + }) + + it('persists plugin auto-reload state and surfaces it in registry snapshots', async () => { + const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') + await writeManifestInPluginDir({ + rootDir: pluginsDir, + pluginDirName: 'test-auto-reload', + pluginName: 'test-auto-reload', + entrypointPath: normalEntrypoint, + }) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) + const invokeList = defineInvoke(contextState.lastContext!, electronPluginList) + + await invokeSetAutoReload({ name: 'test-auto-reload', enabled: true }) + let snapshot = await invokeList() + expect(snapshot.plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'test-auto-reload', autoReload: true }), + ])) + + await invokeSetAutoReload({ name: 'test-auto-reload', enabled: false }) + snapshot = await invokeList() + expect(snapshot.plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'test-auto-reload', autoReload: false }), + ])) + }) + + it('reloads a loaded plugin when auto-reload is enabled and entrypoint changes', async () => { + const pluginDir = join(pluginsDir, 'test-auto-reload-reload') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-auto-reload-reload.ts', + contents: 'export async function init() {}', + }) + await writeManifest({ + dir: pluginDir, + name: 'test-auto-reload-reload', + entrypoint: './test-auto-reload-reload.ts', + }) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + const invokeSetAutoReload = defineInvoke(contextState.lastContext!, electronPluginSetAutoReload) + const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) + const invokeUnload = defineInvoke(contextState.lastContext!, electronPluginUnload) + + await invokeSetEnabled({ name: 'test-auto-reload-reload', enabled: true }) + await invokeLoadEnabled() + await invokeSetAutoReload({ name: 'test-auto-reload-reload', enabled: true }) + + const before = await invokeInspect() + const beforeSession = before.sessions.find(session => session.manifestName === 'test-auto-reload-reload') + expect(beforeSession).toBeDefined() + + await writeFile(entrypointPath, 'export async function init() { return "changed" }') + + const deadline = Date.now() + 3000 + let afterSessionId = beforeSession?.id + while (Date.now() < deadline && afterSessionId === beforeSession?.id) { + await new Promise(resolve => setTimeout(resolve, 100)) + const snapshot = await invokeInspect() + afterSessionId = snapshot.sessions.find(session => session.manifestName === 'test-auto-reload-reload')?.id + } + + expect(afterSessionId).toBeDefined() + expect(afterSessionId).not.toEqual(beforeSession?.id) + + await invokeSetAutoReload({ name: 'test-auto-reload-reload', enabled: false }) + await invokeUnload({ name: 'test-auto-reload-reload' }) + }) + it('loads enabled plugins with absolute manifest entrypoints outside the plugin directory', async () => { const externalDir = await mkdtemp(join(tmpdir(), 'airi-plugin-external-')) @@ -266,8 +648,8 @@ describe('setupPluginHost', () => { const pluginDir = join(pluginsDir, 'devtools-sample-plugin') await mkdir(pluginDir, { recursive: true }) await writeFile( - join(pluginDir, 'devtools-sample-plugin.json'), - await readFile(join(samplePluginRoot, 'devtools-sample-plugin.json'), 'utf-8'), + join(pluginDir, pluginManifestFileName), + await readFile(join(samplePluginRoot, pluginManifestFileName), 'utf-8'), ) await writeFile( join(pluginDir, 'devtools-sample-plugin.mjs'), @@ -288,6 +670,629 @@ describe('setupPluginHost', () => { expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) }) + it('loads the chess-like demo plugin and exposes an active gamelet module snapshot', async () => { + const pluginDir = join(pluginsDir, 'airi-plugin-game-chess') + await mkdir(pluginsDir, { recursive: true }) + try { + await stat(join(chessLikePluginRoot, 'dist')) + await cp(join(chessLikePluginRoot, 'dist'), pluginDir, { recursive: true }) + } + catch { + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, pluginManifestFileName), + await readFile(join(chessLikePluginRoot, pluginManifestFileName), 'utf-8'), + ) + await mkdir(join(pluginDir, 'ui'), { recursive: true }) + await writeFile(join(pluginDir, 'ui', 'index.html'), 'fallback') + } + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) + + await invokeSetEnabled({ name: 'airi-plugin-game-chess', enabled: true }) + + const registry = await invokeLoadEnabled() + const plugin = registry.plugins.find(item => item.name === 'airi-plugin-game-chess') + expect(plugin).toEqual(expect.objectContaining({ enabled: true, loaded: true })) + + const snapshot = await invokeInspect() + + // Verify the host exposes the announced module snapshot after activation. + expect(snapshot.modules).toEqual(expect.arrayContaining([ + expect.objectContaining({ + moduleId: 'chess-like-main', + ownerPluginId: 'airi-plugin-game-chess', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + runtime: 'electron', + state: 'active', + config: expect.objectContaining({ + title: 'Chess', + entrypoint: 'ui/index.html', + widget: expect.objectContaining({ + mount: 'iframe', + iframe: expect.objectContaining({ + assetPath: 'ui/index.html', + src: expect.stringMatching( + /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/ui\/index\.html\?t=[\w-]{10,}$/, + ), + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }), + windowSize: expect.objectContaining({ + width: 980, + height: 840, + minWidth: 640, + minHeight: 640, + }), + }), + config: expect.objectContaining({ + defaults: expect.objectContaining({ + opening: 'queen-gambit', + side: 'white', + }), + }), + }), + }), + ])) + }) + + it('exposes plugin asset base URL through Eventa invoke', async () => { + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeGetAssetBaseUrl = defineInvoke(contextState.lastContext!, electronPluginGetAssetBaseUrl) + + const baseUrl = await invokeGetAssetBaseUrl() + expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + }) + + it('exposes registered plugin tools to renderer clients', async () => { + const service = await setupPluginHost() + const pluginDir = join(pluginsDir, 'test-plugin-tools') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-tools.ts', + contents: 'export async function init() {}', + }) + + const session = await service.host.start(createToolEnabledManifest(entrypointPath), { cwd: pluginDir }) + await session.apis.tools.register({ + tool: { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['play.*chess'], + }, + parameters: { + type: 'object', + properties: {}, + }, + }, + execute: async () => ({ ok: true }), + }) + await session.apis.tools.register({ + tool: { + id: 'end_play_chess', + title: 'End Play Chess', + description: 'End chess.', + activation: { + keywords: ['end chess'], + patterns: ['end.*chess'], + }, + parameters: { + type: 'object', + properties: {}, + }, + }, + execute: async () => ({ ok: true, ended: true }), + }) + + expect(contextState.lastContext).toBeDefined() + const invokeListAgentTools = defineInvoke(contextState.lastContext!, electronPluginListAgentTools) + const invokeListXsaiTools = defineInvoke(contextState.lastContext!, electronPluginListXsaiTools) + const invokePluginTool = defineInvoke(contextState.lastContext!, electronPluginInvokeTool) + + await expect(invokeListAgentTools()).resolves.toEqual([ + expect.objectContaining({ id: 'play_chess' }), + expect.objectContaining({ id: 'end_play_chess' }), + ]) + await expect(invokeListXsaiTools()).resolves.toEqual([ + expect.objectContaining({ name: 'play_chess' }), + expect.objectContaining({ name: 'end_play_chess' }), + ]) + await expect(invokePluginTool({ + ownerPluginId: session.identity.plugin.id, + name: 'play_chess', + input: {}, + })).resolves.toEqual({ ok: true }) + }) + + it('lets a plugin tool drive host-backed gamelet widgets end-to-end', async () => { + const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() + const pluginDir = join(pluginsDir, 'test-plugin-gamelets') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-gamelets.ts', + contents: [ + 'const gameletId = \'gamelet-under-test\'', + '', + 'export async function init(ctx) {', + ' await ctx.apis.bindings.announce({', + ' moduleId: gameletId,', + ' kitId: \'kit.gamelet\',', + ' kitModuleType: \'gamelet\',', + ' config: {', + ' title: \'Gamelet Under Test\',', + ' entrypoint: \'ui/index.html\',', + ' widget: {', + ' mount: \'iframe\',', + ' iframe: {', + ' assetPath: \'ui/index.html\',', + ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', + ' },', + ' windowSize: {', + ' width: 980,', + ' height: 840,', + ' minWidth: 640,', + ' minHeight: 640,', + ' },', + ' },', + ' config: {', + ' defaults: {', + ' opening: \'queen-gambit\',', + ' },', + ' },', + ' },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId: gameletId })', + ' await ctx.apis.tools.register({', + ' tool: {', + ' id: \'drive_gamelet\',', + ' title: \'Drive Gamelet\',', + ' description: \'Drive a gamelet through host-backed APIs.\',', + ' activation: { keywords: [], patterns: [] },', + ' parameters: { type: \'object\', properties: {} },', + ' },', + ' async execute() {', + ' await ctx.apis.gamelets.open(gameletId, { mode: \'new\', side: \'white\' })', + ' await ctx.apis.gamelets.configure(gameletId, { opening: \'sicilian\', side: \'black\' })', + ' const wasOpen = await ctx.apis.gamelets.isOpen(gameletId)', + ' await ctx.apis.gamelets.close(gameletId)', + '', + ' return { ok: true, wasOpen }', + ' },', + ' })', + '}', + ].join('\n'), + }) + + const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) + + expect(contextState.lastContext).toBeDefined() + const invokePluginTool = defineInvoke(contextState.lastContext!, electronPluginInvokeTool) + + await expect(invokePluginTool({ + ownerPluginId: session.identity.plugin.id, + name: 'drive_gamelet', + input: {}, + })).resolves.toEqual({ ok: true, wasOpen: true }) + + expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'gamelet-under-test', + componentName: 'extension-ui', + componentProps: expect.objectContaining({ + moduleId: 'gamelet-under-test', + title: 'Gamelet Under Test', + payload: { + mode: 'new', + side: 'white', + }, + }), + })) + expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'gamelet-under-test', + componentProps: expect.objectContaining({ + payload: { + mode: 'new', + side: 'black', + opening: 'sicilian', + }, + }), + })) + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-under-test') + expect(widgetSnapshots.get('gamelet-under-test')).toBeUndefined() + expect(service.host.getBinding('gamelet-under-test')).toEqual(expect.objectContaining({ + config: expect.objectContaining({ + config: expect.objectContaining({ + defaults: { + opening: 'queen-gambit', + }, + current: { + opening: 'sicilian', + side: 'black', + }, + }), + }), + })) + }) + + it('updates widgetsManager through the host gamelet wrapper', async () => { + const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() + const pluginDir = join(pluginsDir, 'test-plugin-gamelets-wrapper') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-gamelets-wrapper.ts', + contents: [ + 'const gameletId = \'gamelet-wrapper-under-test\'', + '', + 'export async function init(ctx) {', + ' await ctx.apis.bindings.announce({', + ' moduleId: gameletId,', + ' kitId: \'kit.gamelet\',', + ' kitModuleType: \'gamelet\',', + ' config: {', + ' title: \'Gamelet Wrapper Under Test\',', + ' entrypoint: \'ui/index.html\',', + ' widget: {', + ' mount: \'iframe\',', + ' iframe: {', + ' assetPath: \'ui/index.html\',', + ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', + ' },', + ' windowSize: {', + ' width: 980,', + ' height: 840,', + ' minWidth: 640,', + ' minHeight: 640,', + ' },', + ' },', + ' config: {', + ' defaults: {', + ' opening: \'queen-gambit\',', + ' },', + ' },', + ' },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId: gameletId })', + '}', + ].join('\n'), + }) + + const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) + const gamelets = getGameletApis(session) + + await expect(gamelets.open('gamelet-wrapper-under-test', { mode: 'new', side: 'white' })).resolves.toBeUndefined() + expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'gamelet-wrapper-under-test', + componentName: 'extension-ui', + componentProps: expect.objectContaining({ + moduleId: 'gamelet-wrapper-under-test', + title: 'Gamelet Wrapper Under Test', + payload: { + mode: 'new', + side: 'white', + }, + }), + })) + expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toEqual(expect.objectContaining({ + componentProps: expect.objectContaining({ + payload: { + mode: 'new', + side: 'white', + }, + }), + })) + + await expect(gamelets.configure('gamelet-wrapper-under-test', { opening: 'sicilian', side: 'black' })).resolves.toBeUndefined() + expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'gamelet-wrapper-under-test', + componentProps: expect.objectContaining({ + payload: { + mode: 'new', + side: 'black', + opening: 'sicilian', + }, + }), + })) + expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toEqual(expect.objectContaining({ + componentProps: expect.objectContaining({ + payload: { + mode: 'new', + side: 'black', + opening: 'sicilian', + }, + }), + })) + + await expect(gamelets.close('gamelet-wrapper-under-test')).resolves.toBeUndefined() + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-wrapper-under-test') + expect(widgetSnapshots.get('gamelet-wrapper-under-test')).toBeUndefined() + }) + + it('removes open gamelet widgets when the owning session stops', async () => { + const { service, widgetsManager, widgetSnapshots } = await setupPluginHostForTest() + const pluginDir = join(pluginsDir, 'test-plugin-gamelets-stop-cleanup') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-gamelets-stop-cleanup.ts', + contents: [ + 'const gameletId = \'gamelet-stop-cleanup-under-test\'', + '', + 'export async function init(ctx) {', + ' await ctx.apis.bindings.announce({', + ' moduleId: gameletId,', + ' kitId: \'kit.gamelet\',', + ' kitModuleType: \'gamelet\',', + ' config: {', + ' title: \'Stop Cleanup Gamelet\',', + ' widget: {', + ' windowSize: { width: 720, height: 540 },', + ' },', + ' },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId: gameletId })', + '}', + ].join('\n'), + }) + + const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) + const gamelets = getGameletApis(session) + + await expect(gamelets.open('gamelet-stop-cleanup-under-test', { side: 'white' })).resolves.toBeUndefined() + expect(widgetSnapshots.get('gamelet-stop-cleanup-under-test')).toEqual(expect.objectContaining({ + id: 'gamelet-stop-cleanup-under-test', + })) + + service.host.stop(session.id) + + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-under-test') + expect(widgetSnapshots.get('gamelet-stop-cleanup-under-test')).toBeUndefined() + }) + + it('handles rejected widget cleanup promises while stopping a session', async () => { + const widgetSnapshots = new Map() + const widgetsManager = { + openWindow: vi.fn(async (_params?: { id?: string }) => {}), + pushWidget: vi.fn(async (payload: WidgetsAddPayload) => { + const snapshot: WidgetSnapshot = { + id: payload.id ?? Math.random().toString(36).slice(2, 10), + componentName: payload.componentName, + componentProps: payload.componentProps ?? {}, + size: payload.size ?? 'm', + windowSize: payload.windowSize, + ttlMs: payload.ttlMs ?? 0, + } + + widgetSnapshots.set(snapshot.id, snapshot) + return snapshot.id + }), + updateWidget: vi.fn(async (_payload: WidgetsUpdatePayload) => {}), + removeWidget: vi.fn(async (id: string) => { + if (id === 'gamelet-stop-cleanup-reject-a') { + throw new Error('remove failed') + } + + widgetSnapshots.delete(id) + }), + getWidgetSnapshot: vi.fn((id: string) => widgetSnapshots.get(id)), + } + const service = await setupPluginHostService({ widgetsManager }) + const pluginDir = join(pluginsDir, 'test-plugin-gamelets-stop-cleanup-reject') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-gamelets-stop-cleanup-reject.ts', + contents: [ + 'export async function init(ctx) {', + ' await ctx.apis.bindings.announce({', + ' moduleId: \'gamelet-stop-cleanup-reject-a\',', + ' kitId: \'kit.gamelet\',', + ' kitModuleType: \'gamelet\',', + ' config: { title: \'Reject A\', widget: { windowSize: { width: 720, height: 540 } } },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId: \'gamelet-stop-cleanup-reject-a\' })', + ' await ctx.apis.bindings.announce({', + ' moduleId: \'gamelet-stop-cleanup-reject-b\',', + ' kitId: \'kit.gamelet\',', + ' kitModuleType: \'gamelet\',', + ' config: { title: \'Reject B\', widget: { windowSize: { width: 720, height: 540 } } },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId: \'gamelet-stop-cleanup-reject-b\' })', + '}', + ].join('\n'), + }) + + const session = await service.host.start(createToolDrivenGameletManifest(entrypointPath), { cwd: pluginDir }) + const gamelets = getGameletApis(session) + + await expect(gamelets.open('gamelet-stop-cleanup-reject-a', { side: 'white' })).resolves.toBeUndefined() + await expect(gamelets.open('gamelet-stop-cleanup-reject-b', { side: 'black' })).resolves.toBeUndefined() + + expect(() => service.host.stop(session.id)).not.toThrow() + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-reject-a') + expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-stop-cleanup-reject-b') + expect(widgetSnapshots.get('gamelet-stop-cleanup-reject-b')).toBeUndefined() + }) + + it('rejects gamelet access when plugin id matches but session id does not', async () => { + const { service } = await setupPluginHostForTest() + const pluginDir = join(pluginsDir, 'test-plugin-gamelets-isolation') + await mkdir(pluginDir, { recursive: true }) + const entrypointPath = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-gamelets-isolation.ts', + contents: 'export async function init() {}', + }) + + const manifest = createToolDrivenGameletManifest(entrypointPath) + const first = await service.host.start(manifest, { cwd: pluginDir }) + service.host.announceBinding(first.id, { + moduleId: 'isolated-gamelet', + kitId: 'kit.gamelet', + kitModuleType: 'gamelet', + config: { + title: 'Isolated Gamelet', + entrypoint: 'ui/index.html', + widget: { + mount: 'iframe', + iframe: { + assetPath: 'ui/index.html', + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }, + windowSize: { + width: 980, + height: 840, + minWidth: 640, + minHeight: 640, + }, + }, + }, + }) + + const second = await service.host.start(manifest, { cwd: pluginDir }) + const secondGamelets = getGameletApis(second) + + await expect(secondGamelets.isOpen('isolated-gamelet')).rejects.toThrow( + `Gamelet module \`isolated-gamelet\` is not owned by session \`${second.id}\`.`, + ) + }) + + it('rewrites plugin widget iframe asset URLs in inspect snapshots', async () => { + const pluginDir = join(pluginsDir, 'test-plugin-widget-asset-url') + await mkdir(pluginDir, { recursive: true }) + await mkdir(join(pluginDir, 'ui'), { recursive: true }) + await mkdir(join(pluginDir, 'ui', 'private'), { recursive: true }) + await writeFile(join(pluginDir, 'ui', 'index.html'), 'widget') + await writeFile(join(pluginDir, 'ui', 'other.html'), 'other') + await writeFile(join(pluginDir, 'ui', 'private', 'secret.txt'), 'secret') + const entrypointFile = await writeEntrypoint({ + dir: pluginDir, + name: 'test-plugin-widget-asset-url.ts', + contents: [ + 'const moduleId = \'widget-shell-under-test\'', + '', + 'export async function init(ctx) {', + ' await ctx.apis.bindings.announce({', + ' moduleId,', + ' kitId: \'kit.widget\',', + ' kitModuleType: \'window\',', + ' config: {', + ' title: \'Widget Shell Under Test\',', + ' entrypoint: \'./ui/index.html\',', + ' widget: {', + ' mount: \'iframe\',', + ' iframe: {', + ' assetPath: \'./ui/index.html\',', + ' sandbox: \'allow-scripts allow-same-origin allow-forms allow-popups\',', + ' },', + ' windowSize: {', + ' width: 980,', + ' height: 840,', + ' minWidth: 640,', + ' minHeight: 640,', + ' },', + ' },', + ' },', + ' })', + ' await ctx.apis.bindings.activate({ moduleId })', + '}', + ].join('\n'), + }) + await writeFile(join(pluginDir, pluginManifestFileName), JSON.stringify({ + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin-widget-asset-url', + permissions: { + apis: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, + ], + resources: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, + { key: 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings', actions: ['read', 'write'] }, + ], + capabilities: [ + { key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] }, + ], + }, + entrypoints: { + electron: `./${basename(entrypointFile)}`, + }, + }, null, 2)) + + await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled) + const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled) + const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) + + await invokeSetEnabled({ name: 'test-plugin-widget-asset-url', enabled: true }) + await invokeLoadEnabled() + const snapshot = await invokeInspect() + + expect(snapshot.modules).toEqual(expect.arrayContaining([ + expect.objectContaining({ + moduleId: 'widget-shell-under-test', + ownerPluginId: 'test-plugin-widget-asset-url', + kitId: 'kit.widget', + kitModuleType: 'window', + runtime: 'electron', + config: expect.objectContaining({ + title: 'Widget Shell Under Test', + widget: expect.objectContaining({ + iframe: expect.objectContaining({ + assetPath: './ui/index.html', + src: expect.stringMatching( + /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/test-plugin-widget-asset-url\/ui\/index\.html\?t=[\w-]{10,}$/, + ), + sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', + }), + }), + }), + }), + ])) + + const iframeSource = (snapshot.modules.find(module => module.moduleId === 'widget-shell-under-test')?.config as Record) + ?.widget + ?.iframe + ?.src as string | undefined + expect(iframeSource).toBeTruthy() + + const iframeResponse = await fetch(iframeSource!) + expect(iframeResponse.status).toBe(200) + expect(await iframeResponse.text()).toContain('widget') + + const iframeUrl = new URL(iframeSource!) + const siblingRootUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/other.html?t=${iframeUrl.searchParams.get('t')}` + const siblingRootResponse = await fetch(siblingRootUrl) + expect(siblingRootResponse.status).toBe(401) + + const outsidePrefixUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/private/secret.txt?t=${iframeUrl.searchParams.get('t')}` + const outsidePrefixResponse = await fetch(outsidePrefixUrl) + expect(outsidePrefixResponse.status).toBe(401) + }) + it('mirrors degraded and withdrawn capability updates into the host snapshot', async () => { await setupPluginHost() @@ -325,4 +1330,118 @@ describe('setupPluginHost', () => { }), ])) }) + + it('includes built-in kits and module snapshots in inspect responses without leaking mutable references', async () => { + const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') + const { host } = await setupPluginHost() + + expect(contextState.lastContext).toBeDefined() + const invokeInspect = defineInvoke(contextState.lastContext!, electronPluginInspect) + + const session = await host.start(createDynamicModuleManifest(normalEntrypoint), { cwd: pluginsDir }) + host.announceBinding(session.id, { + moduleId: 'widget-shell', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { route: '/widgets/runtime' }, + }) + + const snapshot = await invokeInspect() + + expect(snapshot.kits).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kitId: 'kit.widget', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + ], + }), + expect.objectContaining({ + kitId: 'kit.gamelet', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + ], + }), + ])) + expect(snapshot.modules).toEqual(expect.arrayContaining([ + expect.objectContaining({ + moduleId: 'widget-shell', + ownerSessionId: session.id, + ownerPluginId: 'test-dynamic-module', + kitId: 'kit.widget', + kitModuleType: 'window', + runtime: 'electron', + state: 'announced', + config: { route: '/widgets/runtime' }, + }), + ])) + + snapshot.kits[0]!.kitId = 'kit.mutated' + snapshot.kits[0]!.capabilities[0]!.actions.push('tampered') + snapshot.modules[0]!.config = { route: '/widgets/tampered' } + + const nextSnapshot = await invokeInspect() + + expect(nextSnapshot.kits).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kitId: 'kit.widget', + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + ], + }), + expect.objectContaining({ + kitId: 'kit.gamelet', + capabilities: [ + { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + ], + }), + ])) + expect(nextSnapshot.modules).toEqual(expect.arrayContaining([ + expect.objectContaining({ + moduleId: 'widget-shell', + config: { route: '/widgets/runtime' }, + }), + ])) + }) + + it('sources built-in kit descriptors from installable kit modules', () => { + expect(widgetPluginKitDescriptor).toEqual({ + kitId: 'kit.widget', + version: '1.0.0', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + ], + }) + + expect(gameletPluginKitDescriptor).toEqual({ + kitId: 'kit.gamelet', + version: '1.0.0', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + ], + }) + }) + + it('rejects module announce when the kit runtime does not match the host runtime', async () => { + const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts') + const { host } = await setupPluginHost() + + const session = await host.start(createDynamicModuleManifest(normalEntrypoint), { cwd: pluginsDir }) + host.registerKit({ + kitId: 'kit.web-only', + version: '1.0.0', + runtimes: ['web'], + capabilities: [{ key: 'kit.web-only.module', actions: ['announce'] }], + }) + + expect(() => host.announceBinding(session.id, { + moduleId: 'web-only-shell', + kitId: 'kit.web-only', + kitModuleType: 'window', + config: { route: '/widgets/web-only' }, + })).toThrowError(/not available for runtime `electron`/i) + }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index cb12de552..870dff346 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -1,206 +1,41 @@ -import type { Dirent } from 'node:fs' - -import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host' - import type { - PluginHostDebugSnapshot, - PluginManifestSummary, - PluginRegistrySnapshot, -} from '../../../../shared/eventa' + PluginHostService, + SetupPluginHostOptions, +} from './types' -import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { dirname, extname, join } from 'node:path' - -import { useLogg } from '@guiiai/logg' -import { defineInvoke, defineInvokeHandler } from '@moeru/eventa' +import { + defineInvoke, + defineInvokeHandler, +} from '@moeru/eventa' import { createContext } from '@moeru/eventa/adapters/electron/main' -import { manifestV1Schema, PluginHost } from '@proj-airi/plugin-sdk/plugin-host' -import { app, ipcMain } from 'electron' -import { array, object, record, safeParse, string } from 'valibot' +import { + app, + ipcMain, +} from 'electron' +import { + electronPluginGetAssetBaseUrl, +} from '../../../../shared/eventa/plugin/assets' +import { + electronPluginUpdateCapability, + pluginProtocolListProviders, + pluginProtocolListProvidersEventName, +} from '../../../../shared/eventa/plugin/capabilities' import { electronPluginInspect, electronPluginList, electronPluginLoad, electronPluginLoadEnabled, + electronPluginSetAutoReload, electronPluginSetEnabled, electronPluginUnload, - electronPluginUpdateCapability, - pluginProtocolListProviders, - pluginProtocolListProvidersEventName, -} from '../../../../shared/eventa' -import { createConfig } from '../../../libs/electron/persistence' - -interface PluginHostService { - host: PluginHost - manifests: ManifestV1[] -} - -interface CapabilityAwarePluginHost extends PluginHost { - setResourceResolver: (key: string, resolver: () => Promise | T) => void - announceCapability: (key: string, metadata?: Record) => { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record - updatedAt: number - } - markCapabilityReady: (key: string, metadata?: Record) => { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record - updatedAt: number - } - markCapabilityDegraded: (key: string, metadata?: Record) => { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record - updatedAt: number - } - withdrawCapability: (key: string, metadata?: Record) => { - key: string - state: 'announced' | 'ready' | 'degraded' | 'withdrawn' - metadata?: Record - updatedAt: number - } -} - -interface PluginConfig { - enabled: string[] - known: Record -} - -interface ManifestEntry { - manifest: ManifestV1 - path: string -} - -const pluginConfigSchema = object({ - enabled: array(string()), - known: record(string(), object({ - path: string(), - })), -}) - -function isManifestV1(value: unknown): value is ManifestV1 { - return safeParse(manifestV1Schema, value).success -} - -async function realPathOf(entry: Dirent, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> { - if (!entry.isSymbolicLink()) { - return { resolved: false } - } - - try { - const resolvedPath = await realpath(join(options?.cwd ?? '', entry.name)) - - const stats = await stat(resolvedPath) - if (stats.isFile() || stats.isDirectory()) { - return { resolved: true, path: resolvedPath } - } - - return { resolved: false } - } - catch (error) { - return { resolved: false, error } - } -} - -async function loadManifestsFrom(dir: string, log: ReturnType): Promise { - await mkdir(dir, { recursive: true }) - const entries = await readdir(dir, { withFileTypes: true }) - const manifests: ManifestEntry[] = [] - const manifestPaths: string[] = [] - - for (const entry of entries) { - if (!entry.isDirectory()) { - if (entry.isSymbolicLink()) { - const { resolved, error } = await realPathOf(entry, { cwd: dir }) - if (error) { - log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping') - continue - } - if (!resolved) { - log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') - continue - } - } - else { - continue - } - } - - let pluginDir = join(dir, entry.name) - if (entry.isSymbolicLink()) { - const { path, resolved } = await realPathOf(entry, { cwd: dir }) - if (resolved) { - pluginDir = path - } - else { - log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping') - continue - } - } - - const pluginEntries = await readdir(pluginDir, { withFileTypes: true }) - for (const pluginEntry of pluginEntries) { - if (pluginEntry.isSymbolicLink()) { - try { - const resolvedPath = await realpath(join(pluginDir, pluginEntry.name)) - - const stats = await stat(resolvedPath) - if (!stats.isFile()) { - continue - } - if (extname(resolvedPath) !== '.json') { - continue - } - } - catch (error) { - log.withError(error).withFields({ name: pluginEntry.name }).warn('failed to resolve symlink, skipping') - - continue - } - - manifestPaths.push(join(pluginDir, pluginEntry.name)) - } - if (pluginEntry.isFile() && extname(pluginEntry.name) === '.json') { - manifestPaths.push(join(pluginDir, pluginEntry.name)) - } - - continue - } - } - - for (const path of manifestPaths) { - try { - const raw = await readFile(path, 'utf-8') - const parsed = JSON.parse(raw) as unknown - if (!isManifestV1(parsed)) { - log.warn('invalid plugin manifest schema', { path }) - continue - } - - manifests.push({ manifest: parsed, path }) - } - catch (error) { - log.withError(error).withFields({ path }).error('failed to read plugin manifest') - } - } - - return manifests -} - -function createPluginSummary(entry: ManifestEntry, config: PluginConfig, loaded: Set): PluginManifestSummary { - const name = entry.manifest.name - return { - name, - entrypoints: entry.manifest.entrypoints, - path: entry.path, - enabled: config.enabled.includes(name), - loaded: loaded.has(name), - isNew: !config.known[name], - } -} +} from '../../../../shared/eventa/plugin/host' +import { + electronPluginInvokeTool, + electronPluginListAgentTools, + electronPluginListXsaiTools, +} from '../../../../shared/eventa/plugin/tools' +import { setupPluginHostHostService } from './host' /** * Initializes the Electron plugin host and wires IPC handlers. @@ -215,185 +50,62 @@ function createPluginSummary(entry: ManifestEntry, config: PluginConfig, loaded: * * Persists enablement/known state to `plugins-v1.json` alongside config data. * - * - Windows: %APPDATA%\${appId}\plugins-v1.json + * - Windows: %APPDATA%\${appId}/plugins-v1.json * - Linux: $XDG_CONFIG_HOME/${appId}/plugins-v1.json or ~/.config/${appId}/plugins-v1.json * - macOS: ~/Library/Application Support/${appId}/plugins-v1.json */ -export async function setupPluginHost(): Promise { - const log = useLogg('main/plugin-host').useGlobalConfig() - const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1') - - const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, { - default: { - enabled: [], - known: {}, - }, - autoHeal: true, - }) - - pluginConfig.setup() - - const host = new PluginHost({ runtime: 'electron' }) - - // NOTICE: stage-tamagotchi currently typechecks against package exports while plugin-sdk changes - // are source-local in this workspace. Cast keeps the bridge typed until package dist is regenerated. - const capabilityHost = host as CapabilityAwarePluginHost - log.withFields({ pluginsRoot }).log('loading plugin manifests') - - let entries = await loadManifestsFrom(pluginsRoot, log) - log.withFields({ count: entries.length }).log('plugin manifests loaded') - - let manifests = entries.map((entry) => { - log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found') - - return entry.manifest - }) - - const loaded = new Set() - const loadedSessionIds = new Map() - - const refreshManifests = async () => { - entries = await loadManifestsFrom(pluginsRoot, log) - manifests = entries.map(entry => entry.manifest) - } - - const getConfig = (): PluginConfig => { - return pluginConfig.get() ?? { enabled: [], known: {} } - } - - const toSnapshot = (): PluginRegistrySnapshot => { - const config = getConfig() - return { - root: pluginsRoot, - plugins: entries.map(entry => createPluginSummary(entry, config, loaded)), - } - } - - const toDebugSnapshot = (): PluginHostDebugSnapshot => { - return { - registry: toSnapshot(), - sessions: host.listSessions().map(session => ({ - id: session.id, - manifestName: session.manifest.name, - phase: session.phase, - runtime: session.runtime, - moduleId: session.identity.id, - })), - capabilities: capabilityHost.listCapabilities(), - refreshedAt: Date.now(), - } - } - - const findManifestEntry = (name: string) => { - return entries.find(entry => entry.manifest.name === name) - } - - const loadPluginByName = async (name: string) => { - if (loaded.has(name)) - return - - const entry = findManifestEntry(name) - if (!entry) { - throw new Error(`Plugin manifest not found: ${name}`) - } - - const session = await host.start(entry.manifest, { cwd: dirname(entry.path) }) - loaded.add(name) - loadedSessionIds.set(name, session.id) - log.log('plugin loaded', { plugin: name, sessionId: session.id }) - } - - const unloadPluginByName = (name: string) => { - const sessionId = loadedSessionIds.get(name) - if (!sessionId) { - loaded.delete(name) - return - } - - host.stop(sessionId) - loadedSessionIds.delete(name) - loaded.delete(name) - log.log('plugin unloaded', { plugin: name, sessionId }) - } - - const loadEnabled = async () => { - const config = getConfig() - for (const entry of entries) { - const name = entry.manifest.name - if (!config.enabled.includes(name)) - continue - if (loaded.has(name)) - continue - - try { - await loadPluginByName(name) - } - catch (error) { - log.withError(error).withFields({ plugin: name }).error('plugin failed to start') - } - } - } - +export async function setupPluginHost(options: SetupPluginHostOptions): Promise { + const hostService = await setupPluginHostHostService(options) const { context } = createContext(ipcMain) const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders) defineInvokeHandler(context, electronPluginList, async () => { - // IPC: fetch current plugin list by refreshing manifests and returning a snapshot. - await refreshManifests() - return toSnapshot() + return await hostService.list() }) defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => { - // IPC: toggle a plugin's enabled state, persist config, and return updated snapshot. - await refreshManifests() - const config = getConfig() - const enabled = new Set(config.enabled) - if (payload?.enabled) - enabled.add(payload.name) - else - enabled.delete(payload.name) + return await hostService.setEnabled(payload) + }) - const entry = entries.find(candidate => candidate.manifest.name === payload.name) - const manifestPath = entry?.path ?? payload.path ?? '' - const nextConfig: PluginConfig = { - enabled: [...enabled], - known: { - ...config.known, - [payload.name]: { path: manifestPath }, - }, - } - - pluginConfig.update(nextConfig) - - return toSnapshot() + defineInvokeHandler(context, electronPluginSetAutoReload, async (payload) => { + return await hostService.setAutoReload(payload) }) defineInvokeHandler(context, electronPluginLoadEnabled, async () => { - // IPC: load all enabled plugins and return the latest snapshot. - await refreshManifests() - await loadEnabled() - return toSnapshot() + return await hostService.loadEnabled() }) defineInvokeHandler(context, electronPluginLoad, async (payload) => { - await refreshManifests() - await loadPluginByName(payload.name) - return toSnapshot() + return await hostService.load(payload.name) }) defineInvokeHandler(context, electronPluginUnload, async (payload) => { - unloadPluginByName(payload.name) - return toSnapshot() + return hostService.unload(payload.name) }) defineInvokeHandler(context, electronPluginInspect, async () => { - await refreshManifests() - return toDebugSnapshot() + return await hostService.inspect() + }) + + defineInvokeHandler(context, electronPluginGetAssetBaseUrl, async () => { + return hostService.getAssetBaseUrl() + }) + + defineInvokeHandler(context, electronPluginListAgentTools, async () => { + return await hostService.host.listAvailableToolDescriptors() + }) + + defineInvokeHandler(context, electronPluginListXsaiTools, async () => { + return await hostService.host.listSerializedXsaiTools() + }) + + defineInvokeHandler(context, electronPluginInvokeTool, async (payload) => { + return await hostService.host.invokeTool(payload.ownerPluginId, payload.name, payload.input) }) defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => { if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') { - capabilityHost.setResourceResolver( + hostService.host.setResourceResolver( pluginProtocolListProvidersEventName, async () => await invokePluginProtocolListProviders(), ) @@ -401,13 +113,13 @@ export async function setupPluginHost(): Promise { switch (payload.state) { case 'announced': - return capabilityHost.announceCapability(payload.key, payload.metadata) + return hostService.host.announceCapability(payload.key, payload.metadata) case 'ready': - return capabilityHost.markCapabilityReady(payload.key, payload.metadata) + return hostService.host.markCapabilityReady(payload.key, payload.metadata) case 'degraded': - return capabilityHost.markCapabilityDegraded(payload.key, payload.metadata) + return hostService.host.markCapabilityDegraded(payload.key, payload.metadata) case 'withdrawn': - return capabilityHost.withdrawCapability(payload.key, payload.metadata) + return hostService.host.withdrawCapability(payload.key, payload.metadata) default: { const unexpectedState: never = payload.state throw new Error(`Unsupported capability state: ${unexpectedState}`) @@ -415,9 +127,14 @@ export async function setupPluginHost(): Promise { } }) - // Initialize enabled plugins during module setup so startup is bound to injeca lifecycle. - await refreshManifests() - await loadEnabled() + if (typeof app.once === 'function') { + app.once('before-quit', () => { + void hostService.dispose() + }) + } - return { host, manifests } + return { + host: hostService.host, + manifests: hostService.manifests, + } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts new file mode 100644 index 000000000..3af44a2cd --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/gamelet-widget-state.ts @@ -0,0 +1,207 @@ +import type { + BindingRecord, + HostDataRecord, + PluginHost, +} from '@proj-airi/plugin-sdk/plugin-host' + +import type { WidgetWindowSize } from '../../../../../../shared/eventa' + +import { isPlainObject } from 'es-toolkit' + +function cloneRecord(value: TValue): TValue { + return structuredClone(value) +} + +function toRecord(value: unknown): Record | undefined { + return isPlainObject(value) ? cloneRecord(value as Record) : undefined +} + +function toHostDataRecord(value: unknown): HostDataRecord | undefined { + return isPlainObject(value) ? cloneRecord(value as HostDataRecord) : undefined +} + +function toWindowSize(value: unknown): WidgetWindowSize | undefined { + if (!isPlainObject(value)) { + return undefined + } + + if (typeof value.width !== 'number' || typeof value.height !== 'number') { + return undefined + } + + return cloneRecord(value as WidgetWindowSize) +} + +/** + * Resolves one owned gamelet binding and rejects mismatched ownership. + * + * Use when: + * - Plugin sessions invoke `session.apis.gamelets.*` + * - The gamelet kit must enforce plugin and session ownership before touching widget state + * + * Expects: + * - `host` is the active plugin host instance + * - `moduleId` refers to a binding announced through `kit.gamelet` + * + * Returns: + * - The owned gamelet binding record when ownership and kit checks pass + */ +export function getOwnedGameletBindingOrThrow(params: { + host: PluginHost + ownerPluginId: string + ownerSessionId: string + moduleId: string +}): BindingRecord { + const binding = params.host.getBinding(params.moduleId) + if (!binding) { + throw new Error(`Gamelet module not found: ${params.moduleId}`) + } + + if (binding.ownerPluginId !== params.ownerPluginId) { + throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by plugin \`${params.ownerPluginId}\`.`) + } + + if (binding.ownerSessionId !== params.ownerSessionId) { + throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by session \`${params.ownerSessionId}\`.`) + } + + if (binding.kitId !== 'kit.gamelet') { + throw new Error(`Module \`${params.moduleId}\` is not a gamelet binding.`) + } + + return binding +} + +/** + * Derives the widget window size for one gamelet binding. + * + * Use when: + * - Opening or reconfiguring a gamelet-backed widget + * - Preferring module config while preserving current window size when the config omits it + * + * Expects: + * - `moduleConfig` is the binding config stored on the host + * + * Returns: + * - The configured window size or the current widget snapshot size + */ +export function getGameletWidgetWindowSize(params: { + moduleConfig: HostDataRecord + existingSnapshot?: { windowSize?: unknown } +}): WidgetWindowSize | undefined { + const widgetConfig = toRecord(params.moduleConfig.widget) + const windowSize = toWindowSize(widgetConfig?.windowSize) + return windowSize ?? toWindowSize(params.existingSnapshot?.windowSize) +} + +/** + * Derives the current display title for a gamelet widget. + * + * Use when: + * - Opening or updating a widget-backed gamelet + * - Preserving a current title when the binding config does not provide one + * + * Expects: + * - `moduleId` is the stable fallback title when neither config nor widget props define one + * + * Returns: + * - The best available title for the widget shell + */ +export function getGameletTitle(params: { + moduleId: string + moduleConfig: HostDataRecord + existingComponentProps?: Record +}): string { + const configuredTitle = typeof params.moduleConfig.title === 'string' && params.moduleConfig.title.trim() + ? params.moduleConfig.title + : undefined + const currentTitle = typeof params.existingComponentProps?.title === 'string' && params.existingComponentProps.title.trim() + ? params.existingComponentProps.title + : undefined + + return configuredTitle ?? currentTitle ?? params.moduleId +} + +/** + * Reads the persisted gamelet config payload stored under `config.current`. + * + * Use when: + * - Hydrating widget payload state from the host binding config + * - Merging `gamelets.configure(...)` patches into the stored config + * + * Expects: + * - `moduleConfig` is the full binding config record for one gamelet + * + * Returns: + * - The stored config payload, or an empty record when it has not been set yet + */ +export function getStoredGameletConfig(moduleConfig: HostDataRecord): HostDataRecord { + const configSection = toHostDataRecord(moduleConfig.config) + return toHostDataRecord(configSection?.current) ?? {} +} + +/** + * Merges one `gamelets.configure(...)` patch into the stored binding config. + * + * Use when: + * - Updating the host binding config and the mirrored widget payload together + * + * Expects: + * - `patch` is a JSON-compatible config patch + * + * Returns: + * - The merged `current` payload and the next full binding config record + */ +export function mergeGameletConfigPatch(params: { + moduleConfig: HostDataRecord + patch: HostDataRecord +}): { + nextCurrentConfig: HostDataRecord + nextConfig: HostDataRecord +} { + const nextCurrentConfig: HostDataRecord = { + ...getStoredGameletConfig(params.moduleConfig), + ...cloneRecord(params.patch), + } + const nextConfig: HostDataRecord = { + ...cloneRecord(params.moduleConfig), + config: { + ...toHostDataRecord(params.moduleConfig.config), + current: nextCurrentConfig, + }, + } + + return { + nextCurrentConfig, + nextConfig, + } +} + +/** + * Builds the extension-ui component props used for one gamelet widget. + * + * Use when: + * - Opening or updating a gamelet-backed extension-ui widget + * - Preserving unrelated existing component props while replacing payload-specific fields + * + * Expects: + * - `moduleId` and `title` are already resolved for the current binding state + * + * Returns: + * - The next component props payload sent to the widgets manager + */ +export function createGameletWidgetProps(params: { + moduleId: string + title: string + payload?: Record + windowSize?: WidgetWindowSize + existingComponentProps?: Record +}): Record { + return { + ...params.existingComponentProps, + moduleId: params.moduleId, + title: params.title, + ...(params.windowSize ? { windowSize: params.windowSize } : {}), + ...(params.payload ? { payload: params.payload } : {}), + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts new file mode 100644 index 000000000..d09abf291 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts @@ -0,0 +1,344 @@ +import type { + HostDataRecord, + KitDescriptor, + PluginHost, + PluginHostContribution, +} from '@proj-airi/plugin-sdk/plugin-host' + +import type { PluginHostGameletWidgetsManager } from '../../types' + +import { isPlainObject } from 'es-toolkit' + +import { + createGameletWidgetProps, + getGameletTitle, + getGameletWidgetWindowSize, + getOwnedGameletBindingOrThrow, + getStoredGameletConfig, + mergeGameletConfigPatch, +} from './gamelet-widget-state' + +/** + * Identifies the stage-tamagotchi permission key used to open a host-backed gamelet surface. + * + * Use when: + * - Declaring or asserting permission for `session.apis.gamelets.open(...)` + * - Reusing the stable gamelet event key in stage-owned tests + * + * Expects: + * - The gamelet kit contribution and its tests share this stage-owned constant + * + * Returns: + * - The permission/event key string for opening gamelets + */ +export const pluginGameletApiOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:open' + +/** + * Identifies the stage-tamagotchi permission key used to update a host-backed gamelet surface. + * + * Use when: + * - Declaring or asserting permission for `session.apis.gamelets.configure(...)` + * - Reusing the stable gamelet event key in stage-owned tests + * + * Expects: + * - The gamelet kit contribution and its tests share this stage-owned constant + * + * Returns: + * - The permission/event key string for configuring gamelets + */ +export const pluginGameletApiConfigureEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:configure' + +/** + * Identifies the stage-tamagotchi permission key used to close a host-backed gamelet surface. + * + * Use when: + * - Declaring or asserting permission for `session.apis.gamelets.close(...)` + * - Reusing the stable gamelet event key in stage-owned tests + * + * Expects: + * - The gamelet kit contribution and its tests share this stage-owned constant + * + * Returns: + * - The permission/event key string for closing gamelets + */ +export const pluginGameletApiCloseEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:close' + +/** + * Identifies the stage-tamagotchi permission key used to query whether a gamelet is open. + * + * Use when: + * - Declaring or asserting permission for `session.apis.gamelets.isOpen(...)` + * - Reusing the stable gamelet event key in stage-owned tests + * + * Expects: + * - The gamelet kit contribution and its tests share this stage-owned constant + * + * Returns: + * - The permission/event key string for querying gamelets + */ +export const pluginGameletApiIsOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:is-open' + +function cloneRecord(value: TValue): TValue { + return structuredClone(value) +} + +function toRecord(value: unknown): Record | undefined { + return isPlainObject(value) ? cloneRecord(value as Record) : undefined +} + +/** + * Declares the built-in gamelet kit exposed by `stage-tamagotchi`. + * + * Use when: + * - Bootstrapping the Electron plugin host with gamelet support + * - Reading the stable built-in gamelet kit descriptor in tests or snapshots + * + * Expects: + * - The host registers this descriptor during startup + * + * Returns: + * - The gamelet kit descriptor used for `kit.gamelet` + */ +export const gameletPluginKitDescriptor = { + kitId: 'kit.gamelet', + version: '1.0.0', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.gamelet.runtime', actions: ['announce', 'activate', 'update', 'withdraw', 'publish', 'subscribe'] }, + ], +} satisfies KitDescriptor + +/** + * Registers the built-in gamelet kit on one host instance. + * + * Use when: + * - Bootstrapping the Electron plugin host with gamelet kit support + * - Keeping gamelet descriptor registration inside the gamelet kit module + * + * Expects: + * - `host` is the initialized plugin host instance + * + * Returns: + * - The registered gamelet kit descriptor + */ +export function registerGameletPluginKit(host: PluginHost): KitDescriptor { + return host.registerKit(gameletPluginKitDescriptor) +} + +/** + * Creates the installable gamelet host contribution for `session.apis.gamelets`. + * + * Use when: + * - `stage-tamagotchi` needs plugin sessions to open, configure, close, or inspect gamelet widgets + * - The root plugin host bootstrap should consume a kit-owned contribution instead of embedding gamelet logic + * + * Expects: + * - `attachHost(...)` is called immediately after constructing `PluginHost` + * - `widgetsManager` already manages extension-ui widget state + * + * Returns: + * - A contribution plus an attach step that binds it to the constructed host instance + */ +export function createGameletHostContribution(options: { + widgetsManager: PluginHostGameletWidgetsManager +}): { + attachHost: (host: PluginHost) => void + contribution: PluginHostContribution +} { + let host: PluginHost | undefined + const openWidgetIdsBySession = new Map>() + const cleanupPromisesBySession = new Map>() + + const requireHost = () => { + if (!host) { + throw new Error('Gamelet host contribution has not been attached to a PluginHost instance.') + } + + return host + } + + const trackOpenWidget = (sessionId: string, moduleId: string) => { + const widgetIds = openWidgetIdsBySession.get(sessionId) ?? new Set() + widgetIds.add(moduleId) + openWidgetIdsBySession.set(sessionId, widgetIds) + } + + const untrackOpenWidget = (sessionId: string, moduleId: string) => { + const widgetIds = openWidgetIdsBySession.get(sessionId) + if (!widgetIds) { + return + } + + widgetIds.delete(moduleId) + if (widgetIds.size === 0) { + openWidgetIdsBySession.delete(sessionId) + } + } + + return { + attachHost(instance) { + host = instance + }, + contribution: { + install(context) { + context.registerLifecycleHook('session-stopped', ({ session }) => { + const widgetIds = openWidgetIdsBySession.get(session.sessionId) + if (!widgetIds) { + return + } + + const widgetIdsToRemove = [...widgetIds] + const cleanupPromise = Promise + .allSettled(widgetIdsToRemove.map(widgetId => options.widgetsManager.removeWidget(widgetId))) + .then(() => { + openWidgetIdsBySession.delete(session.sessionId) + cleanupPromisesBySession.delete(session.sessionId) + }) + + cleanupPromisesBySession.set(session.sessionId, cleanupPromise) + void cleanupPromise.catch(() => {}) + }) + + context.registerSessionApi('gamelets', ({ session, assertPermission }) => ({ + async open(id: string, params?: HostDataRecord) { + assertPermission({ + area: 'apis', + action: 'invoke', + key: pluginGameletApiOpenEventName, + }) + + const module = getOwnedGameletBindingOrThrow({ + host: requireHost(), + ownerPluginId: session.ownerPluginId, + ownerSessionId: session.sessionId, + moduleId: id, + }) + const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) + const existingComponentProps = toRecord(existingSnapshot?.componentProps) + const payload = params + ? cloneRecord(params) + : (toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config)) + const windowSize = getGameletWidgetWindowSize({ + moduleConfig: module.config, + existingSnapshot, + }) + const componentProps = createGameletWidgetProps({ + moduleId: id, + title: getGameletTitle({ + moduleId: id, + moduleConfig: module.config, + existingComponentProps, + }), + payload, + windowSize, + existingComponentProps, + }) + + if (existingSnapshot) { + await options.widgetsManager.updateWidget({ + id, + componentProps, + windowSize, + }) + await options.widgetsManager.openWindow({ id }) + trackOpenWidget(session.sessionId, id) + return + } + + await options.widgetsManager.pushWidget({ + id, + componentName: 'extension-ui', + componentProps, + size: 'm', + ttlMs: 0, + windowSize, + }) + trackOpenWidget(session.sessionId, id) + }, + async configure(id: string, patch: HostDataRecord) { + assertPermission({ + area: 'apis', + action: 'invoke', + key: pluginGameletApiConfigureEventName, + }) + + const module = getOwnedGameletBindingOrThrow({ + host: requireHost(), + ownerPluginId: session.ownerPluginId, + ownerSessionId: session.sessionId, + moduleId: id, + }) + const { nextConfig } = mergeGameletConfigPatch({ + moduleConfig: module.config, + patch, + }) + + requireHost().updateBinding(module.ownerSessionId, id, { config: nextConfig }) + + const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) + if (!existingSnapshot) { + return + } + + const existingComponentProps = toRecord(existingSnapshot.componentProps) + const existingPayload = toRecord(existingComponentProps?.payload) ?? {} + const windowSize = getGameletWidgetWindowSize({ + moduleConfig: nextConfig, + existingSnapshot, + }) + + await options.widgetsManager.updateWidget({ + id, + componentProps: createGameletWidgetProps({ + moduleId: id, + title: getGameletTitle({ + moduleId: id, + moduleConfig: nextConfig, + existingComponentProps, + }), + payload: { + ...existingPayload, + ...cloneRecord(patch), + }, + windowSize, + existingComponentProps, + }), + windowSize, + }) + }, + async close(id: string) { + assertPermission({ + area: 'apis', + action: 'invoke', + key: pluginGameletApiCloseEventName, + }) + + getOwnedGameletBindingOrThrow({ + host: requireHost(), + ownerPluginId: session.ownerPluginId, + ownerSessionId: session.sessionId, + moduleId: id, + }) + await options.widgetsManager.removeWidget(id) + untrackOpenWidget(session.sessionId, id) + }, + async isOpen(id: string) { + assertPermission({ + area: 'apis', + action: 'invoke', + key: pluginGameletApiIsOpenEventName, + }) + + getOwnedGameletBindingOrThrow({ + host: requireHost(), + ownerPluginId: session.ownerPluginId, + ownerSessionId: session.sessionId, + moduleId: id, + }) + return options.widgetsManager.getWidgetSnapshot(id) !== undefined + }, + })) + }, + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts new file mode 100644 index 000000000..6a8b51bd3 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.ts @@ -0,0 +1,43 @@ +import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' + +import type { SetupPluginHostOptions } from '../types' + +import { + createGameletHostContribution, + registerGameletPluginKit, +} from './gamelet' +import { registerWidgetPluginKit } from './widget' + +/** + * Creates the built-in kit runtime installed by the Electron plugin host. + * + * Use when: + * - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline + * - Built-in kit registration and contributions should remain outside the host layer + * + * Expects: + * - `widgetsManager` is initialized before host construction + * + * Returns: + * - Helpers to attach contributions and register built-in kits on the host + */ +export function createBuiltInPluginKitRuntime(options: SetupPluginHostOptions): { + contributions: ReturnType['contribution'][] + attachHost: (host: PluginHost) => void + registerHostKits: (host: PluginHost) => void +} { + const gameletContribution = createGameletHostContribution({ + widgetsManager: options.widgetsManager, + }) + + return { + contributions: [gameletContribution.contribution], + attachHost(host) { + gameletContribution.attachHost(host) + }, + registerHostKits(host) { + registerWidgetPluginKit(host) + registerGameletPluginKit(host) + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts new file mode 100644 index 000000000..3886262a3 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts @@ -0,0 +1,189 @@ +import type { PluginHostModuleSummary } from '../../../../../../shared/eventa/plugin/host' +import type { ManifestEntry } from '../../types' + +import { isPlainObject } from 'es-toolkit' + +import { + buildMountedPluginAssetPath, + normalizePluginAssetPath, +} from '../../asset-mount' + +const trailingSlashesPattern = /\/+$/ + +/** + * Describes one widget iframe asset as seen from the mounted `/ui` route. + * + * Use when: + * - Converting plugin config asset paths into mounted extension asset URLs + * - Issuing tokens that must validate against route-relative asset paths + * + * Expects: + * - `routeAssetPath` is relative to `/_airi/extensions/:extensionId/ui/` + * - `tokenPathPrefix` is a directory prefix under that same route, or empty for root + * + * Returns: + * - N/A + */ +export interface WidgetAssetRoute { + routeAssetPath: string + tokenPathPrefix: string +} + +function normalizeWidgetAssetPath(assetPath: string): string | undefined { + const trimmed = assetPath.trim().replaceAll('\\', '/') + if (!trimmed) { + return undefined + } + + const withoutRelativePrefix = trimmed.startsWith('./') + ? trimmed.slice(2) + : trimmed + + return normalizePluginAssetPath(withoutRelativePrefix) +} + +function withSearchParams(url: string, query: Record) { + const next = new URL(url) + for (const [key, value] of Object.entries(query)) { + next.searchParams.set(key, value) + } + return next.toString() +} + +/** + * Normalizes a widget iframe asset path into `/ui` route semantics. + * + * Use when: + * - Building mounted widget iframe URLs + * - Issuing asset tokens that must validate against the `/ui` static asset route + * - Keeping widget route semantics owned by the widget kit module + * + * Expects: + * - `assetPath` points to a file-like path under plugin static assets + * + * Returns: + * - The route-relative asset path and the allowed token prefix for that route + */ +export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined { + const normalized = normalizeWidgetAssetPath(assetPath) + if (!normalized) { + return undefined + } + + const routeAssetPath = normalized.startsWith('ui/') + ? normalized.slice(3) + : normalized + if (!routeAssetPath) { + return undefined + } + + const segments = routeAssetPath.split('/').filter(Boolean) + if (segments.length <= 1) { + return { + routeAssetPath, + tokenPathPrefix: routeAssetPath, + } + } + + return { + routeAssetPath, + tokenPathPrefix: `${segments.slice(0, -1).join('/')}/`, + } +} + +/** + * Rewrites widget iframe config to use mounted plugin asset URLs. + * + * Use when: + * - Building plugin inspect snapshots with renderer-consumable widget iframe URLs + * - Issuing temporary asset tokens for widget-owned iframe assets + * + * Expects: + * - Module config may contain widget iframe `src` or `assetPath` fields + * - Mapping includes a manifest entry for `module.ownerPluginId` + * + * Returns: + * - Original module when rewrite is not applicable + * - Cloned module with injected iframe `src` when asset path mount succeeds + */ +export function rewriteWidgetModuleAssetUrl( + module: PluginHostModuleSummary, + manifestEntryByName: Map, + options?: { + pluginAssetBaseUrl?: string + issueAssetToken?: (input: { + extensionId: string + version: string + sessionId: string + routeAssetPath: string + tokenPathPrefix: string + }) => string + }, +): PluginHostModuleSummary { + const entry = manifestEntryByName.get(module.ownerPluginId) + if (!entry) { + return module + } + + const config = isPlainObject(module.config) ? module.config as Record : {} + const widgetConfig = isPlainObject(config.widget) ? config.widget as Record : {} + const iframeConfig = isPlainObject(widgetConfig.iframe) ? widgetConfig.iframe as Record : {} + const iframeSrc = typeof iframeConfig.src === 'string' ? iframeConfig.src.trim() : '' + if (iframeSrc) { + return module + } + + const assetPath = normalizeWidgetAssetPath( + typeof iframeConfig.assetPath === 'string' + ? iframeConfig.assetPath + : typeof widgetConfig.iframeAssetPath === 'string' + ? widgetConfig.iframeAssetPath + : typeof config.iframeAssetPath === 'string' + ? config.iframeAssetPath + : '', + ) + if (!assetPath) { + return module + } + + const widgetAssetRoute = resolveWidgetAssetRoute(assetPath) + if (!widgetAssetRoute) { + return module + } + + const mountedPath = buildMountedPluginAssetPath({ + extensionId: entry.manifest.name, + assetPath: widgetAssetRoute.routeAssetPath, + }) + if (!mountedPath) { + return module + } + + const mountedAbsoluteUrl = options?.pluginAssetBaseUrl + ? new URL(mountedPath, `${options.pluginAssetBaseUrl.replace(trailingSlashesPattern, '')}/`).toString() + : mountedPath + const assetToken = options?.issueAssetToken?.({ + extensionId: entry.manifest.name, + version: entry.version, + sessionId: module.ownerSessionId, + routeAssetPath: widgetAssetRoute.routeAssetPath, + tokenPathPrefix: widgetAssetRoute.tokenPathPrefix, + }) + const iframeSourceUrl = assetToken + ? withSearchParams(mountedAbsoluteUrl, { t: assetToken }) + : mountedAbsoluteUrl + + return { + ...module, + config: { + ...config, + widget: { + ...widgetConfig, + iframe: { + ...iframeConfig, + src: iframeSourceUrl, + }, + }, + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts new file mode 100644 index 000000000..0526d692d --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/index.ts @@ -0,0 +1,48 @@ +import type { + KitDescriptor, + PluginHost, +} from '@proj-airi/plugin-sdk/plugin-host' + +export { + resolveWidgetAssetRoute, + rewriteWidgetModuleAssetUrl, +} from './asset-url' + +/** + * Declares the built-in widget kit exposed by `stage-tamagotchi`. + * + * Use when: + * - Bootstrapping the Electron plugin host with widget support + * - Reading the stable built-in widget kit descriptor in tests or snapshots + * + * Expects: + * - The host registers this descriptor during startup + * + * Returns: + * - The widget kit descriptor used for `kit.widget` + */ +export const widgetPluginKitDescriptor = { + kitId: 'kit.widget', + version: '1.0.0', + runtimes: ['electron', 'web'], + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + ], +} satisfies KitDescriptor + +/** + * Registers the built-in widget kit on one host instance. + * + * Use when: + * - Bootstrapping the Electron plugin host with widget kit support + * - Keeping widget descriptor registration inside the widget kit module + * + * Expects: + * - `host` is the initialized plugin host instance + * + * Returns: + * - The registered widget kit descriptor + */ +export function registerWidgetPluginKit(host: PluginHost): KitDescriptor { + return host.registerKit(widgetPluginKitDescriptor) +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts new file mode 100644 index 000000000..596fe953f --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts @@ -0,0 +1,146 @@ +import type { ManifestV1, PluginHost } from '@proj-airi/plugin-sdk/plugin-host' + +import type { + WidgetsAddPayload, + WidgetSnapshot, + WidgetsUpdatePayload, +} from '../../../../shared/eventa' + +/** + * Runtime-facing plugin host service bundle returned by setup. + * + * Use when: + * - Bootstrapping plugin infrastructure during Electron startup + * - Accessing loaded manifests after host initialization + * + * Expects: + * - `host` is an initialized Electron runtime plugin host + * - `manifests` reflect the latest loaded manifest snapshot at setup time + * + * Returns: + * - A stable object containing host instance and manifest list + */ +export interface PluginHostService { + host: PluginHost + manifests: ManifestV1[] +} + +/** + * Describes the widget manager surface required by plugin-driven gamelet APIs. + * + * Use when: + * - `setupPluginHost(...)` needs to open, update, or close extension-ui widgets + * + * Expects: + * - Widget ids remain stable and may be reused for the same module id + * + * Returns: + * - The minimal widget-manager contract consumed by the plugin host service + */ +export interface PluginHostGameletWidgetsManager { + openWindow: (params?: { id?: string }) => Promise + pushWidget: (payload: WidgetsAddPayload) => Promise + updateWidget: (payload: WidgetsUpdatePayload) => Promise + removeWidget: (id: string) => Promise + getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined +} + +/** + * Configures the runtime dependencies required by `setupPluginHost(...)`. + * + * Use when: + * - Wiring the plugin host during Electron startup + * - Providing test doubles for plugin-driven gamelet orchestration + * + * Expects: + * - `widgetsManager` is already initialized and ready to manage overlay widgets + * + * Returns: + * - N/A + */ +export interface SetupPluginHostOptions { + widgetsManager: PluginHostGameletWidgetsManager +} + +/** + * Binding announcement payload used by plugin-side runtime registration. + * + * Use when: + * - Announcing a new module for a registered kit + * - Reusing existing module ownership with the same module identifier + * + * Expects: + * - `moduleId` is unique per owner session/plugin pair + * - `kitId` and `kitModuleType` map to a registered kit descriptor + * - `config` is a JSON-compatible record + * + * Returns: + * - N/A + */ +export interface PluginHostBindingAnnounceInput { + moduleId: string + kitId: string + kitModuleType: string + config: Record +} + +/** + * Optional filters for listing announced bindings. + * + * Use when: + * - Querying only modules from one session + * - Querying modules belonging to one kit + * + * Expects: + * - Any provided key is treated as a strict equality filter + * + * Returns: + * - N/A + */ +export interface PluginHostBindingListOptions { + ownerSessionId?: string + kitId?: string +} + +/** + * Persisted plugin configuration snapshot. + * + * Use when: + * - Reading/writing enabled and auto-reload plugin state + * - Keeping known plugin manifest path metadata + * + * Expects: + * - Arrays contain plugin manifest names + * - `known` maps plugin names to canonical manifest paths + * + * Returns: + * - N/A + */ +export interface PluginConfig { + enabled: string[] + autoReload: string[] + known: Record +} + +/** + * Internal manifest record with resolved location and package version. + * + * Use when: + * - Loading plugin manifests from disk + * - Resolving runtime entrypoints and extension asset metadata + * + * Expects: + * - `manifest` is schema-validated + * - `path` points to `plugin.airi.json` + * - `rootDir` is the plugin root directory + * - `version` is discovered from package metadata or fallback + * + * Returns: + * - N/A + */ +export interface ManifestEntry { + manifest: ManifestV1 + path: string + rootDir: string + version: string +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 8425262f9..5c788e307 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -2,11 +2,11 @@ import type { BrowserWindow, Rectangle } from 'electron' import type { InferOutput } from 'valibot' import type { - PluginModuleWidgetPayload, WidgetsAddPayload, WidgetSnapshot, WidgetsUpdatePayload, } from '../../../shared/eventa' +import type { PluginModuleWidgetPayload } from '../../../shared/eventa/plugin/host' import type { I18n } from '../../libs/i18n' import type { ServerChannel } from '../../services/airi/channel-server' diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index dfc6afefc..749ebcaf2 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -26,6 +26,16 @@ import ResizeHandler from './components/ResizeHandler.vue' import { electronGetServerChannelConfig, + electronSettingsNavigate, + electronStartTrackMousePosition, + i18nSetLocale, +} from '../shared/eventa' +import { + electronPluginUpdateCapability, + pluginProtocolListProviders, + pluginProtocolListProvidersEventName, +} from '../shared/eventa/plugin/capabilities' +import { electronPluginInspect, electronPluginList, electronPluginLoad, @@ -33,13 +43,7 @@ import { electronPluginSetAutoReload, electronPluginSetEnabled, electronPluginUnload, - electronPluginUpdateCapability, - electronSettingsNavigate, - electronStartTrackMousePosition, - i18nSetLocale, - pluginProtocolListProviders, - pluginProtocolListProvidersEventName, -} from '../shared/eventa' +} from '../shared/eventa/plugin/host' import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback' import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace' import { useTamagotchiMcpToolsStore } from './stores/mcp-tools' diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts index 20c95240e..0f3b97b0d 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts @@ -31,7 +31,7 @@ vi.mock('@proj-airi/electron-vueuse', () => ({ })) describe('useTamagotchiPluginToolsStore', async () => { - const { useTamagotchiPluginToolsStore } = await import('./plugin-tools') + const { useTamagotchiPluginToolsStore } = await import('./tools') beforeEach(() => { setActivePinia(createPinia()) diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts index 84245b344..38930eabd 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts @@ -3,7 +3,7 @@ import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' import { rawTool } from '@xsai/tool' import { defineStore } from 'pinia' -import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../shared/eventa' +import { electronPluginInvokeTool, electronPluginListXsaiTools } from '../../shared/eventa/plugin/tools' /** * Registers Electron-backed plugin xsai tools into the shared LLM tools store. diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue index cc3408d59..8997c2a83 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue @@ -1,13 +1,14 @@