feat(plugin-sdk,stage-tamagotchi): rework of plugin structure, integrated kits api, now plugin manifest is plugin.airi.json
This commit is contained in:
@@ -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),
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+10
-3
@@ -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 }
|
||||
|
||||
@@ -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/',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, ExtensionStaticAssetManifestEntry>
|
||||
}): 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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<typeof useLogg>
|
||||
getConfig: () => PluginConfig
|
||||
listEntries: () => ManifestEntry[]
|
||||
isLoaded: (name: string) => boolean
|
||||
resolveWatchPaths: (name: string) => string[]
|
||||
reload: (name: string, changedPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>()
|
||||
const autoReloadTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const autoReloadWatchers = new Map<string, FSWatcher[]>()
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
manifestEntryByName: Map<string, ManifestEntry>
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* 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<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* 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<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* 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<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* 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<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* 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<PluginHostDebugSnapshot>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<userData>/plugins/v1`
|
||||
*
|
||||
* Returns:
|
||||
* - The internal bootstrap service that powers the public plugin-host IPC facade
|
||||
*/
|
||||
export async function setupPluginHostHostService(
|
||||
options: SetupPluginHostOptions,
|
||||
): Promise<PluginHostHostService> {
|
||||
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<string>()
|
||||
const loadedSessionIds = new Map<string, string>()
|
||||
const moduleAssetTokenCache = new Map<string, string>()
|
||||
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string>, 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<typeof useLogg>,
|
||||
): Promise<ManifestEntry[]> {
|
||||
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<string, unknown>
|
||||
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<string>,
|
||||
): 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<string>
|
||||
}): 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<ManifestEntry[]>
|
||||
listEntries: () => ManifestEntry[]
|
||||
listManifests: () => ManifestV1[]
|
||||
findManifestEntry: (name: string) => ManifestEntry | undefined
|
||||
getManifestEntryByName: () => Map<string, ManifestEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof useLogg>
|
||||
}): PluginHostRegistry {
|
||||
let entries: ManifestEntry[] = []
|
||||
let manifests: ManifestV1[] = []
|
||||
let manifestEntryByName = new Map<string, ManifestEntry>()
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: <T>(key: string, resolver: () => Promise<T> | T) => void
|
||||
announceCapability: (key: string, metadata?: Record<string, unknown>) => {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PluginConfig {
|
||||
enabled: string[]
|
||||
known: Record<string, { path: string }>
|
||||
}
|
||||
|
||||
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<string>, 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<typeof useLogg>): Promise<ManifestEntry[]> {
|
||||
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<string>): 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<PluginHostService> {
|
||||
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<string>()
|
||||
const loadedSessionIds = new Map<string, string>()
|
||||
|
||||
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<PluginHostService> {
|
||||
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<PluginHostService> {
|
||||
|
||||
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<PluginHostService> {
|
||||
}
|
||||
})
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
+207
@@ -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<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : 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<HostDataRecord> {
|
||||
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, unknown>
|
||||
}): 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<string, unknown>
|
||||
windowSize?: WidgetWindowSize
|
||||
existingComponentProps?: Record<string, unknown>
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
...params.existingComponentProps,
|
||||
moduleId: params.moduleId,
|
||||
title: params.title,
|
||||
...(params.windowSize ? { windowSize: params.windowSize } : {}),
|
||||
...(params.payload ? { payload: params.payload } : {}),
|
||||
}
|
||||
}
|
||||
@@ -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<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : 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<string, Set<string>>()
|
||||
const cleanupPromisesBySession = new Map<string, Promise<void>>()
|
||||
|
||||
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<string>()
|
||||
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
|
||||
},
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createGameletHostContribution>['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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>) {
|
||||
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<string, ManifestEntry>,
|
||||
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<string, unknown> : {}
|
||||
const widgetConfig = isPlainObject(config.widget) ? config.widget as Record<string, unknown> : {}
|
||||
const iframeConfig = isPlainObject(widgetConfig.iframe) ? widgetConfig.iframe as Record<string, unknown> : {}
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<void>
|
||||
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
||||
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
||||
removeWidget: (id: string) => Promise<void>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, { path: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa/plugin/host'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import { electronPluginGetAssetBaseUrl, electronPluginInspect } from '../../../../shared/eventa'
|
||||
import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets'
|
||||
import { electronPluginInspect } from '../../../../shared/eventa/plugin/host'
|
||||
import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module'
|
||||
import { useIframeMessagePort } from '../composables/use-iframe-message-port'
|
||||
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../host'
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
@@ -22,6 +22,7 @@ function firstString(...values: unknown[]) {
|
||||
}
|
||||
|
||||
const trailingSlashesPattern = /\/+$/
|
||||
const mountedPluginAssetPathPrefix = '/_airi/extensions/'
|
||||
|
||||
/**
|
||||
* Resolves the inspected extension UI module snapshot and derives iframe-facing config for the host.
|
||||
@@ -123,7 +124,7 @@ export function useExtensionUIForModule(options: {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (src.startsWith('/_airi/plugins/')) {
|
||||
if (src.startsWith(mountedPluginAssetPathPrefix)) {
|
||||
const baseUrl = pluginAssetBaseUrl.value
|
||||
if (!baseUrl) {
|
||||
return undefined
|
||||
@@ -136,7 +137,7 @@ export function useExtensionUIForModule(options: {
|
||||
})
|
||||
|
||||
const iframeMountError = computed(() => {
|
||||
if (!iframeSrc.value?.startsWith('/_airi/plugins/')) {
|
||||
if (!iframeSrc.value?.startsWith(mountedPluginAssetPathPrefix)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { MaybeElementRef } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
|
||||
|
||||
import { unrefElement } from '@vueuse/core'
|
||||
import { onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginHostModuleSummary } from '../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary } from '../../../shared/eventa/plugin/host'
|
||||
|
||||
const extensionUiDispatchReservedPropKeys = new Set([
|
||||
'modelValue',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host'
|
||||
|
||||
import { defineEventa } from '@moeru/eventa'
|
||||
|
||||
|
||||
+4
-119
@@ -51,19 +51,6 @@ export interface ElectronUpdaterPreferences {
|
||||
export const electronGetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:get-preferences')
|
||||
export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterPreferences, ElectronUpdaterPreferences>('eventa:invoke:electron:auto-updater:set-preferences')
|
||||
|
||||
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
|
||||
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
|
||||
export const electronPluginSetAutoReload = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean }>('eventa:invoke:electron:plugins:set-auto-reload')
|
||||
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
|
||||
export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:load')
|
||||
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
|
||||
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
|
||||
export const electronPluginGetAssetBaseUrl = defineInvokeEventa<string>('eventa:invoke:electron:plugins:asset-base-url')
|
||||
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
|
||||
|
||||
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
|
||||
|
||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
|
||||
@@ -106,15 +93,6 @@ export interface WidgetWindowSize {
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export interface PluginModuleWidgetPayload {
|
||||
moduleId: string
|
||||
title?: string
|
||||
widgetComponent?: string
|
||||
componentProps?: Record<string, any>
|
||||
payload?: Record<string, any>
|
||||
windowSize?: WidgetWindowSize
|
||||
}
|
||||
|
||||
export interface WidgetsAddPayload {
|
||||
id?: string
|
||||
componentName: string
|
||||
@@ -143,96 +121,6 @@ export interface WidgetsUpdatePayload {
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface PluginManifestSummary {
|
||||
name: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
path: string
|
||||
enabled: boolean
|
||||
autoReload: boolean
|
||||
loaded: boolean
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
export interface PluginRegistrySnapshot {
|
||||
root: string
|
||||
plugins: PluginManifestSummary[]
|
||||
}
|
||||
|
||||
// TODO: Replace these manually duplicated IPC types with re-exports from
|
||||
// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared
|
||||
// eventa layer can depend on the SDK without introducing unwanted coupling.
|
||||
export interface PluginCapabilityPayload {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PluginCapabilityState {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PluginHostSessionSummary {
|
||||
id: string
|
||||
manifestName: string
|
||||
phase: string
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
export interface PluginHostKitCapabilitySummary {
|
||||
key: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface PluginHostKitSummary {
|
||||
kitId: string
|
||||
version: string
|
||||
capabilities: PluginHostKitCapabilitySummary[]
|
||||
runtimes: Array<'electron' | 'node' | 'web'>
|
||||
}
|
||||
|
||||
export interface PluginHostModuleSummary {
|
||||
moduleId: string
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
state: 'announced' | 'active' | 'degraded' | 'withdrawn'
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
revision: number
|
||||
updatedAt: number
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PluginHostDebugSnapshot {
|
||||
registry: PluginRegistrySnapshot
|
||||
sessions: PluginHostSessionSummary[]
|
||||
kits: PluginHostKitSummary[]
|
||||
modules: PluginHostModuleSummary[]
|
||||
capabilities: PluginCapabilityState[]
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
export interface ElectronPluginToolDescriptor {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronPluginXsaiToolDefinition {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ElectronMcpStdioServerConfig {
|
||||
command: string
|
||||
args?: string[]
|
||||
@@ -292,13 +180,6 @@ export const electronMcpApplyAndRestart = defineInvokeEventa<ElectronMcpStdioApp
|
||||
export const electronMcpGetRuntimeStatus = defineInvokeEventa<ElectronMcpStdioRuntimeStatus>('eventa:invoke:electron:mcp:get-runtime-status')
|
||||
export const electronMcpListTools = defineInvokeEventa<ElectronMcpToolDescriptor[]>('eventa:invoke:electron:mcp:list-tools')
|
||||
export const electronMcpCallTool = defineInvokeEventa<ElectronMcpCallToolResult, ElectronMcpCallToolPayload>('eventa:invoke:electron:mcp:call-tool')
|
||||
export const electronPluginListAgentTools = defineInvokeEventa<ElectronPluginToolDescriptor[]>('eventa:invoke:electron:plugins:tools:list')
|
||||
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolDefinition[]>('eventa:invoke:electron:plugins:tools:list-xsai')
|
||||
export const electronPluginInvokeTool = defineInvokeEventa<unknown, {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
input: unknown
|
||||
}>('eventa:invoke:electron:plugins:tools:invoke')
|
||||
|
||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||
@@ -381,5 +262,9 @@ export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electr
|
||||
export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
|
||||
export const i18nGetLocale = defineInvokeEventa<Locale>('eventa:invoke:electron:i18n:get-locale')
|
||||
|
||||
export * from './plugin/assets'
|
||||
export * from './plugin/capabilities'
|
||||
export * from './plugin/host'
|
||||
export * from './plugin/tools'
|
||||
export { electron } from '@proj-airi/electron-eventa'
|
||||
export * from '@proj-airi/electron-eventa/electron-updater'
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export const electronPluginGetAssetBaseUrl = defineInvokeEventa<string>('eventa:invoke:electron:plugins:asset-base-url')
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
/**
|
||||
* Plugin capability state change payload reported by renderer or plugin code.
|
||||
*
|
||||
* Use when:
|
||||
* - Updating one plugin capability lifecycle state through the host bridge
|
||||
*
|
||||
* Expects:
|
||||
* - `key` matches a capability known to the plugin host or plugin SDK
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginCapabilityPayload {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin capability snapshot stored by the host.
|
||||
*
|
||||
* Use when:
|
||||
* - Inspecting plugin capability lifecycle state in renderer tooling
|
||||
*
|
||||
* Expects:
|
||||
* - `updatedAt` is a millisecond timestamp from the host process
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginCapabilityState {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
|
||||
// TODO: Replace these manually duplicated IPC types with re-exports from
|
||||
// @proj-airi/plugin-sdk (CapabilityDescriptor) once stage-ui and the shared
|
||||
// eventa layer can depend on the SDK without introducing unwanted coupling.
|
||||
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
electronPluginGetAssetBaseUrl,
|
||||
electronPluginInspect,
|
||||
electronPluginInvokeTool,
|
||||
electronPluginList,
|
||||
electronPluginListAgentTools,
|
||||
electronPluginListXsaiTools,
|
||||
electronPluginLoad,
|
||||
electronPluginLoadEnabled,
|
||||
electronPluginSetAutoReload,
|
||||
electronPluginSetEnabled,
|
||||
electronPluginUnload,
|
||||
electronPluginUpdateCapability,
|
||||
pluginProtocolListProviders,
|
||||
pluginProtocolListProvidersEventName,
|
||||
} from '../index'
|
||||
import {
|
||||
electronPluginGetAssetBaseUrl as electronPluginGetAssetBaseUrlFromAssets,
|
||||
} from './assets'
|
||||
import {
|
||||
electronPluginUpdateCapability as electronPluginUpdateCapabilityFromCapabilities,
|
||||
pluginProtocolListProvidersEventName as pluginProtocolListProvidersEventNameFromCapabilities,
|
||||
pluginProtocolListProviders as pluginProtocolListProvidersFromCapabilities,
|
||||
} from './capabilities'
|
||||
import {
|
||||
electronPluginInspect as electronPluginInspectFromHost,
|
||||
electronPluginList as electronPluginListFromHost,
|
||||
electronPluginLoadEnabled as electronPluginLoadEnabledFromHost,
|
||||
electronPluginLoad as electronPluginLoadFromHost,
|
||||
electronPluginSetAutoReload as electronPluginSetAutoReloadFromHost,
|
||||
electronPluginSetEnabled as electronPluginSetEnabledFromHost,
|
||||
electronPluginUnload as electronPluginUnloadFromHost,
|
||||
} from './host'
|
||||
import {
|
||||
electronPluginInvokeTool as electronPluginInvokeToolFromTools,
|
||||
electronPluginListAgentTools as electronPluginListAgentToolsFromTools,
|
||||
electronPluginListXsaiTools as electronPluginListXsaiToolsFromTools,
|
||||
} from './tools'
|
||||
|
||||
/**
|
||||
* Characterizes the Eventa domain split while keeping the barrel compatible.
|
||||
*
|
||||
* Use when:
|
||||
* - Refactoring plugin IPC contracts into focused shared modules
|
||||
* - Verifying existing `shared/eventa` imports still resolve to the same definitions
|
||||
*
|
||||
* Expects:
|
||||
* - Domain modules remain the source of truth for plugin IPC contracts
|
||||
* - The compatibility barrel re-exports those exact contract objects
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*
|
||||
* @example
|
||||
* describe('plugin Eventa domain modules', () => {
|
||||
* expect(electronPluginList).toBe(electronPluginListFromHost)
|
||||
* })
|
||||
*/
|
||||
describe('plugin Eventa domain modules', () => {
|
||||
/**
|
||||
* Keeps plugin host IPC definitions source-compatible across the split.
|
||||
*
|
||||
* Use when:
|
||||
* - Consumers still import plugin host IPC from `shared/eventa`
|
||||
*
|
||||
* Expects:
|
||||
* - The barrel to re-export the same Eventa definitions from `plugin/host.ts`
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*
|
||||
* @example
|
||||
* it('re-exports plugin host contracts through the compatibility barrel', () => {
|
||||
* expect(electronPluginInspect).toBe(electronPluginInspectFromHost)
|
||||
* })
|
||||
*/
|
||||
it('re-exports plugin host contracts through the compatibility barrel', () => {
|
||||
expect(electronPluginList).toBe(electronPluginListFromHost)
|
||||
expect(electronPluginSetEnabled).toBe(electronPluginSetEnabledFromHost)
|
||||
expect(electronPluginSetAutoReload).toBe(electronPluginSetAutoReloadFromHost)
|
||||
expect(electronPluginLoadEnabled).toBe(electronPluginLoadEnabledFromHost)
|
||||
expect(electronPluginLoad).toBe(electronPluginLoadFromHost)
|
||||
expect(electronPluginUnload).toBe(electronPluginUnloadFromHost)
|
||||
expect(electronPluginInspect).toBe(electronPluginInspectFromHost)
|
||||
})
|
||||
|
||||
/**
|
||||
* Keeps plugin capability, tool, and asset IPC grouped under focused modules.
|
||||
*
|
||||
* Use when:
|
||||
* - Verifying the split ownership for non-host plugin IPC contracts
|
||||
*
|
||||
* Expects:
|
||||
* - The barrel to re-export the same Eventa definitions from the focused modules
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*
|
||||
* @example
|
||||
* it('re-exports plugin capability, tool, and asset contracts through the barrel', () => {
|
||||
* expect(pluginProtocolListProviders).toBe(pluginProtocolListProvidersFromCapabilities)
|
||||
* })
|
||||
*/
|
||||
it('re-exports plugin capability, tool, and asset contracts through the barrel', () => {
|
||||
expect(pluginProtocolListProvidersEventName).toBe(pluginProtocolListProvidersEventNameFromCapabilities)
|
||||
expect(pluginProtocolListProviders).toBe(pluginProtocolListProvidersFromCapabilities)
|
||||
expect(electronPluginUpdateCapability).toBe(electronPluginUpdateCapabilityFromCapabilities)
|
||||
expect(electronPluginListAgentTools).toBe(electronPluginListAgentToolsFromTools)
|
||||
expect(electronPluginListXsaiTools).toBe(electronPluginListXsaiToolsFromTools)
|
||||
expect(electronPluginInvokeTool).toBe(electronPluginInvokeToolFromTools)
|
||||
expect(electronPluginGetAssetBaseUrl).toBe(electronPluginGetAssetBaseUrlFromAssets)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { PluginCapabilityState } from './capabilities'
|
||||
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
/**
|
||||
* Window sizing metadata forwarded through plugin widget payloads.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin module wants the host to size an extension UI widget window
|
||||
*
|
||||
* Expects:
|
||||
* - Dimensions are pixel values understood by the Electron window layer
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
interface PluginModuleWidgetWindowSize {
|
||||
width: number
|
||||
height: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-driven widget payload forwarded into the extension UI host.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin module mounts its widget UI inside the renderer
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` matches a registered plugin module binding
|
||||
* - Records remain structured-clone-safe for Eventa transport
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginModuleWidgetPayload {
|
||||
moduleId: string
|
||||
title?: string
|
||||
widgetComponent?: string
|
||||
componentProps?: Record<string, any>
|
||||
payload?: Record<string, any>
|
||||
windowSize?: PluginModuleWidgetWindowSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-facing plugin manifest summary.
|
||||
*
|
||||
* Use when:
|
||||
* - Listing discovered plugins in devtools or settings surfaces
|
||||
*
|
||||
* Expects:
|
||||
* - `path` points to the manifest file on disk
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginManifestSummary {
|
||||
name: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
path: string
|
||||
enabled: boolean
|
||||
autoReload: boolean
|
||||
loaded: boolean
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the current plugin manifest registry.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer code needs the latest enabled and loaded plugin list
|
||||
*
|
||||
* Expects:
|
||||
* - `plugins` is a stable snapshot derived from the current registry state
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginRegistrySnapshot {
|
||||
root: string
|
||||
plugins: PluginManifestSummary[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Active plugin session summary.
|
||||
*
|
||||
* Use when:
|
||||
* - Inspecting the live plugin host runtime state
|
||||
*
|
||||
* Expects:
|
||||
* - `id` stays stable for the lifetime of one started plugin session
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostSessionSummary {
|
||||
id: string
|
||||
manifestName: string
|
||||
phase: string
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability summary exposed by one registered kit.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer tooling needs to show what actions a kit supports
|
||||
*
|
||||
* Expects:
|
||||
* - `actions` contains unique action identifiers
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostKitCapabilitySummary {
|
||||
key: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered kit summary exposed by the plugin host.
|
||||
*
|
||||
* Use when:
|
||||
* - Inspecting kit registration state from renderer tooling
|
||||
*
|
||||
* Expects:
|
||||
* - `capabilities` matches the installed kit descriptor state
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostKitSummary {
|
||||
kitId: string
|
||||
version: string
|
||||
capabilities: PluginHostKitCapabilitySummary[]
|
||||
runtimes: Array<'electron' | 'node' | 'web'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered plugin module binding summary.
|
||||
*
|
||||
* Use when:
|
||||
* - Inspecting plugin modules and deriving renderer-side extension UI state
|
||||
*
|
||||
* Expects:
|
||||
* - `config` is JSON-compatible and structured-clone-safe
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostModuleSummary {
|
||||
moduleId: string
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
state: 'announced' | 'active' | 'degraded' | 'withdrawn'
|
||||
runtime: 'electron' | 'node' | 'web'
|
||||
revision: number
|
||||
updatedAt: number
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full plugin host inspection snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer devtools need registry, session, kit, and module state together
|
||||
*
|
||||
* Expects:
|
||||
* - All arrays are snapshots captured at `refreshedAt`
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostDebugSnapshot {
|
||||
registry: PluginRegistrySnapshot
|
||||
sessions: PluginHostSessionSummary[]
|
||||
kits: PluginHostKitSummary[]
|
||||
modules: PluginHostModuleSummary[]
|
||||
capabilities: PluginCapabilityState[]
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
|
||||
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
|
||||
export const electronPluginSetAutoReload = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean }>('eventa:invoke:electron:plugins:set-auto-reload')
|
||||
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
|
||||
export const electronPluginLoad = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:load')
|
||||
export const electronPluginUnload = defineInvokeEventa<PluginRegistrySnapshot, { name: string }>('eventa:invoke:electron:plugins:unload')
|
||||
export const electronPluginInspect = defineInvokeEventa<PluginHostDebugSnapshot>('eventa:invoke:electron:plugins:inspect')
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
/**
|
||||
* Renderer-facing plugin tool descriptor used by agent tooling UIs.
|
||||
*
|
||||
* Use when:
|
||||
* - Listing plugin-backed tools for discovery or debugging
|
||||
*
|
||||
* Expects:
|
||||
* - Activation metadata is already normalized for renderer display
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface ElectronPluginToolDescriptor {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized xsai tool definition exposed by the plugin host.
|
||||
*
|
||||
* Use when:
|
||||
* - Registering plugin-backed xsai tools in the renderer
|
||||
*
|
||||
* Expects:
|
||||
* - `parameters` is a provider-compliant JSON Schema object
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface ElectronPluginXsaiToolDefinition {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const electronPluginListAgentTools = defineInvokeEventa<ElectronPluginToolDescriptor[]>('eventa:invoke:electron:plugins:tools:list')
|
||||
export const electronPluginListXsaiTools = defineInvokeEventa<ElectronPluginXsaiToolDefinition[]>('eventa:invoke:electron:plugins:tools:list-xsai')
|
||||
export const electronPluginInvokeTool = defineInvokeEventa<unknown, {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
input: unknown
|
||||
}>('eventa:invoke:electron:plugins:tools:invoke')
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { ContextInit } from '@proj-airi/plugin-sdk'
|
||||
|
||||
import type { TamagotchiToolContext } from './index'
|
||||
|
||||
import { object, optional, string } from 'valibot'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -12,9 +16,19 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => {
|
||||
const registerBinding = vi.fn()
|
||||
const registerTool = vi.fn()
|
||||
const openGamelet = vi.fn()
|
||||
const configureGamelet = vi.fn()
|
||||
const closeGamelet = vi.fn()
|
||||
const isGameletOpen = vi.fn(() => true)
|
||||
|
||||
const ctx = {
|
||||
const ctx: Pick<ContextInit, 'apis'> & TamagotchiToolContext = {
|
||||
apis: {
|
||||
gamelets: {
|
||||
open: openGamelet,
|
||||
configure: configureGamelet,
|
||||
close: closeGamelet,
|
||||
isOpen: isGameletOpen,
|
||||
},
|
||||
tools: {
|
||||
register: registerTool,
|
||||
},
|
||||
@@ -39,11 +53,15 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
announce: registerBinding,
|
||||
update: registerBinding,
|
||||
activate: registerBinding,
|
||||
withdraw: registerBinding,
|
||||
},
|
||||
providers: {
|
||||
listProviders: async () => [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const gamelet = await defineGamelet(ctx as never, {
|
||||
const gamelet = await defineGamelet(ctx, {
|
||||
id: 'chess',
|
||||
title: 'Chess',
|
||||
entrypoint: './ui/index.html',
|
||||
@@ -55,7 +73,7 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
],
|
||||
})
|
||||
|
||||
await defineToolset(ctx as never, {
|
||||
await defineToolset(ctx, {
|
||||
tools: [
|
||||
{
|
||||
id: 'play_chess',
|
||||
@@ -115,5 +133,100 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
await registerTool.mock.calls[0]?.[0].execute({})
|
||||
|
||||
expect(openGamelet).not.toHaveBeenCalled()
|
||||
expect(configureGamelet).not.toHaveBeenCalled()
|
||||
expect(closeGamelet).not.toHaveBeenCalled()
|
||||
expect(isGameletOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
|
||||
* expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
|
||||
*/
|
||||
it('passes host-backed gamelet operations through defineToolset execution context', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const openGamelet = vi.fn()
|
||||
const configureGamelet = vi.fn()
|
||||
const closeGamelet = vi.fn()
|
||||
const isGameletOpen = vi.fn(() => true)
|
||||
|
||||
const ctx: TamagotchiToolContext = {
|
||||
apis: {
|
||||
gamelets: {
|
||||
open: openGamelet,
|
||||
configure: configureGamelet,
|
||||
close: closeGamelet,
|
||||
isOpen: isGameletOpen,
|
||||
},
|
||||
tools: {
|
||||
register: registerTool,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await defineToolset(ctx, {
|
||||
tools: [
|
||||
{
|
||||
id: 'drive_chess',
|
||||
title: 'Drive Chess',
|
||||
description: 'Drive a host-backed chess gamelet.',
|
||||
inputSchema: object({}),
|
||||
async isAvailable(context) {
|
||||
return await context.gamelets.isOpen('chess')
|
||||
},
|
||||
async execute(_input, context) {
|
||||
await context.gamelets.open('chess', { opening: 'sicilian' })
|
||||
await context.gamelets.configure('chess', { side: 'black' })
|
||||
await context.gamelets.close('chess')
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const registration = registerTool.mock.calls[0]?.[0]
|
||||
expect(registration).toBeDefined()
|
||||
await expect(registration?.availability?.()).resolves.toBe(true)
|
||||
await expect(registration?.execute({})).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(isGameletOpen).toHaveBeenCalledWith('chess')
|
||||
expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
|
||||
expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
|
||||
expect(closeGamelet).toHaveBeenCalledWith('chess')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(defineToolset({ apis: { tools: { register: registerTool } } } as never, options)).rejects.toThrow(/gamelet API/i)
|
||||
*/
|
||||
it('fails with a clear error when the tamagotchi gamelet API is not available', async () => {
|
||||
const registerTool = vi.fn()
|
||||
|
||||
await expect(defineToolset({
|
||||
apis: {
|
||||
tools: {
|
||||
register: registerTool,
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
tools: [
|
||||
{
|
||||
id: 'drive_chess',
|
||||
title: 'Drive Chess',
|
||||
description: 'Drive a host-backed chess gamelet.',
|
||||
inputSchema: object({}),
|
||||
async execute() {
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
],
|
||||
})).rejects.toThrow(/gamelet API/i)
|
||||
|
||||
expect(registerTool).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,46 @@ import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { parse } from 'valibot'
|
||||
import { toJsonSchema } from 'xsschema'
|
||||
|
||||
/**
|
||||
* Describes the stage-tamagotchi gamelet API expected on `ctx.apis`.
|
||||
*
|
||||
* Use when:
|
||||
* - Tool execution wants to open, configure, close, or inspect host-managed gamelet surfaces
|
||||
* - Runtime validation needs a structural contract independent from `@proj-airi/plugin-sdk`
|
||||
*
|
||||
* Expects:
|
||||
* - The stage-tamagotchi host contribution installs `gamelets` on the plugin session API object
|
||||
*
|
||||
* Returns:
|
||||
* - The host-backed gamelet control surface exposed to tool callbacks
|
||||
*/
|
||||
export interface ToolExecutionGameletApi {
|
||||
open: (id: string, params?: HostDataRecord) => Promise<void>
|
||||
configure: (id: string, patch: HostDataRecord) => Promise<void>
|
||||
close: (id: string) => Promise<void>
|
||||
isOpen: (id: string) => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the tamagotchi-flavored plugin context accepted by {@link defineToolset}.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin host exposes tool registration plus the stage-owned `gamelets` surface
|
||||
* - Tests want to model the runtime shape without relying on baked-in SDK typing
|
||||
*
|
||||
* Expects:
|
||||
* - `apis.tools.register` is available
|
||||
* - `apis.gamelets` is installed by the stage-tamagotchi host contribution
|
||||
*
|
||||
* Returns:
|
||||
* - A context shape compatible with the tamagotchi tool helper
|
||||
*/
|
||||
export interface TamagotchiToolContext {
|
||||
apis: Pick<ContextInit['apis'], 'tools'> & {
|
||||
gamelets: ToolExecutionGameletApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the host services available while checking or executing a plugin tool.
|
||||
*
|
||||
@@ -19,12 +59,7 @@ import { toJsonSchema } from 'xsschema'
|
||||
* - A runtime capability surface for tool execution
|
||||
*/
|
||||
export interface ToolExecutionContext {
|
||||
gamelets: {
|
||||
open: (id: string, params?: Record<string, unknown>) => Promise<void>
|
||||
configure: (id: string, patch: Record<string, unknown>) => Promise<void>
|
||||
close: (id: string) => Promise<void>
|
||||
isOpen: (id: string) => boolean
|
||||
}
|
||||
gamelets: ToolExecutionGameletApi
|
||||
|
||||
// TODO:
|
||||
// Add character/runtime orchestration APIs after the gamelet/tool path is stable.
|
||||
@@ -85,14 +120,36 @@ export interface DefineToolsetOptions<TInputSchema = unknown> {
|
||||
tools: Array<PluginToolDefinition<TInputSchema>>
|
||||
}
|
||||
|
||||
function createToolExecutionContext(): ToolExecutionContext {
|
||||
function isToolExecutionGameletApi(value: unknown): value is ToolExecutionGameletApi {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Partial<Record<keyof ToolExecutionGameletApi, unknown>>
|
||||
|
||||
return typeof candidate.open === 'function'
|
||||
&& typeof candidate.configure === 'function'
|
||||
&& typeof candidate.close === 'function'
|
||||
&& typeof candidate.isOpen === 'function'
|
||||
}
|
||||
|
||||
function getToolExecutionGameletApi(
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
): ToolExecutionGameletApi {
|
||||
const gamelets = (ctx.apis as Record<string, unknown>).gamelets
|
||||
|
||||
if (!isToolExecutionGameletApi(gamelets)) {
|
||||
throw new Error('stage-tamagotchi gamelet API is not available on `ctx.apis.gamelets`.')
|
||||
}
|
||||
|
||||
return gamelets
|
||||
}
|
||||
|
||||
function createToolExecutionContext(
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
): ToolExecutionContext {
|
||||
return {
|
||||
gamelets: {
|
||||
async open() {},
|
||||
async configure() {},
|
||||
async close() {},
|
||||
isOpen: () => false,
|
||||
},
|
||||
gamelets: getToolExecutionGameletApi(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,10 +242,10 @@ async function serializeToolParameters(inputSchema: unknown): Promise<HostDataRe
|
||||
* - Resolves after all tool registrations complete
|
||||
*/
|
||||
export async function defineToolset(
|
||||
ctx: Pick<ContextInit, 'apis'>,
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
options: DefineToolsetOptions,
|
||||
): Promise<void> {
|
||||
const executionContext = createToolExecutionContext()
|
||||
const executionContext = createToolExecutionContext(ctx)
|
||||
|
||||
for (const definition of options.tools) {
|
||||
await ctx.apis.tools.register({
|
||||
|
||||
@@ -188,6 +188,7 @@ describe('for PluginHost', () => {
|
||||
const kitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits'
|
||||
const toolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools'
|
||||
const widgetKitBindingsResourceKey = 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings'
|
||||
const customSessionApiPingEventName = 'proj-airi:plugin-sdk:apis:client:test-session-api:ping'
|
||||
const testManifest = {
|
||||
apiVersion: 'v1' as const,
|
||||
kind: 'manifest.plugin.airi.moeru.ai' as const,
|
||||
@@ -232,6 +233,16 @@ describe('for PluginHost', () => {
|
||||
],
|
||||
} satisfies ModulePermissionDeclaration,
|
||||
}
|
||||
const customSessionApiManifest = {
|
||||
...testManifest,
|
||||
permissions: {
|
||||
...testManifest.permissions,
|
||||
apis: [
|
||||
...(testManifest.permissions.apis ?? []),
|
||||
{ key: customSessionApiPingEventName, actions: ['invoke'] },
|
||||
],
|
||||
} satisfies ModulePermissionDeclaration,
|
||||
}
|
||||
const deniedKitReadManifest = {
|
||||
...testManifest,
|
||||
permissions: {
|
||||
@@ -304,8 +315,8 @@ describe('for PluginHost', () => {
|
||||
|
||||
await expect(host.init(session.id)).rejects.toThrow('Plugin initialization aborted by plugin: test-plugin-no-connect')
|
||||
|
||||
const latest = host.getSession(session.id)
|
||||
expect(latest?.phase).toBe('failed')
|
||||
expect(session.phase).toBe('stopped')
|
||||
expect(host.getSession(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should expose runtime-compatible kits through bound plugin apis', async () => {
|
||||
@@ -366,6 +377,54 @@ describe('for PluginHost', () => {
|
||||
})).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should let contributions install custom session api namespaces', async () => {
|
||||
const installContribution = vi.fn()
|
||||
const callCustomNamespace = vi.fn(({ ownerPluginId, message }: { ownerPluginId: string, message: string }) => {
|
||||
return `${ownerPluginId}:${message}`
|
||||
})
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
transport: { kind: 'in-memory' },
|
||||
contributions: [{
|
||||
install(context) {
|
||||
installContribution()
|
||||
context.registerSessionApi('testSessionApi', ({ session, assertPermission }) => ({
|
||||
async ping(message: string) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: customSessionApiPingEventName,
|
||||
})
|
||||
|
||||
return callCustomNamespace({
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
message,
|
||||
})
|
||||
},
|
||||
}))
|
||||
},
|
||||
}],
|
||||
})
|
||||
reportPluginCapability(host, {
|
||||
key: providersCapability,
|
||||
state: 'ready',
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
|
||||
const session = await host.start(customSessionApiManifest, { cwd: '' })
|
||||
const testSessionApi = (session.apis as Record<string, unknown>).testSessionApi as {
|
||||
ping: (message: string) => Promise<string>
|
||||
}
|
||||
|
||||
expect(installContribution).toHaveBeenCalledTimes(1)
|
||||
expect(testSessionApi).toBeDefined()
|
||||
await expect(testSessionApi.ping('hello')).resolves.toBe(`${session.identity.plugin.id}:hello`)
|
||||
expect(callCustomNamespace).toHaveBeenCalledWith({
|
||||
ownerPluginId: session.identity.plugin.id,
|
||||
message: 'hello',
|
||||
})
|
||||
})
|
||||
|
||||
it('should register available plugin tools and expose serialized xsai schemas', async () => {
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
@@ -454,6 +513,167 @@ describe('for PluginHost', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide and reject tools registered by stopped sessions', async () => {
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
transport: { kind: 'in-memory' },
|
||||
})
|
||||
reportPluginCapability(host, {
|
||||
key: providersCapability,
|
||||
state: 'ready',
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
|
||||
const session = await host.start(dynamicApiManifest, { cwd: '' })
|
||||
|
||||
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 expect(host.listAvailableToolDescriptors()).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'play_chess' }),
|
||||
])
|
||||
|
||||
host.stop(session.id)
|
||||
|
||||
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
|
||||
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
|
||||
await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', {})).rejects.toThrow(
|
||||
`Plugin tool not found: ${session.identity.plugin.id}:play_chess`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should clean up sessions modules and tools when a session-ready hook throws during init', async () => {
|
||||
const readyHookError = new Error('session-ready hook failed')
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
transport: { kind: 'in-memory' },
|
||||
contributions: [{
|
||||
install(context) {
|
||||
context.registerLifecycleHook('session-ready', () => {
|
||||
throw readyHookError
|
||||
})
|
||||
},
|
||||
}],
|
||||
})
|
||||
registerWidgetKit(host)
|
||||
reportPluginCapability(host, {
|
||||
key: providersCapability,
|
||||
state: 'ready',
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
|
||||
const session = await host.load(dynamicApiManifest, { cwd: '' })
|
||||
session.plugin = {
|
||||
...session.plugin,
|
||||
setupModules: async ({ apis }) => {
|
||||
await apis.tools.register({
|
||||
tool: {
|
||||
id: 'ready_hook_tool',
|
||||
title: 'Ready Hook Tool',
|
||||
description: 'Registered before the ready hook throws.',
|
||||
activation: {
|
||||
keywords: ['ready'],
|
||||
patterns: ['ready'],
|
||||
},
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
|
||||
await apis.bindings.announce({
|
||||
moduleId: 'module-ready-hook-failure',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'window',
|
||||
config: { route: '/widgets/ready-hook-failure' },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
await expect(host.init(session.id)).rejects.toThrow('session-ready hook failed')
|
||||
|
||||
expect(session.phase).toBe('stopped')
|
||||
expect(host.getSession(session.id)).toBeUndefined()
|
||||
expect(host.getBinding('module-ready-hook-failure')).toBeUndefined()
|
||||
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
|
||||
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
|
||||
await expect(host.invokeTool(session.identity.plugin.id, 'ready_hook_tool', {})).rejects.toThrow(
|
||||
`Plugin tool not found: ${session.identity.plugin.id}:ready_hook_tool`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should finish stop cleanup before rethrowing a session-stopped hook failure', async () => {
|
||||
const stoppedHookError = new Error('session-stopped hook failed')
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
transport: { kind: 'in-memory' },
|
||||
contributions: [{
|
||||
install(context) {
|
||||
context.registerLifecycleHook('session-stopped', () => {
|
||||
throw stoppedHookError
|
||||
})
|
||||
},
|
||||
}],
|
||||
})
|
||||
registerWidgetKit(host)
|
||||
reportPluginCapability(host, {
|
||||
key: providersCapability,
|
||||
state: 'ready',
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
|
||||
const session = await host.start(dynamicApiManifest, { cwd: '' })
|
||||
await session.apis.tools.register({
|
||||
tool: {
|
||||
id: 'stopped_hook_tool',
|
||||
title: 'Stopped Hook Tool',
|
||||
description: 'Should be cleaned up before stop rethrows.',
|
||||
activation: {
|
||||
keywords: ['stop'],
|
||||
patterns: ['stop'],
|
||||
},
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
await session.apis.bindings.announce({
|
||||
moduleId: 'module-stopped-hook-failure',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'window',
|
||||
config: { route: '/widgets/stopped-hook-failure' },
|
||||
})
|
||||
|
||||
expect(() => host.stop(session.id)).toThrow('session-stopped hook failed')
|
||||
|
||||
expect(session.phase).toBe('stopped')
|
||||
expect(host.getSession(session.id)).toBeUndefined()
|
||||
expect(host.getBinding('module-stopped-hook-failure')).toBeUndefined()
|
||||
await expect(host.listAvailableToolDescriptors()).resolves.toEqual([])
|
||||
await expect(host.listSerializedXsaiTools()).resolves.toEqual([])
|
||||
await expect(host.invokeTool(session.identity.plugin.id, 'stopped_hook_tool', {})).rejects.toThrow(
|
||||
`Plugin tool not found: ${session.identity.plugin.id}:stopped_hook_tool`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow plugin to announce update activate and withdraw dynamic bindings through bound apis', async () => {
|
||||
const host = new PluginHost({
|
||||
runtime: 'electron',
|
||||
@@ -999,7 +1219,8 @@ describe('for PluginHost', () => {
|
||||
},
|
||||
})).rejects.toThrow('Negotiation rejected:')
|
||||
|
||||
expect(host.getSession(session.id)?.phase).toBe('failed')
|
||||
expect(session.phase).toBe('stopped')
|
||||
expect(host.getSession(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should isolate module status events between plugin sessions', async () => {
|
||||
|
||||
@@ -14,9 +14,16 @@ import type {
|
||||
ModuleIdentity,
|
||||
ModulePermissionDeclaration,
|
||||
ModulePermissionGrant,
|
||||
PluginHostContribution,
|
||||
PluginHostInstallContext,
|
||||
PluginHostLifecycleEvent,
|
||||
PluginHostLifecycleHook,
|
||||
PluginHostOptions,
|
||||
PluginHostPermissionRequest,
|
||||
PluginHostSessionContext,
|
||||
PluginLoadOptions,
|
||||
PluginRuntime,
|
||||
PluginSessionApiFactory,
|
||||
PluginSessionPhase,
|
||||
PluginStartOptions,
|
||||
} from './shared/types'
|
||||
@@ -562,7 +569,7 @@ export interface PluginHostSession {
|
||||
host: ReturnType<typeof createPluginContext>
|
||||
}
|
||||
/** Bound plugin SDK APIs exposed to plugin code. */
|
||||
apis: ReturnType<typeof createApis>
|
||||
apis: PluginHostSessionApis
|
||||
/** Requested and granted permissions for the session. */
|
||||
permissions: {
|
||||
/** Permissions requested by the manifest and runtime declarations. */
|
||||
@@ -596,6 +603,10 @@ export interface PluginHostBindingListOptions {
|
||||
type BoundAnnounceBindingInput<C extends HostDataRecord = HostDataRecord> = AnnounceBindingInput<C>
|
||||
type BoundUpdateBindingInput<C extends HostDataRecord = HostDataRecord> = UpdateBindingInput<C>
|
||||
|
||||
const builtInSessionApiNamespaces = new Set(['providers', 'kits', 'bindings', 'tools'])
|
||||
|
||||
type PluginHostSessionApis = ReturnType<typeof createApis> & Record<string, unknown>
|
||||
|
||||
function omitModuleId<C extends HostDataRecord>(input: BoundUpdateBindingInput<C>) {
|
||||
return {
|
||||
state: input.state,
|
||||
@@ -687,6 +698,14 @@ export class PluginHost {
|
||||
private readonly permissionResolver?: PluginHostOptions['permissionResolver']
|
||||
private readonly persistedPermissionGrants = new Map<string, ModulePermissionGrant>()
|
||||
private readonly resources = new ResourceService()
|
||||
private readonly sessionApiFactories = new Map<string, PluginSessionApiFactory>()
|
||||
private readonly lifecycleHooks: Record<PluginHostLifecycleEvent, PluginHostLifecycleHook[]> = {
|
||||
'session-loaded': [],
|
||||
'session-ready': [],
|
||||
'session-stopped': [],
|
||||
}
|
||||
|
||||
private readonly installContext: PluginHostInstallContext
|
||||
|
||||
constructor(options: PluginHostOptions = {}) {
|
||||
this.loader = new FileSystemLoader()
|
||||
@@ -699,6 +718,11 @@ export class PluginHost {
|
||||
this.permissionResolver = options.permissionResolver
|
||||
this.resources.setValue(protocolListProvidersEventName, [] as Array<{ name: string }>)
|
||||
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' })
|
||||
this.installContext = this.createInstallContext()
|
||||
|
||||
for (const contribution of options.contributions ?? []) {
|
||||
this.installContribution(contribution)
|
||||
}
|
||||
}
|
||||
|
||||
private getPermissionScopeKey(session: PluginHostSession) {
|
||||
@@ -707,12 +731,7 @@ export class PluginHost {
|
||||
|
||||
private assertPermission(
|
||||
session: PluginHostSession,
|
||||
input: {
|
||||
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
|
||||
action: string
|
||||
key: string
|
||||
reason?: string
|
||||
},
|
||||
input: PluginHostPermissionRequest,
|
||||
) {
|
||||
const allowed = this.permissions.isAllowed(this.getPermissionScopeKey(session), input.area, input.action, input.key)
|
||||
if (allowed) {
|
||||
@@ -748,6 +767,173 @@ export class PluginHost {
|
||||
return session
|
||||
}
|
||||
|
||||
private createSessionContext(session: PluginHostSession): PluginHostSessionContext {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
ownerPluginId: session.identity.plugin.id,
|
||||
runtime: session.runtime,
|
||||
}
|
||||
}
|
||||
|
||||
private createInstallContext(): PluginHostInstallContext {
|
||||
return {
|
||||
registerSessionApi: (namespace, factory) => {
|
||||
if (builtInSessionApiNamespaces.has(namespace)) {
|
||||
throw new Error(`Session API namespace \`${namespace}\` is reserved by PluginHost.`)
|
||||
}
|
||||
|
||||
const currentFactory = this.sessionApiFactories.get(namespace)
|
||||
if (currentFactory && currentFactory !== factory) {
|
||||
throw new Error(`Duplicate session API namespace registration for \`${namespace}\`.`)
|
||||
}
|
||||
|
||||
this.sessionApiFactories.set(namespace, factory)
|
||||
},
|
||||
registerLifecycleHook: (event, hook) => {
|
||||
this.lifecycleHooks[event].push(hook)
|
||||
},
|
||||
registerKit: kit => this.registerKit(kit),
|
||||
unregisterKit: kitId => this.unregisterKit(kitId),
|
||||
setResourceResolver: (key, resolver) => this.setResourceResolver(key, resolver),
|
||||
setResourceValue: (key, value) => this.setResourceValue(key, value),
|
||||
announceCapability: (key, metadata) => {
|
||||
this.announceCapability(key, metadata)
|
||||
},
|
||||
markCapabilityReady: (key, metadata) => {
|
||||
this.markCapabilityReady(key, metadata)
|
||||
},
|
||||
markCapabilityDegraded: (key, metadata) => {
|
||||
this.markCapabilityDegraded(key, metadata)
|
||||
},
|
||||
withdrawCapability: (key, metadata) => {
|
||||
this.withdrawCapability(key, metadata)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private installContribution(contribution: PluginHostContribution) {
|
||||
contribution.install(this.installContext)
|
||||
}
|
||||
|
||||
private createSessionApis(
|
||||
session: PluginHostSession,
|
||||
hostChannel: ReturnType<typeof createPluginContext>,
|
||||
): PluginHostSessionApis {
|
||||
const baseApis = createBoundApis(hostChannel, {
|
||||
kits: {
|
||||
list: () => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginKitApiListEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginKitRegistryResourceKey,
|
||||
})
|
||||
|
||||
return this.listKits(session.runtime)
|
||||
},
|
||||
getCapabilities: (kitId) => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginKitApiGetCapabilitiesEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginKitRegistryResourceKey,
|
||||
})
|
||||
this.assertKitAvailableForSession(session, kitId)
|
||||
|
||||
return this.getKitCapabilities(kitId)
|
||||
},
|
||||
},
|
||||
bindings: {
|
||||
list: () => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginBindingApiListEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginBindingRegistryResourceKey,
|
||||
})
|
||||
|
||||
return this.listBindings({ ownerSessionId: session.id })
|
||||
},
|
||||
announce: input => this.announceBinding(session.id, input),
|
||||
activate: input => this.activateBinding(session.id, input.moduleId),
|
||||
update: input => this.updateBinding(session.id, input.moduleId, input),
|
||||
withdraw: input => this.withdrawBinding(session.id, input.moduleId),
|
||||
},
|
||||
tools: {
|
||||
register: input => this.registerTool(session.id, input),
|
||||
},
|
||||
})
|
||||
|
||||
const contributionApis = Object.fromEntries(
|
||||
[...this.sessionApiFactories.entries()].map(([namespace, factory]) => [
|
||||
namespace,
|
||||
factory({
|
||||
host: this.installContext,
|
||||
session: this.createSessionContext(session),
|
||||
assertPermission: input => this.assertPermission(session, input),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
return {
|
||||
...baseApis,
|
||||
...contributionApis,
|
||||
}
|
||||
}
|
||||
|
||||
private runLifecycleHooks(event: PluginHostLifecycleEvent, session: PluginHostSession) {
|
||||
for (const hook of this.lifecycleHooks[event]) {
|
||||
hook({
|
||||
host: this.installContext,
|
||||
session: this.createSessionContext(session),
|
||||
manifest: session.manifest,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupSession(session: PluginHostSession) {
|
||||
let lifecycleHookError: unknown
|
||||
|
||||
if (session.phase !== 'stopped') {
|
||||
const canStop = session.lifecycle.getSnapshot().can({ type: 'STOP' })
|
||||
if (canStop) {
|
||||
assertTransition(session, 'stopped')
|
||||
}
|
||||
else {
|
||||
session.phase = 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of this.modules.listByOwner(session.id)) {
|
||||
this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId)
|
||||
this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId)
|
||||
}
|
||||
|
||||
try {
|
||||
this.runLifecycleHooks('session-stopped', session)
|
||||
}
|
||||
catch (error) {
|
||||
lifecycleHookError = error
|
||||
}
|
||||
|
||||
session.lifecycle.stop()
|
||||
this.sessionService.remove(session.id)
|
||||
|
||||
return lifecycleHookError
|
||||
}
|
||||
|
||||
private getModuleOrThrow(moduleId: string) {
|
||||
const module = this.modules.get(moduleId)
|
||||
if (!module) {
|
||||
@@ -812,7 +998,7 @@ export class PluginHost {
|
||||
return cloneKitCapabilities(capabilities)
|
||||
}
|
||||
|
||||
getBinding(moduleId: string) {
|
||||
getBinding(moduleId: string): BindingRecord<HostDataRecord> | undefined {
|
||||
const module = this.modules.get(moduleId)
|
||||
if (!module) {
|
||||
return undefined
|
||||
@@ -974,8 +1160,20 @@ export class PluginHost {
|
||||
},
|
||||
parameters: cloneHostDataRecord(input.tool.parameters),
|
||||
},
|
||||
availability: input.availability,
|
||||
execute: input.execute,
|
||||
availability: async () => {
|
||||
if (!this.getSession(session.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return await input.availability?.() ?? true
|
||||
},
|
||||
execute: async (toolInput) => {
|
||||
if (!this.getSession(session.id)) {
|
||||
throw new Error(`Plugin tool not found: ${session.identity.plugin.id}:${input.tool.id}`)
|
||||
}
|
||||
|
||||
return await input.execute(toolInput)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1012,65 +1210,7 @@ export class PluginHost {
|
||||
},
|
||||
)
|
||||
|
||||
let session!: PluginHostSession
|
||||
const apis = createBoundApis(hostChannel, {
|
||||
kits: {
|
||||
list: () => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginKitApiListEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginKitRegistryResourceKey,
|
||||
})
|
||||
|
||||
return this.listKits(session.runtime)
|
||||
},
|
||||
getCapabilities: (kitId) => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginKitApiGetCapabilitiesEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginKitRegistryResourceKey,
|
||||
})
|
||||
this.assertKitAvailableForSession(session, kitId)
|
||||
|
||||
return this.getKitCapabilities(kitId)
|
||||
},
|
||||
},
|
||||
bindings: {
|
||||
list: () => {
|
||||
this.assertPermission(session, {
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginBindingApiListEventName,
|
||||
})
|
||||
this.assertPermission(session, {
|
||||
area: 'resources',
|
||||
action: 'read',
|
||||
key: pluginBindingRegistryResourceKey,
|
||||
})
|
||||
|
||||
return this.listBindings({ ownerSessionId: session.id })
|
||||
},
|
||||
announce: input => this.announceBinding(session.id, input),
|
||||
activate: input => this.activateBinding(session.id, input.moduleId),
|
||||
update: input => this.updateBinding(session.id, input.moduleId, input),
|
||||
withdraw: input => this.withdrawBinding(session.id, input.moduleId),
|
||||
},
|
||||
tools: {
|
||||
register: input => this.registerTool(session.id, input),
|
||||
},
|
||||
})
|
||||
|
||||
session = {
|
||||
const session: PluginHostSession = {
|
||||
manifest,
|
||||
plugin: {},
|
||||
id,
|
||||
@@ -1084,13 +1224,14 @@ export class PluginHost {
|
||||
channels: {
|
||||
host: hostChannel,
|
||||
},
|
||||
apis,
|
||||
apis: {} as PluginHostSessionApis,
|
||||
permissions: {
|
||||
requested: permissionSnapshot.requested,
|
||||
granted: permissionSnapshot.granted,
|
||||
revision: permissionSnapshot.revision,
|
||||
},
|
||||
}
|
||||
session.apis = this.createSessionApis(session, hostChannel)
|
||||
|
||||
defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => {
|
||||
this.assertPermission(session, {
|
||||
@@ -1146,6 +1287,7 @@ export class PluginHost {
|
||||
// Assert lifecycle progression (`loading` -> `loaded`) to keep transition rules explicit.
|
||||
// This prevents accidental phase drift if the method evolves later.
|
||||
assertTransition(session, 'loaded')
|
||||
this.runLifecycleHooks('session-loaded', session)
|
||||
return session
|
||||
}
|
||||
catch (error) {
|
||||
@@ -1392,6 +1534,7 @@ export class PluginHost {
|
||||
identity: session.identity,
|
||||
phase: 'ready',
|
||||
})
|
||||
this.runLifecycleHooks('session-ready', session)
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -1405,6 +1548,8 @@ export class PluginHost {
|
||||
reason: error instanceof Error ? error.message : 'Plugin host initialization failed.',
|
||||
})
|
||||
|
||||
this.cleanupSession(session)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1591,24 +1736,11 @@ export class PluginHost {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Prefer guarded transition when allowed; otherwise force-close as a safety fallback.
|
||||
if (session.phase !== 'stopped') {
|
||||
const canStop = session.lifecycle.getSnapshot().can({ type: 'STOP' })
|
||||
if (canStop) {
|
||||
assertTransition(session, 'stopped')
|
||||
}
|
||||
else {
|
||||
session.phase = 'stopped'
|
||||
}
|
||||
const lifecycleHookError = this.cleanupSession(session)
|
||||
if (lifecycleHookError) {
|
||||
throw lifecycleHookError
|
||||
}
|
||||
|
||||
for (const module of this.modules.listByOwner(session.id)) {
|
||||
this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId)
|
||||
this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId)
|
||||
}
|
||||
|
||||
session.lifecycle.stop()
|
||||
this.sessionService.remove(session.id)
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@proj-airi/plugin-protocol/types'
|
||||
|
||||
import type { PluginTransport } from '../transports'
|
||||
import type { KitDescriptor } from './kits'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import {
|
||||
@@ -476,6 +477,169 @@ export interface PluginHostOptions {
|
||||
requested: ModulePermissionDeclaration
|
||||
persisted?: ModulePermissionGrant
|
||||
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
|
||||
/** Installable host features that can extend session APIs and register host behavior. @default [] */
|
||||
contributions?: PluginHostContribution[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the stable session metadata exposed to host-installed contributions.
|
||||
*
|
||||
* Use when:
|
||||
* - Building contribution-owned session APIs
|
||||
* - Hooking plugin-session lifecycle work outside the core `PluginHost`
|
||||
*
|
||||
* Expects:
|
||||
* - Values come from the currently executing plugin session
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal session context safe to pass outside `PluginHost`
|
||||
*/
|
||||
export interface PluginHostSessionContext {
|
||||
sessionId: string
|
||||
ownerPluginId: string
|
||||
runtime: PluginRuntime
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes one permission gate that a contribution-owned session API can enforce.
|
||||
*
|
||||
* Use when:
|
||||
* - A contribution method must check host-granted API, resource, or capability access
|
||||
*
|
||||
* Expects:
|
||||
* - The permission key/action pair matches the manifest permission contract
|
||||
*
|
||||
* Returns:
|
||||
* - The permission request consumed by `PluginHost.assertPermission(...)`
|
||||
*/
|
||||
export interface PluginHostPermissionRequest {
|
||||
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
|
||||
action: string
|
||||
key: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the host-owned registration surface that contributions can use during installation.
|
||||
*
|
||||
* Use when:
|
||||
* - Installing a host feature into `PluginHost`
|
||||
* - Registering session API namespaces, kits, resources, capabilities, or lifecycle hooks
|
||||
*
|
||||
* Expects:
|
||||
* - Installation happens during `PluginHost` construction
|
||||
* - Session API namespace names are unique across all contributions and built-in namespaces
|
||||
*
|
||||
* Returns:
|
||||
* - Registration helpers that keep `PluginHost` generic while allowing extensions
|
||||
*/
|
||||
export interface PluginHostInstallContext {
|
||||
registerSessionApi: (namespace: string, factory: PluginSessionApiFactory) => void
|
||||
registerLifecycleHook: (event: PluginHostLifecycleEvent, hook: PluginHostLifecycleHook) => void
|
||||
registerKit: (kit: KitDescriptor) => KitDescriptor
|
||||
unregisterKit: (kitId: string) => KitDescriptor | undefined
|
||||
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
|
||||
setResourceValue: <T>(key: string, value: T) => void
|
||||
announceCapability: (key: string, metadata?: Record<string, unknown>) => void
|
||||
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => void
|
||||
markCapabilityDegraded: (key: string, metadata?: Record<string, unknown>) => void
|
||||
withdrawCapability: (key: string, metadata?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the context passed into one contribution-owned session API factory.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating a custom namespace that will be attached to `session.apis`
|
||||
*
|
||||
* Expects:
|
||||
* - `session` refers to the plugin session currently being assembled
|
||||
* - `assertPermission` is called inside contribution methods before privileged work
|
||||
*
|
||||
* Returns:
|
||||
* - The context needed to build one session API namespace
|
||||
*/
|
||||
export interface PluginSessionApiFactoryContext {
|
||||
host: PluginHostInstallContext
|
||||
session: PluginHostSessionContext
|
||||
assertPermission: (input: PluginHostPermissionRequest) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one custom session API namespace installed by a host contribution.
|
||||
*
|
||||
* Use when:
|
||||
* - Extending `session.apis` with a plugin-host-specific namespace
|
||||
*
|
||||
* Expects:
|
||||
* - The returned value is an object-like namespace safe to expose to plugin code
|
||||
*
|
||||
* Returns:
|
||||
* - The namespace object attached to `session.apis[namespace]`
|
||||
*/
|
||||
export type PluginSessionApiFactory<TNamespace = unknown> = (context: PluginSessionApiFactoryContext) => TNamespace
|
||||
|
||||
/**
|
||||
* Enumerates the host lifecycle moments contributions may observe.
|
||||
*
|
||||
* Use when:
|
||||
* - Registering contribution hooks tied to session load, readiness, or stop events
|
||||
*
|
||||
* Expects:
|
||||
* - Hooks are synchronous and should stay lightweight
|
||||
*
|
||||
* Returns:
|
||||
* - The supported lifecycle event names for `registerLifecycleHook(...)`
|
||||
*/
|
||||
export type PluginHostLifecycleEvent = 'session-loaded' | 'session-ready' | 'session-stopped'
|
||||
|
||||
/**
|
||||
* Describes the context passed into one contribution lifecycle hook.
|
||||
*
|
||||
* Use when:
|
||||
* - Reacting to a plugin session lifecycle event outside the generic host core
|
||||
*
|
||||
* Expects:
|
||||
* - `session` and `manifest` refer to the active session at the time of the hook
|
||||
*
|
||||
* Returns:
|
||||
* - The snapshot available to contribution lifecycle hooks
|
||||
*/
|
||||
export interface PluginHostLifecycleHookContext {
|
||||
host: PluginHostInstallContext
|
||||
session: PluginHostSessionContext
|
||||
manifest: ManifestV1
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one contribution-owned lifecycle event emitted by `PluginHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - A contribution needs to observe session loading, readiness, or teardown
|
||||
*
|
||||
* Expects:
|
||||
* - Hooks are synchronous and should throw only for deterministic setup failures
|
||||
*
|
||||
* Returns:
|
||||
* - No value; side effects are owned by the contribution
|
||||
*/
|
||||
export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext) => void
|
||||
|
||||
/**
|
||||
* Installs one generic host feature into `PluginHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - The host should register extra session APIs or bootstrap runtime-specific behavior
|
||||
*
|
||||
* Expects:
|
||||
* - Installation is idempotent for one host instance
|
||||
* - Contributions keep domain-specific behavior out of the low-level host core
|
||||
*
|
||||
* Returns:
|
||||
* - No value; the contribution mutates the provided install context
|
||||
*/
|
||||
export interface PluginHostContribution {
|
||||
install: (context: PluginHostInstallContext) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface PluginApiBindings {
|
||||
* - `bindings` contains the host-backed callbacks for each enabled API group
|
||||
*
|
||||
* Returns:
|
||||
* - The composed plugin client APIs for resources, kits, bindings, and tools
|
||||
* - The composed built-in plugin client APIs for resources, kits, bindings, and tools
|
||||
*/
|
||||
export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindings = {}) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user