feat(stage-tamagotchi): widget now supports customizable ui from plugin/extension
This commit is contained in:
@@ -6,6 +6,12 @@ import type { WidgetsWindowManager } from '../../../windows/widgets'
|
||||
import { defineInvokeHandlers } from '@moeru/eventa'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsFetch, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
|
||||
import {
|
||||
normalizeOptionalWidgetId,
|
||||
normalizeRequiredWidgetId,
|
||||
validateWidgetsAddPayload,
|
||||
validateWidgetsUpdatePayload,
|
||||
} from './validation'
|
||||
|
||||
interface InvokeOptions {
|
||||
raw?: { ipcMainEvent?: IpcMainEvent }
|
||||
@@ -18,6 +24,27 @@ function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow)
|
||||
return sender.id === window.webContents.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers widget-related Electron invoke handlers for one window context.
|
||||
*
|
||||
* Use when:
|
||||
* - A main-process window should expose widget management invokes to its renderer
|
||||
* - Widget requests must be validated before reaching {@link WidgetsWindowManager}
|
||||
*
|
||||
* Expects:
|
||||
* - `context` is an Eventa context bound to the target Electron window
|
||||
* - `window` is the only renderer allowed to use the registered invokes
|
||||
*
|
||||
* Returns:
|
||||
* - Registers handlers on the provided context and does not return a value
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* createWidgetsService (./index)
|
||||
* -> {@link defineInvokeHandlers}
|
||||
* -> {@link validateWidgetsAddPayload}
|
||||
* -> {@link WidgetsWindowManager.pushWidget}
|
||||
*/
|
||||
export function createWidgetsService(params: { context: ReturnType<typeof createContext>['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) {
|
||||
defineInvokeHandlers(params.context, {
|
||||
widgetsPrepareWindow,
|
||||
@@ -31,37 +58,43 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
|
||||
widgetsPrepareWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.prepareWidgetWindow(payload ?? undefined)
|
||||
const id = normalizeOptionalWidgetId(payload?.id)
|
||||
return params.widgetsManager.prepareWidgetWindow(id ? { id } : undefined)
|
||||
},
|
||||
widgetsOpenWindow: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.openWindow(payload ?? undefined)
|
||||
const id = normalizeOptionalWidgetId(payload?.id)
|
||||
return params.widgetsManager.openWindow(id ? { id } : undefined)
|
||||
},
|
||||
widgetsAdd: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload ? params.widgetsManager!.pushWidget(payload) : undefined
|
||||
return params.widgetsManager.pushWidget(validateWidgetsAddPayload(payload))
|
||||
},
|
||||
widgetsUpdate: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload ? params.widgetsManager!.updateWidget(payload) : undefined
|
||||
return params.widgetsManager.updateWidget(validateWidgetsUpdatePayload(payload))
|
||||
},
|
||||
widgetsRemove: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload?.id ? params.widgetsManager!.removeWidget(payload.id) : undefined
|
||||
return params.widgetsManager.removeWidget(
|
||||
normalizeRequiredWidgetId(payload?.id, 'id is required to remove a widget.'),
|
||||
)
|
||||
},
|
||||
widgetsClear: async (_payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return params.widgetsManager!.clearWidgets()
|
||||
return params.widgetsManager.clearWidgets()
|
||||
},
|
||||
widgetsFetch: async (payload, options) => {
|
||||
if (!isFromWindow(options as InvokeOptions, params.window))
|
||||
return undefined
|
||||
return payload?.id ? params.widgetsManager!.getWidgetSnapshot(payload.id) : undefined
|
||||
return params.widgetsManager.getWidgetSnapshot(
|
||||
normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeOptionalWidgetId,
|
||||
normalizeRequiredWidgetId,
|
||||
validateWidgetsAddPayload,
|
||||
validateWidgetsUpdatePayload,
|
||||
} from './validation'
|
||||
|
||||
describe('widget invoke validation', () => {
|
||||
describe('validateWidgetsAddPayload', () => {
|
||||
it('normalizes add payloads for the widgets manager', () => {
|
||||
expect(validateWidgetsAddPayload({
|
||||
id: ' widget-1 ',
|
||||
componentName: ' weather ',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
ttlMs: 2500.9,
|
||||
windowSize: {
|
||||
width: 620.8,
|
||||
height: 480.2,
|
||||
minWidth: 320.9,
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'widget-1',
|
||||
componentName: 'weather',
|
||||
componentProps: { city: 'Tokyo' },
|
||||
ttlMs: 2500,
|
||||
windowSize: {
|
||||
width: 620,
|
||||
height: 480,
|
||||
minWidth: 320,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty component names and invalid payload fields', () => {
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: ' ',
|
||||
} as any)).toThrow('componentName is required to spawn a widget.')
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: 'weather',
|
||||
componentProps: [] as any,
|
||||
})).toThrow('componentProps must be a plain object.')
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: 'weather',
|
||||
ttlMs: -1,
|
||||
})).toThrow('ttlMs must be a non-negative finite number.')
|
||||
|
||||
expect(() => validateWidgetsAddPayload({
|
||||
componentName: 'weather',
|
||||
windowSize: { width: 0, height: 320 },
|
||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateWidgetsUpdatePayload', () => {
|
||||
it('normalizes widget updates and keeps optional fields optional', () => {
|
||||
expect(validateWidgetsUpdatePayload({
|
||||
id: ' widget-1 ',
|
||||
componentProps: { city: 'Taipei' },
|
||||
ttlMs: 1500.4,
|
||||
})).toEqual({
|
||||
id: 'widget-1',
|
||||
componentProps: { city: 'Taipei' },
|
||||
ttlMs: 1500,
|
||||
windowSize: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing ids and malformed update fields', () => {
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
id: ' ',
|
||||
} as any)).toThrow('id is required to update a widget.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
id: 'widget-1',
|
||||
componentProps: [] as any,
|
||||
})).toThrow('componentProps must be a plain object.')
|
||||
|
||||
expect(() => validateWidgetsUpdatePayload({
|
||||
id: 'widget-1',
|
||||
windowSize: { width: Number.NaN, height: 400 },
|
||||
} as any)).toThrow('windowSize must contain a positive finite width and height.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget id normalization helpers', () => {
|
||||
it('normalizes optional ids for open/prepare flows', () => {
|
||||
expect(normalizeOptionalWidgetId(' widget-1 ')).toBe('widget-1')
|
||||
expect(normalizeOptionalWidgetId(' ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('enforces required ids for destructive flows', () => {
|
||||
expect(normalizeRequiredWidgetId(' widget-1 ', 'id required')).toBe('widget-1')
|
||||
expect(() => normalizeRequiredWidgetId(' ', 'id required')).toThrow('id required')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
WidgetsAddPayload,
|
||||
WidgetsUpdatePayload,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
|
||||
|
||||
function normalizeWidgetId(value?: string): string | undefined {
|
||||
if (!value)
|
||||
return undefined
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function normalizeTtlMs(ttlMs?: number): number {
|
||||
if (ttlMs === undefined)
|
||||
return 0
|
||||
|
||||
if (!Number.isFinite(ttlMs) || ttlMs < 0)
|
||||
throw new Error('ttlMs must be a non-negative finite number.')
|
||||
|
||||
return Math.floor(ttlMs)
|
||||
}
|
||||
|
||||
function normalizeComponentProps(componentProps?: Record<string, unknown>): Record<string, unknown> {
|
||||
if (componentProps === undefined)
|
||||
return {}
|
||||
|
||||
if (!isPlainObject(componentProps))
|
||||
throw new Error('componentProps must be a plain object.')
|
||||
|
||||
return componentProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes widget spawn payloads at the Electron invoke boundary.
|
||||
*
|
||||
* Use when:
|
||||
* - `defineInvokeHandler(...)` receives a widgets add request from a renderer
|
||||
*
|
||||
* Expects:
|
||||
* - `componentName` is a non-empty string
|
||||
* - `componentProps`, when provided, is a plain object
|
||||
* - `ttlMs`, when provided, is a non-negative finite number
|
||||
*
|
||||
* Returns:
|
||||
* - A normalized payload safe to pass into the widgets manager
|
||||
*/
|
||||
export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsAddPayload {
|
||||
if (!payload)
|
||||
throw new Error('widgets.add requires a payload.')
|
||||
|
||||
const componentName = payload.componentName?.trim()
|
||||
if (!componentName)
|
||||
throw new Error('componentName is required to spawn a widget.')
|
||||
|
||||
const normalizedWindowSize = payload.windowSize === undefined
|
||||
? undefined
|
||||
: normalizeWidgetWindowSize(payload.windowSize)
|
||||
|
||||
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
||||
throw new Error('windowSize must contain a positive finite width and height.')
|
||||
|
||||
return {
|
||||
...payload,
|
||||
id: normalizeWidgetId(payload.id),
|
||||
componentName,
|
||||
componentProps: normalizeComponentProps(payload.componentProps),
|
||||
ttlMs: normalizeTtlMs(payload.ttlMs),
|
||||
windowSize: normalizedWindowSize,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes widget update payloads at the Electron invoke boundary.
|
||||
*
|
||||
* Use when:
|
||||
* - `defineInvokeHandler(...)` receives a widgets update request from a renderer
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is a non-empty string after trimming
|
||||
* - `componentProps`, when provided, is a plain object
|
||||
*
|
||||
* Returns:
|
||||
* - A normalized payload safe to pass into the widgets manager
|
||||
*/
|
||||
export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): WidgetsUpdatePayload {
|
||||
if (!payload)
|
||||
throw new Error('widgets.update requires a payload.')
|
||||
|
||||
const id = normalizeWidgetId(payload.id)
|
||||
if (!id)
|
||||
throw new Error('id is required to update a widget.')
|
||||
|
||||
const normalizedWindowSize = payload.windowSize === undefined
|
||||
? undefined
|
||||
: normalizeWidgetWindowSize(payload.windowSize)
|
||||
|
||||
if (payload.windowSize !== undefined && !normalizedWindowSize)
|
||||
throw new Error('windowSize must contain a positive finite width and height.')
|
||||
|
||||
return {
|
||||
...payload,
|
||||
id,
|
||||
componentProps: payload.componentProps === undefined
|
||||
? undefined
|
||||
: normalizeComponentProps(payload.componentProps),
|
||||
ttlMs: payload.ttlMs === undefined
|
||||
? undefined
|
||||
: normalizeTtlMs(payload.ttlMs),
|
||||
windowSize: normalizedWindowSize,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates widget ids for remove/fetch/open operations at the Electron boundary.
|
||||
*
|
||||
* Use when:
|
||||
* - A widget operation requires an existing widget id
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is a string or `undefined`
|
||||
*
|
||||
* Returns:
|
||||
* - The trimmed id, or `undefined` for empty input
|
||||
*/
|
||||
export function normalizeRequiredWidgetId(id?: string, reason = 'id is required.'): string {
|
||||
const normalized = normalizeWidgetId(id)
|
||||
if (!normalized)
|
||||
throw new Error(reason)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes optional widget ids for open/prepare operations.
|
||||
*
|
||||
* Before:
|
||||
* - `" widget-1 "`
|
||||
* - `""`
|
||||
*
|
||||
* After:
|
||||
* - `"widget-1"`
|
||||
* - `undefined`
|
||||
*/
|
||||
export function normalizeOptionalWidgetId(id?: string): string | undefined {
|
||||
return normalizeWidgetId(id)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WidgetWindowSize } from '../../../shared/eventa'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size'
|
||||
|
||||
describe('normalizeWidgetWindowSize', () => {
|
||||
it('returns undefined for missing or unusable base sizes', () => {
|
||||
expect(normalizeWidgetWindowSize()).toBeUndefined()
|
||||
expect(normalizeWidgetWindowSize({ width: 0, height: 320 })).toBeUndefined()
|
||||
expect(normalizeWidgetWindowSize({ width: 320, height: -1 })).toBeUndefined()
|
||||
expect(normalizeWidgetWindowSize({ width: Number.NaN, height: 320 })).toBeUndefined()
|
||||
expect(normalizeWidgetWindowSize({ width: 320, height: Number.POSITIVE_INFINITY })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('floors valid dimensions and strips invalid optional constraints', () => {
|
||||
const input: WidgetWindowSize = {
|
||||
width: 620.9,
|
||||
height: 480.4,
|
||||
minWidth: -10,
|
||||
minHeight: Number.NaN,
|
||||
maxWidth: 1280.6,
|
||||
maxHeight: 720.1,
|
||||
}
|
||||
|
||||
expect(normalizeWidgetWindowSize(input)).toEqual({
|
||||
width: 620,
|
||||
height: 480,
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps contradictory but numerically valid constraints for later display clamping', () => {
|
||||
const input: WidgetWindowSize = {
|
||||
width: 900,
|
||||
height: 700,
|
||||
minWidth: 1200,
|
||||
maxWidth: 800,
|
||||
minHeight: 900,
|
||||
maxHeight: 600,
|
||||
}
|
||||
|
||||
expect(normalizeWidgetWindowSize(input)).toEqual({
|
||||
width: 900,
|
||||
height: 700,
|
||||
minWidth: 1200,
|
||||
maxWidth: 800,
|
||||
minHeight: 900,
|
||||
maxHeight: 600,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { BrowserWindow, Rectangle } from 'electron'
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import type { WidgetsAddPayload, WidgetSnapshot } from '../../../shared/eventa'
|
||||
import type {
|
||||
PluginModuleWidgetPayload,
|
||||
WidgetsAddPayload,
|
||||
WidgetSnapshot,
|
||||
WidgetsUpdatePayload,
|
||||
} from '../../../shared/eventa'
|
||||
import type { I18n } from '../../libs/i18n'
|
||||
import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
|
||||
@@ -10,26 +15,140 @@ import { join, resolve } from 'node:path'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import { safeClose } from '@proj-airi/electron-vueuse/main'
|
||||
import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron'
|
||||
import { clamp } from 'es-toolkit/math'
|
||||
import { isMacOS } from 'std-env'
|
||||
import { number, object, optional } from 'valibot'
|
||||
|
||||
import icon from '../../../../resources/icon.png?asset'
|
||||
|
||||
import { widgetsClearEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa'
|
||||
import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size'
|
||||
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
|
||||
import { createConfig } from '../../libs/electron/persistence'
|
||||
import { createReusableWindow } from '../../libs/electron/window-manager'
|
||||
import { spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
|
||||
import { setupWidgetsWindowInvokes } from './rpc/index.electron'
|
||||
|
||||
/**
|
||||
* Controls the overlay widget window lifecycle and widget registry.
|
||||
*
|
||||
* Use when:
|
||||
* - Electron services need to spawn or update overlay widgets
|
||||
* - Renderer invokes need a stable window-management bridge
|
||||
*
|
||||
* Expects:
|
||||
* - A reusable Electron widget window managed by {@link setupWidgetsWindowManager}
|
||||
* - Widget ids remain stable across updates for the same widget surface
|
||||
*
|
||||
* Returns:
|
||||
* - An imperative manager for opening the widget window and mutating widget state
|
||||
*/
|
||||
export interface WidgetsWindowManager {
|
||||
/**
|
||||
* Resolves the shared widgets window instance.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller needs direct access to the backing Electron window
|
||||
*
|
||||
* Expects:
|
||||
* - The window manager has already been initialized
|
||||
*
|
||||
* Returns:
|
||||
* - The live widgets {@link BrowserWindow}, creating it if necessary
|
||||
*/
|
||||
getWindow: () => Promise<BrowserWindow>
|
||||
/**
|
||||
* Opens the widgets window, optionally focusing a prepared widget route.
|
||||
*
|
||||
* Use when:
|
||||
* - The caller wants to show the widgets surface without pushing a new widget payload yet
|
||||
* - A prepared widget id should restore its dedicated route and layout
|
||||
*
|
||||
* Expects:
|
||||
* - `params.id`, when provided, matches a widget prepared through {@link WidgetsWindowManager.prepareWidgetWindow}
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the target window route has been shown
|
||||
*/
|
||||
openWindow: (params?: { id?: string }) => Promise<void>
|
||||
/**
|
||||
* Inserts or replaces a widget snapshot and renders it in the widgets window.
|
||||
*
|
||||
* Use when:
|
||||
* - A renderer or tool wants to spawn a new overlay widget
|
||||
* - A caller has already prepared an id and wants to attach widget content to it
|
||||
*
|
||||
* Expects:
|
||||
* - `payload.componentName` identifies a registered renderer widget
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved widget id used for subsequent updates or removal
|
||||
*/
|
||||
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
||||
updateWidget: (payload: { id: string, componentProps?: Record<string, any> }) => Promise<void>
|
||||
/**
|
||||
* Applies partial widget changes to an existing widget snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - A widget's props, size, or time-to-live must change without respawning it
|
||||
*
|
||||
* Expects:
|
||||
* - `payload.id` references an existing widget managed by this instance
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after in-memory state and renderer events have been updated
|
||||
*/
|
||||
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
||||
/**
|
||||
* Removes a single widget from the registry and renderer surface.
|
||||
*
|
||||
* Use when:
|
||||
* - A specific widget should disappear immediately
|
||||
*
|
||||
* Expects:
|
||||
* - `id` matches a widget previously created or prepared through this manager
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the widget has been removed and the renderer notified
|
||||
*/
|
||||
removeWidget: (id: string) => Promise<void>
|
||||
/**
|
||||
* Removes all widgets and closes any live widget windows.
|
||||
*
|
||||
* Use when:
|
||||
* - The overlay surface should reset to an empty state
|
||||
*
|
||||
* Expects:
|
||||
* - No additional input
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the registry, renderer, and child windows have been cleared
|
||||
*/
|
||||
clearWidgets: () => Promise<void>
|
||||
/**
|
||||
* Reads the current snapshot for a single widget id.
|
||||
*
|
||||
* Use when:
|
||||
* - Another service needs to inspect a widget before opening or mutating it
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is the widget identifier to inspect
|
||||
*
|
||||
* Returns:
|
||||
* - The current snapshot, or `undefined` when the widget is unknown
|
||||
*/
|
||||
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
|
||||
/**
|
||||
* Reserves a widget id before content is pushed into the widgets window.
|
||||
*
|
||||
* Use when:
|
||||
* - The caller wants a stable route or window context before rendering
|
||||
*
|
||||
* Expects:
|
||||
* - `options.id`, when provided, should be stable for later reuse
|
||||
*
|
||||
* Returns:
|
||||
* - The prepared widget id bound to a future window context
|
||||
*/
|
||||
prepareWidgetWindow: (options?: { id?: string }) => string
|
||||
}
|
||||
|
||||
@@ -53,6 +172,18 @@ function computeDefaultBounds(): Rectangle {
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
function resolveWindowSizeFromPayload(payload: Pick<WidgetsAddPayload, 'componentName' | 'componentProps' | 'windowSize'>) {
|
||||
const explicitWindowSize = normalizeWidgetWindowSize(payload.windowSize)
|
||||
if (explicitWindowSize)
|
||||
return explicitWindowSize
|
||||
|
||||
if (payload.componentName?.trim().toLowerCase() !== 'plugin-module')
|
||||
return undefined
|
||||
|
||||
const pluginModulePayload = payload.componentProps as PluginModuleWidgetPayload | undefined
|
||||
return normalizeWidgetWindowSize(pluginModulePayload?.windowSize)
|
||||
}
|
||||
|
||||
function createWidgetsWindow() {
|
||||
const window = new ElectronBrowserWindow({
|
||||
title: 'Widgets',
|
||||
@@ -96,6 +227,27 @@ interface WidgetWindowContext {
|
||||
window?: BrowserWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Electron widgets window manager and its widget registry bridge.
|
||||
*
|
||||
* Use when:
|
||||
* - Main-process services need to spawn, update, or remove overlay widgets
|
||||
* - Widget window RPC handlers need a stable manager instance
|
||||
*
|
||||
* Expects:
|
||||
* - `serverChannel` and `i18n` are already initialized for the main process
|
||||
* - Renderer widget routes are available under the widgets page
|
||||
*
|
||||
* Returns:
|
||||
* - A {@link WidgetsWindowManager} that coordinates widget state and window reuse
|
||||
*
|
||||
* Call stack:
|
||||
*
|
||||
* setupWidgetsWindowManager (./index)
|
||||
* -> {@link createReusableWindow}
|
||||
* -> {@link setupWidgetsWindowInvokes}
|
||||
* -> {@link createContext}
|
||||
*/
|
||||
export function setupWidgetsWindowManager(params: {
|
||||
serverChannel: ServerChannel
|
||||
i18n: I18n
|
||||
@@ -117,6 +269,7 @@ export function setupWidgetsWindowManager(params: {
|
||||
let pendingRoute: string | undefined
|
||||
let currentRoute: string | undefined
|
||||
let activeWidgetsWindow: BrowserWindow | undefined
|
||||
let persistWindowBounds = true
|
||||
|
||||
let widgetsManager: WidgetsWindowManager | undefined
|
||||
|
||||
@@ -146,7 +299,11 @@ export function setupWidgetsWindowManager(params: {
|
||||
window.setBounds(computeDefaultBounds())
|
||||
}
|
||||
|
||||
const persist = () => update({ bounds: window.getBounds() })
|
||||
const persist = () => {
|
||||
if (!persistWindowBounds)
|
||||
return
|
||||
update({ bounds: window.getBounds() })
|
||||
}
|
||||
window.on('resize', persist)
|
||||
window.on('move', persist)
|
||||
|
||||
@@ -175,6 +332,19 @@ export function setupWidgetsWindowManager(params: {
|
||||
return window
|
||||
})
|
||||
|
||||
/**
|
||||
* Reserves a widget id and its window context before rendering.
|
||||
*
|
||||
* Use when:
|
||||
* - The caller wants a stable route for a widget before pushing content
|
||||
* - `openWindow({ id })` should target a dedicated widget route
|
||||
*
|
||||
* Expects:
|
||||
* - `options.id`, when supplied, should be reused for future updates
|
||||
*
|
||||
* Returns:
|
||||
* - The prepared widget id
|
||||
*/
|
||||
function prepareWidgetWindow(options?: { id?: string }): string {
|
||||
const id = options?.id ?? Math.random().toString(36).slice(2, 10)
|
||||
if (!windowContexts.has(id)) {
|
||||
@@ -227,6 +397,57 @@ export function setupWidgetsWindowManager(params: {
|
||||
currentRoute = route
|
||||
}
|
||||
|
||||
function applyStoredOrDefaultBounds(window: BrowserWindow) {
|
||||
const saved = getConfig().bounds
|
||||
if (saved) {
|
||||
const work = screen.getDisplayMatching(saved).workArea
|
||||
const width = Math.min(saved.width, work.width)
|
||||
const height = Math.min(saved.height, work.height)
|
||||
const clamped: Rectangle = {
|
||||
x: clamp(saved.x, work.x, work.x + work.width - width),
|
||||
y: clamp(saved.y, work.y, work.y + work.height - height),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
window.setBounds(clamped)
|
||||
return
|
||||
}
|
||||
|
||||
window.setBounds(computeDefaultBounds())
|
||||
}
|
||||
|
||||
function applyWindowLayout(window: BrowserWindow, snapshot?: Pick<WidgetSnapshot, 'windowSize'>) {
|
||||
const display = screen.getDisplayMatching(window.getBounds())
|
||||
const work = display.workArea
|
||||
const windowSize = normalizeWidgetWindowSize(snapshot?.windowSize)
|
||||
|
||||
if (!windowSize) {
|
||||
persistWindowBounds = true
|
||||
window.setMinimumSize(0, 0)
|
||||
window.setMaximumSize(work.width, work.height)
|
||||
applyStoredOrDefaultBounds(window)
|
||||
return
|
||||
}
|
||||
|
||||
persistWindowBounds = false
|
||||
const minWidth = clamp(windowSize.minWidth ?? 240, 1, work.width)
|
||||
const minHeight = clamp(windowSize.minHeight ?? 160, 1, work.height)
|
||||
const maxWidth = clamp(windowSize.maxWidth ?? work.width, minWidth, work.width)
|
||||
const maxHeight = clamp(windowSize.maxHeight ?? work.height, minHeight, work.height)
|
||||
const width = clamp(windowSize.width, minWidth, maxWidth)
|
||||
const height = clamp(windowSize.height, minHeight, maxHeight)
|
||||
const currentBounds = window.getBounds()
|
||||
|
||||
window.setMinimumSize(minWidth, minHeight)
|
||||
window.setMaximumSize(maxWidth, maxHeight)
|
||||
window.setBounds({
|
||||
x: clamp(currentBounds.x, work.x, work.x + work.width - width),
|
||||
y: clamp(currentBounds.y, work.y, work.y + work.height - height),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
async function getWindowFromContext(context?: WidgetWindowContext): Promise<BrowserWindow> {
|
||||
if (!context)
|
||||
return getWindow()
|
||||
@@ -237,10 +458,11 @@ export function setupWidgetsWindowManager(params: {
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function showWindowWithRoute(route: string, context?: WidgetWindowContext) {
|
||||
async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick<WidgetSnapshot, 'windowSize'>) {
|
||||
pendingRoute = route
|
||||
const window = await getWindowFromContext(context)
|
||||
pendingRoute = undefined
|
||||
applyWindowLayout(window, snapshot)
|
||||
if (currentRoute !== route)
|
||||
await loadWithRoute(window, route)
|
||||
window.show()
|
||||
@@ -249,17 +471,54 @@ export function setupWidgetsWindowManager(params: {
|
||||
return window
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the shared widgets window instance for callers that need direct access.
|
||||
*
|
||||
* Use when:
|
||||
* - Another service needs the backing Electron window without changing widget state
|
||||
*
|
||||
* Expects:
|
||||
* - The reusable window factory is available
|
||||
*
|
||||
* Returns:
|
||||
* - The widgets {@link BrowserWindow}
|
||||
*/
|
||||
async function getWindow(): Promise<BrowserWindow> {
|
||||
return reusable.getWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the widgets window and restores a prepared widget route when available.
|
||||
*
|
||||
* Use when:
|
||||
* - The caller wants to reveal the widgets surface without pushing new content
|
||||
*
|
||||
* Expects:
|
||||
* - `params.id`, when provided, references a prepared widget id
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the window has been shown
|
||||
*/
|
||||
async function openWindow(params?: { id?: string }) {
|
||||
const id = params?.id ? prepareWidgetWindow({ id: params.id }) : undefined
|
||||
const route = id ? `${defaultRoute}?id=${id}` : defaultRoute
|
||||
const context = id ? windowContexts.get(id) : undefined
|
||||
await showWindowWithRoute(route, context)
|
||||
const snapshot = id ? widgetRecords.get(id) : undefined
|
||||
await showWindowWithRoute(route, context, snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or replaces a widget snapshot and renders it in the widget window.
|
||||
*
|
||||
* Use when:
|
||||
* - A renderer or tool wants to spawn overlay content
|
||||
*
|
||||
* Expects:
|
||||
* - `payload.componentName` matches a renderer component known by the widgets page
|
||||
*
|
||||
* Returns:
|
||||
* - The stable widget id that was rendered
|
||||
*/
|
||||
async function pushWidget(payload: WidgetsAddPayload): Promise<string> {
|
||||
const id = prepareWidgetWindow({ id: payload.id })
|
||||
const snapshot: WidgetSnapshot = {
|
||||
@@ -267,17 +526,30 @@ export function setupWidgetsWindowManager(params: {
|
||||
componentName: payload.componentName,
|
||||
componentProps: payload.componentProps ?? {},
|
||||
size: payload.size ?? 'm',
|
||||
windowSize: resolveWindowSizeFromPayload(payload),
|
||||
ttlMs: payload.ttlMs ?? 0,
|
||||
}
|
||||
upsertRecord(snapshot)
|
||||
const context = windowContexts.get(id)
|
||||
await showWindowWithRoute(`${defaultRoute}?id=${id}`, context)
|
||||
await showWindowWithRoute(`${defaultRoute}?id=${id}`, context, snapshot)
|
||||
eventaContext?.emit(widgetsRenderEvent, snapshot)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
async function updateWidget(payload: { id: string, componentProps?: Record<string, any> }) {
|
||||
/**
|
||||
* Applies partial widget mutations to an existing widget snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - Props, size, or time-to-live need to change without recreating the widget id
|
||||
*
|
||||
* Expects:
|
||||
* - `payload.id` references an existing widget
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after internal state and renderer events have been updated
|
||||
*/
|
||||
async function updateWidget(payload: WidgetsUpdatePayload) {
|
||||
if (!payload?.id)
|
||||
return
|
||||
|
||||
@@ -288,13 +560,39 @@ export function setupWidgetsWindowManager(params: {
|
||||
const nextSnapshot: WidgetSnapshot = {
|
||||
...toSnapshot(existing),
|
||||
componentProps: payload.componentProps ?? existing.componentProps,
|
||||
size: payload.size ?? existing.size,
|
||||
windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize,
|
||||
ttlMs: payload.ttlMs ?? existing.ttlMs,
|
||||
}
|
||||
|
||||
upsertRecord(nextSnapshot)
|
||||
|
||||
eventaContext?.emit(widgetsUpdateEvent, { id: nextSnapshot.id, componentProps: nextSnapshot.componentProps })
|
||||
const context = windowContexts.get(payload.id)
|
||||
const window = context?.window
|
||||
if (window && !window.isDestroyed())
|
||||
applyWindowLayout(window, nextSnapshot)
|
||||
|
||||
eventaContext?.emit(widgetsUpdateEvent, {
|
||||
id: nextSnapshot.id,
|
||||
componentProps: nextSnapshot.componentProps,
|
||||
size: nextSnapshot.size,
|
||||
windowSize: nextSnapshot.windowSize,
|
||||
ttlMs: nextSnapshot.ttlMs,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one widget and emits the corresponding renderer event.
|
||||
*
|
||||
* Use when:
|
||||
* - A caller needs to dismiss a single widget immediately
|
||||
*
|
||||
* Expects:
|
||||
* - `id` references a widget managed by this instance
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the widget has been removed from memory and renderer state
|
||||
*/
|
||||
async function removeWidget(id: string) {
|
||||
if (!id)
|
||||
return
|
||||
@@ -302,6 +600,18 @@ export function setupWidgetsWindowManager(params: {
|
||||
eventaContext?.emit(widgetsRemoveEvent, { id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears every widget and closes all widget windows owned by this manager.
|
||||
*
|
||||
* Use when:
|
||||
* - The overlay surface must reset completely
|
||||
*
|
||||
* Expects:
|
||||
* - No input
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after state, renderer events, and windows have been cleared
|
||||
*/
|
||||
async function clearWidgets() {
|
||||
const ids = [...widgetRecords.keys()]
|
||||
for (const id of ids)
|
||||
@@ -324,6 +634,18 @@ export function setupWidgetsWindowManager(params: {
|
||||
windowContexts.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current widget snapshot without mutating widget state.
|
||||
*
|
||||
* Use when:
|
||||
* - Another service needs to inspect a widget before deciding what to do next
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is the widget identifier to read
|
||||
*
|
||||
* Returns:
|
||||
* - The widget snapshot, or `undefined` when not found
|
||||
*/
|
||||
function getWidgetSnapshot(id: string) {
|
||||
const record = widgetRecords.get(id)
|
||||
if (!record)
|
||||
|
||||
@@ -243,6 +243,16 @@ function applyMapPreset() {
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
|
||||
function applyExtensionUiPreset() {
|
||||
form.componentName = 'extension-ui'
|
||||
form.sizePreset = 'custom'
|
||||
form.customCols = '4'
|
||||
form.customRows = '3'
|
||||
form.componentProps = JSON.stringify({}, null, 2)
|
||||
form.ttlSeconds = ''
|
||||
resetFeedback()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -271,6 +281,13 @@ function applyMapPreset() {
|
||||
>
|
||||
Map Preset
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
:disabled="busy"
|
||||
@click="applyExtensionUiPreset"
|
||||
>
|
||||
Extension UI Preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { WidgetSnapshot } from '../../shared/eventa'
|
||||
import type { WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
|
||||
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
@@ -14,6 +14,7 @@ interface WidgetItem {
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
size: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ function applySnapshot(snapshot: WidgetSnapshot) {
|
||||
componentName: snapshot.componentName,
|
||||
componentProps: snapshot.componentProps ?? {},
|
||||
size: snapshot.size ?? 'm',
|
||||
windowSize: snapshot.windowSize,
|
||||
ttlMs: snapshot.ttlMs ?? 0,
|
||||
}
|
||||
|
||||
@@ -120,10 +122,13 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
widget.value = {
|
||||
applySnapshot({
|
||||
...widget.value,
|
||||
componentProps: body.componentProps ?? widget.value.componentProps,
|
||||
}
|
||||
size: body.size ?? widget.value.size,
|
||||
windowSize: body.windowSize ?? widget.value.windowSize,
|
||||
ttlMs: body.ttlMs ?? widget.value.ttlMs,
|
||||
})
|
||||
})
|
||||
}
|
||||
catch {}
|
||||
@@ -155,15 +160,16 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const Registry: Record<string, ReturnType<typeof defineAsyncComponent>> = {
|
||||
map: defineAsyncComponent(async () => (await import('../widgets/map')).Map),
|
||||
weather: defineAsyncComponent(async () => (await import('../widgets/weather')).Weather),
|
||||
'extension-ui': defineAsyncComponent(async () => (await import('../widgets/extension-ui')).ExtensionUi),
|
||||
'map': defineAsyncComponent(async () => (await import('../widgets/map')).Map),
|
||||
'weather': defineAsyncComponent(async () => (await import('../widgets/weather')).Weather),
|
||||
}
|
||||
|
||||
const GenericWidget = defineComponent({
|
||||
name: 'GenericWidget',
|
||||
props: { title: { type: String, required: true }, modelValue: { type: Object, default: () => ({}) } },
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'h-full w-full flex flex-col gap-2 rounded-xl border border-neutral-200/30 bg-[rgba(28,28,28,0.72)] p-3 text-neutral-100 shadow-[0_8px_20px_rgba(0,0,0,0.35)] backdrop-blur-md dark:border-neutral-700/30' }, [
|
||||
return () => h('div', { class: 'h-full w-full flex flex-col gap-2 rounded-xl bg-[rgba(28,28,28,0.72)] p-3 text-neutral-100 shadow-[0_8px_20px_rgba(0,0,0,0.35)] backdrop-blur-md' }, [
|
||||
h('div', { class: 'flex items-center justify-between' }, [
|
||||
h('div', { class: 'text-sm font-medium opacity-90' }, props.title),
|
||||
]),
|
||||
@@ -208,7 +214,7 @@ function handleClose() {
|
||||
✕
|
||||
</button>
|
||||
<div v-if="!widgetId" class="h-full flex items-center justify-center">
|
||||
<div class="border border-neutral-200/20 rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
<div class="rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
Missing widget id. Launch the window via a component call to populate this view.
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +229,7 @@ function handleClose() {
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<div class="border border-neutral-200/20 rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
<div class="rounded-xl bg-neutral-900/40 px-4 py-3 text-sm text-neutral-200/80 backdrop-blur">
|
||||
{{ loading ? 'Loading widget...' : `Waiting for widget data for "${widgetId}"` }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WidgetInvokers } from './widgets'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../../../widgets/extension-ui/host'
|
||||
import { executeWidgetAction, normalizeComponentProps } from './widgets'
|
||||
|
||||
describe('widgets tool helpers', () => {
|
||||
@@ -60,6 +61,127 @@ describe('widgets tool helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards custom window sizing when spawning a widget', async () => {
|
||||
const invokers = makeInvokers()
|
||||
vi.mocked(invokers.addWidget).mockResolvedValue('sized-widget')
|
||||
|
||||
await executeWidgetAction({
|
||||
action: 'spawn',
|
||||
id: ' sized-widget ',
|
||||
componentName: 'weather',
|
||||
componentProps: '{"city":"Taipei"}',
|
||||
size: 'l',
|
||||
ttlSeconds: 0,
|
||||
windowSize: {
|
||||
width: 620,
|
||||
height: 760,
|
||||
minWidth: 480,
|
||||
minHeight: 320,
|
||||
},
|
||||
} as any, { invokers })
|
||||
|
||||
expect(invokers.addWidget).toHaveBeenCalledWith({
|
||||
id: 'sized-widget',
|
||||
componentName: 'weather',
|
||||
componentProps: { city: 'Taipei' },
|
||||
size: 'l',
|
||||
ttlMs: 0,
|
||||
windowSize: {
|
||||
width: 620,
|
||||
height: 760,
|
||||
minWidth: 480,
|
||||
minHeight: 320,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves extension-ui payloads when spawning dynamic modules', async () => {
|
||||
const invokers = makeInvokers()
|
||||
vi.mocked(invokers.addWidget).mockResolvedValue('chess-main')
|
||||
|
||||
await executeWidgetAction({
|
||||
action: 'spawn',
|
||||
id: ' chess-main ',
|
||||
componentName: 'extension-ui',
|
||||
componentProps: JSON.stringify({
|
||||
moduleId: 'chess-main',
|
||||
title: 'Extension UI',
|
||||
windowSize: {
|
||||
width: 720,
|
||||
height: 540,
|
||||
minWidth: 480,
|
||||
},
|
||||
payload: {
|
||||
side: 'white',
|
||||
},
|
||||
}),
|
||||
size: 'm',
|
||||
ttlSeconds: 0,
|
||||
}, { invokers })
|
||||
|
||||
expect(invokers.addWidget).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'chess-main',
|
||||
componentName: 'extension-ui',
|
||||
componentProps: expect.objectContaining({
|
||||
moduleId: 'chess-main',
|
||||
title: 'Extension UI',
|
||||
windowSize: {
|
||||
width: 720,
|
||||
height: 540,
|
||||
minWidth: 480,
|
||||
},
|
||||
payload: {
|
||||
side: 'white',
|
||||
},
|
||||
}),
|
||||
windowSize: {
|
||||
width: 720,
|
||||
height: 540,
|
||||
minWidth: 480,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('sanitizes reserved extension-ui host props before dispatch', async () => {
|
||||
const invokers = makeInvokers()
|
||||
vi.mocked(invokers.addWidget).mockResolvedValue('guarded-main')
|
||||
|
||||
await executeWidgetAction({
|
||||
action: 'spawn',
|
||||
id: ' guarded-main ',
|
||||
componentName: 'extension-ui',
|
||||
componentProps: JSON.stringify({
|
||||
'moduleId': 'guarded-main',
|
||||
'title': 'Guarded Module',
|
||||
'modelValue': { injected: true },
|
||||
'module': { injected: true },
|
||||
'moduleConfig': { injected: true },
|
||||
'model-value': { injected: true },
|
||||
'module-config': { injected: true },
|
||||
'payload': {
|
||||
safe: true,
|
||||
},
|
||||
}),
|
||||
size: 'm',
|
||||
ttlSeconds: 0,
|
||||
}, { invokers })
|
||||
|
||||
const dispatched = vi.mocked(invokers.addWidget).mock.calls[0]?.[0]
|
||||
expect(dispatched).toBeDefined()
|
||||
expect(dispatched?.componentProps).toMatchObject({
|
||||
moduleId: 'guarded-main',
|
||||
title: 'Guarded Module',
|
||||
payload: {
|
||||
safe: true,
|
||||
},
|
||||
})
|
||||
expect(dispatched?.componentProps).not.toHaveProperty('modelValue')
|
||||
expect(dispatched?.componentProps).not.toHaveProperty('module')
|
||||
expect(dispatched?.componentProps).not.toHaveProperty('moduleConfig')
|
||||
expect(dispatched?.componentProps).not.toHaveProperty('model-value')
|
||||
expect(dispatched?.componentProps).not.toHaveProperty('module-config')
|
||||
})
|
||||
|
||||
it('updates props and trims id', async () => {
|
||||
const invokers = makeInvokers()
|
||||
await executeWidgetAction({
|
||||
@@ -118,4 +240,46 @@ describe('widgets tool helpers', () => {
|
||||
expect(invokers.clearWidgets).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension-ui host helpers', () => {
|
||||
it('removes host-controlled render props from payload props', () => {
|
||||
expect(sanitizeExtensionUiRenderProps({
|
||||
'title': 'Override',
|
||||
'modelValue': { injected: true },
|
||||
'module': { injected: true },
|
||||
'moduleConfig': { injected: true },
|
||||
'model-value': { injected: true },
|
||||
'module-config': { injected: true },
|
||||
'safe': true,
|
||||
})).toEqual({
|
||||
safe: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a registered module before rendering a resolved widget', () => {
|
||||
expect(canRenderExtensionUi({
|
||||
loading: false,
|
||||
moduleSnapshot: undefined,
|
||||
iframeSrc: 'https://example.com',
|
||||
})).toBe(false)
|
||||
|
||||
expect(canRenderExtensionUi({
|
||||
loading: false,
|
||||
error: 'module missing',
|
||||
moduleSnapshot: {
|
||||
moduleId: 'module-1',
|
||||
ownerSessionId: 'session-1',
|
||||
ownerPluginId: 'plugin-1',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'window',
|
||||
state: 'active',
|
||||
runtime: 'electron',
|
||||
revision: 1,
|
||||
updatedAt: Date.now(),
|
||||
config: {},
|
||||
},
|
||||
iframeSrc: 'https://example.com',
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { Tool } from '@xsai/shared-chat'
|
||||
|
||||
import type { WidgetWindowSize } from '../../../../shared/eventa'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { tool } from '@xsai/tool'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { widgetsAdd, widgetsClear, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa'
|
||||
import { normalizeWidgetWindowSize } from '../../../../shared/utils/electron/windows/window-size'
|
||||
import { sanitizeExtensionUiDispatchProps } from '../../../widgets/extension-ui/host'
|
||||
|
||||
type SizePreset = 's' | 'm' | 'l'
|
||||
|
||||
@@ -16,6 +20,7 @@ type WidgetActionInput
|
||||
componentName: string
|
||||
componentProps: string | Record<string, any>
|
||||
size: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds: number
|
||||
}
|
||||
| {
|
||||
@@ -24,6 +29,7 @@ type WidgetActionInput
|
||||
componentProps: string | Record<string, any>
|
||||
componentName?: string
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
}
|
||||
| {
|
||||
@@ -32,6 +38,7 @@ type WidgetActionInput
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
}
|
||||
| {
|
||||
@@ -40,6 +47,7 @@ type WidgetActionInput
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
}
|
||||
| {
|
||||
@@ -48,6 +56,7 @@ type WidgetActionInput
|
||||
componentName?: string
|
||||
componentProps?: string | Record<string, any>
|
||||
size?: SizePreset
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlSeconds?: number
|
||||
}
|
||||
|
||||
@@ -76,12 +85,22 @@ function resolveInvokers(override?: WidgetInvokers): WidgetInvokers {
|
||||
return cachedInvokers
|
||||
}
|
||||
|
||||
const widgetWindowSizeParams = z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
minWidth: z.number().positive().optional(),
|
||||
minHeight: z.number().positive().optional(),
|
||||
maxWidth: z.number().positive().optional(),
|
||||
maxHeight: z.number().positive().optional(),
|
||||
}).strict()
|
||||
|
||||
const widgetParams = z.object({
|
||||
action: z.enum(['spawn', 'update', 'remove', 'clear', 'open']).describe('Choose one: spawn, update, remove, clear, open'),
|
||||
id: z.string().describe('Widget id; required for update/remove, optional for spawn/open'),
|
||||
componentName: z.string().describe('Widget component to render, e.g. weather (required for spawn)'),
|
||||
componentProps: z.string().describe('Widget props as JSON string (e.g. {"city":"Tokyo"})'),
|
||||
size: z.enum(['s', 'm', 'l']),
|
||||
windowSize: widgetWindowSizeParams.optional().describe('Optional pixel window size and constraints, e.g. {"width":620,"height":760,"minWidth":480}'),
|
||||
ttlSeconds: z.number().int().nonnegative().describe('Auto-close timer in seconds (spawn only)'),
|
||||
}).strict()
|
||||
|
||||
@@ -108,6 +127,28 @@ export function normalizeComponentProps(raw?: string | Record<string, any>) {
|
||||
return {}
|
||||
}
|
||||
|
||||
function resolveWindowSize(
|
||||
componentName: string | undefined,
|
||||
componentProps: Record<string, any>,
|
||||
windowSize?: WidgetWindowSize,
|
||||
) {
|
||||
const explicitWindowSize = normalizeWidgetWindowSize(windowSize)
|
||||
if (explicitWindowSize)
|
||||
return explicitWindowSize
|
||||
|
||||
if (componentName?.trim().toLowerCase() !== 'extension-ui')
|
||||
return undefined
|
||||
|
||||
return normalizeWidgetWindowSize(componentProps.windowSize)
|
||||
}
|
||||
|
||||
function sanitizeComponentPropsForDispatch(componentName: string | undefined, componentProps: Record<string, any>) {
|
||||
if (componentName?.trim().toLowerCase() !== 'extension-ui')
|
||||
return componentProps
|
||||
|
||||
return sanitizeExtensionUiDispatchProps(componentProps)
|
||||
}
|
||||
|
||||
export async function executeWidgetAction(input: WidgetActionInput, deps?: { invokers?: WidgetInvokers }) {
|
||||
const invokers = resolveInvokers(deps?.invokers)
|
||||
const normalizedId = input.id?.trim() || undefined
|
||||
@@ -118,12 +159,15 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
|
||||
throw new Error('componentName is required to spawn a widget.')
|
||||
|
||||
const componentProps = normalizeComponentProps(input.componentProps)
|
||||
const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps)
|
||||
const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize)
|
||||
const ttlMs = input.ttlSeconds ? Math.floor(input.ttlSeconds * 1000) : 0
|
||||
const id = await invokers.addWidget({
|
||||
id: normalizedId,
|
||||
componentName: input.componentName,
|
||||
componentProps,
|
||||
componentProps: sanitizedComponentProps,
|
||||
size: input.size ?? 'm',
|
||||
windowSize,
|
||||
ttlMs,
|
||||
})
|
||||
|
||||
@@ -134,9 +178,12 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv
|
||||
throw new Error('id is required to update a widget.')
|
||||
|
||||
const componentProps = normalizeComponentProps(input.componentProps)
|
||||
const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps)
|
||||
const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize)
|
||||
await invokers.updateWidget({
|
||||
id: normalizedId,
|
||||
componentProps,
|
||||
componentProps: sanitizedComponentProps,
|
||||
windowSize,
|
||||
})
|
||||
|
||||
return `Updated widget (${normalizedId}).`
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import { electronPluginGetAssetBaseUrl, electronPluginInspect } from '../../../../shared/eventa'
|
||||
import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module'
|
||||
import { useIframeMessagePort } from '../composables/use-iframe-message-port'
|
||||
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../host'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
modelValue?: Record<string, any>
|
||||
moduleId?: string
|
||||
componentProps?: Record<string, any>
|
||||
payload?: Record<string, any>
|
||||
}>(), {
|
||||
title: 'Extension UI',
|
||||
modelValue: () => ({}),
|
||||
moduleId: undefined,
|
||||
componentProps: undefined,
|
||||
payload: undefined,
|
||||
})
|
||||
|
||||
function firstString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function omitControlFields(record: Record<string, any>) {
|
||||
const {
|
||||
componentProps: _componentProps,
|
||||
moduleId: _moduleId,
|
||||
payload: _payload,
|
||||
title: _title,
|
||||
windowSize: _windowSize,
|
||||
...rest
|
||||
} = record
|
||||
|
||||
return rest
|
||||
}
|
||||
|
||||
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
|
||||
const getPluginAssetBaseUrl = useElectronEventaInvoke(electronPluginGetAssetBaseUrl)
|
||||
|
||||
const model = computed<PluginModuleWidgetPayload & Record<string, unknown>>(() => (
|
||||
isPlainObject(props.modelValue) ? props.modelValue as PluginModuleWidgetPayload & Record<string, unknown> : {} as PluginModuleWidgetPayload & Record<string, unknown>
|
||||
))
|
||||
const moduleId = computed(() => firstString(props.moduleId, model.value.moduleId))
|
||||
const resolvedTitle = computed(() => firstString(props.title, model.value.title, moduleId.value) ?? 'Extension UI')
|
||||
const resolvedWidgetProps = computed(() => sanitizeExtensionUiRenderProps({
|
||||
...omitControlFields(model.value),
|
||||
...(isPlainObject(model.value.componentProps) ? model.value.componentProps as Record<string, unknown> : {}),
|
||||
...(isPlainObject(props.componentProps) ? props.componentProps as Record<string, unknown> : {}),
|
||||
...(isPlainObject(props.payload) ? props.payload as Record<string, unknown> : {}),
|
||||
...(isPlainObject(model.value.payload) ? model.value.payload as Record<string, unknown> : {}),
|
||||
}))
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
moduleSnapshot,
|
||||
moduleConfig,
|
||||
iframeConfig,
|
||||
iframeSrc,
|
||||
iframeSrcdoc,
|
||||
resolvedIframeSrc,
|
||||
iframeMountError,
|
||||
} = useExtensionUIForModule({ moduleId, inspectPluginHost: () => inspectPluginHost(), getPluginAssetBaseUrl: () => getPluginAssetBaseUrl() })
|
||||
|
||||
const iframeSandbox = computed(() => firstString(
|
||||
iframeConfig.value.sandbox,
|
||||
model.value.iframeSandbox,
|
||||
'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
))
|
||||
|
||||
const iframeElement = shallowRef<HTMLIFrameElement | null>(null)
|
||||
|
||||
const { iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort(
|
||||
iframeElement,
|
||||
{
|
||||
moduleId,
|
||||
moduleSnapshot: computed(() => moduleSnapshot.value as PluginHostModuleSummary | undefined),
|
||||
moduleConfig,
|
||||
propsPayload: resolvedWidgetProps,
|
||||
},
|
||||
)
|
||||
|
||||
function setIframeElement(element: Element | ComponentPublicInstance | null) {
|
||||
iframeElement.value = element instanceof HTMLIFrameElement ? element : null
|
||||
}
|
||||
|
||||
const canRenderIframe = computed(() => canRenderExtensionUi({
|
||||
loading: loading.value,
|
||||
error: error.value,
|
||||
iframeLoadError: iframeLoadError.value,
|
||||
iframeMountError: iframeMountError.value,
|
||||
moduleSnapshot: moduleSnapshot.value,
|
||||
iframeSrc: resolvedIframeSrc.value,
|
||||
iframeSrcdoc: iframeSrcdoc.value,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['h-full', 'w-full']">
|
||||
<iframe
|
||||
v-if="canRenderIframe"
|
||||
:ref="setIframeElement"
|
||||
:src="resolvedIframeSrc"
|
||||
:srcdoc="iframeSrcdoc"
|
||||
:sandbox="iframeSandbox"
|
||||
:class="['h-full', 'w-full', 'rounded-xl', 'bg-transparent']"
|
||||
allowtransparency="true"
|
||||
:style="{ colorScheme: 'auto' }"
|
||||
@load="onIframeLoad"
|
||||
@error="onIframeError"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'h-full',
|
||||
'w-full',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-3',
|
||||
'rounded-xl',
|
||||
'bg-[rgba(28,28,28,0.72)]',
|
||||
'p-4',
|
||||
'text-neutral-100',
|
||||
'shadow-[0_8px_20px_rgba(0,0,0,0.35)]',
|
||||
'backdrop-blur-md',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex', 'items-center', 'justify-between', 'gap-3']">
|
||||
<div>
|
||||
<div :class="['text-sm', 'font-semibold']">
|
||||
{{ resolvedTitle }}
|
||||
</div>
|
||||
<div :class="['text-xs', 'opacity-70']">
|
||||
{{ moduleId ?? 'No module id provided' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="moduleSnapshot"
|
||||
:class="['rounded-full', 'bg-white/10', 'px-2', 'py-1', 'text-[11px]', 'uppercase', 'tracking-[0.08em]']"
|
||||
>
|
||||
{{ moduleSnapshot.state }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" :class="['text-sm', 'opacity-80']">
|
||||
Loading extension UI...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" :class="['rounded-lg', 'bg-amber-500/12', 'p-3', 'text-sm', 'text-amber-100']">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="iframeLoadError" :class="['rounded-lg', 'bg-amber-500/12', 'p-3', 'text-sm', 'text-amber-100']">
|
||||
{{ iframeLoadError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="iframeMountError" :class="['rounded-lg', 'bg-amber-500/12', 'p-3', 'text-sm', 'text-amber-100']">
|
||||
{{ iframeMountError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="!iframeSrc && !iframeSrcdoc" :class="['text-sm', 'opacity-80']">
|
||||
This module is registered, but it did not declare an iframe source to mount yet.
|
||||
</div>
|
||||
|
||||
<dl v-if="moduleSnapshot" :class="['grid', 'grid-cols-[auto_1fr]', 'gap-x-3', 'gap-y-2', 'text-xs', 'opacity-80']">
|
||||
<dt>Kit</dt>
|
||||
<dd>{{ moduleSnapshot.kitId }}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ moduleSnapshot.kitModuleType }}</dd>
|
||||
<dt>Runtime</dt>
|
||||
<dd>{{ moduleSnapshot.runtime }}</dd>
|
||||
<dt>Revision</dt>
|
||||
<dd>{{ moduleSnapshot.revision }}</dd>
|
||||
<dt>Iframe</dt>
|
||||
<dd>{{ iframeSrc ? 'src configured' : (iframeSrcdoc ? 'srcdoc configured' : 'unresolved') }}</dd>
|
||||
</dl>
|
||||
|
||||
<div :class="['min-h-0', 'flex-1', 'overflow-auto', 'rounded-lg', 'bg-black/15', 'p-3']">
|
||||
<pre :class="['whitespace-pre-wrap', 'break-words', 'text-[11px]', 'opacity-80']">{{ JSON.stringify({
|
||||
config: moduleConfig,
|
||||
props: resolvedWidgetProps,
|
||||
}, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ExtensionUiHost } from './extension-ui-host.vue'
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
function firstString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trailingSlashesPattern = /\/+$/
|
||||
|
||||
/**
|
||||
* Resolves the inspected extension UI module snapshot and derives iframe-facing config for the host.
|
||||
*
|
||||
* Use when:
|
||||
* - A widget host needs to inspect one plugin module by id before mounting its iframe
|
||||
* - Host code needs normalized `src` / `srcdoc` values and loopback asset URL resolution
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` is the current extension module identifier when one is selected
|
||||
* - `inspectPluginHost` returns the latest module snapshot list from the Electron bridge
|
||||
* - `getPluginAssetBaseUrl` returns the loopback asset base URL when the asset server is available
|
||||
*
|
||||
* Returns:
|
||||
* - Reactive loading and error state for module inspection
|
||||
* - The resolved module snapshot and normalized iframe configuration values
|
||||
*/
|
||||
export function useExtensionUIForModule(options: {
|
||||
moduleId: ComputedRef<string | undefined>
|
||||
inspectPluginHost: () => Promise<{ modules: PluginHostModuleSummary[] }>
|
||||
getPluginAssetBaseUrl: () => Promise<string>
|
||||
}) {
|
||||
const loading = shallowRef(false)
|
||||
const error = shallowRef<string>()
|
||||
const moduleSnapshot = shallowRef<PluginHostModuleSummary>()
|
||||
const pluginAssetBaseUrl = shallowRef<string>()
|
||||
let requestVersion = 0
|
||||
|
||||
async function refreshPluginAssetBaseUrl() {
|
||||
try {
|
||||
pluginAssetBaseUrl.value = await options.getPluginAssetBaseUrl()
|
||||
}
|
||||
catch {
|
||||
pluginAssetBaseUrl.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(options.moduleId, async (nextModuleId) => {
|
||||
const currentRequestVersion = ++requestVersion
|
||||
|
||||
loading.value = true
|
||||
error.value = undefined
|
||||
moduleSnapshot.value = undefined
|
||||
|
||||
await refreshPluginAssetBaseUrl()
|
||||
|
||||
if (!nextModuleId) {
|
||||
if (currentRequestVersion === requestVersion) {
|
||||
loading.value = false
|
||||
error.value = 'Missing extension UI module id.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await options.inspectPluginHost()
|
||||
if (currentRequestVersion !== requestVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
moduleSnapshot.value = snapshot.modules.find(module => module.moduleId === nextModuleId)
|
||||
if (!moduleSnapshot.value) {
|
||||
error.value = `Extension UI module "${nextModuleId}" is not registered.`
|
||||
}
|
||||
}
|
||||
catch (cause) {
|
||||
if (currentRequestVersion !== requestVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
error.value = errorMessageFrom(cause) || 'Failed to inspect extension UI modules.'
|
||||
}
|
||||
finally {
|
||||
if (currentRequestVersion === requestVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const moduleConfig = computed(() => isPlainObject(moduleSnapshot.value?.config) ? moduleSnapshot.value.config as Record<string, unknown> : {})
|
||||
const widgetConfig = computed(() => isPlainObject(moduleConfig.value.widget) ? moduleConfig.value.widget as Record<string, unknown> : {})
|
||||
const iframeConfig = computed(() => isPlainObject(widgetConfig.value.iframe) ? widgetConfig.value.iframe as Record<string, unknown> : {})
|
||||
|
||||
const iframeSrc = computed(() => firstString(
|
||||
iframeConfig.value.src,
|
||||
widgetConfig.value.iframeSrc,
|
||||
moduleConfig.value.iframeSrc,
|
||||
))
|
||||
|
||||
const iframeSrcdoc = computed(() => firstString(
|
||||
iframeConfig.value.srcdoc,
|
||||
widgetConfig.value.iframeSrcdoc,
|
||||
moduleConfig.value.iframeSrcdoc,
|
||||
))
|
||||
|
||||
const resolvedIframeSrc = computed(() => {
|
||||
const src = iframeSrc.value
|
||||
if (!src) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (src.startsWith('/_airi/plugins/')) {
|
||||
const baseUrl = pluginAssetBaseUrl.value
|
||||
if (!baseUrl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new URL(src, `${baseUrl.replace(trailingSlashesPattern, '')}/`).toString()
|
||||
}
|
||||
|
||||
return src
|
||||
})
|
||||
|
||||
const iframeMountError = computed(() => {
|
||||
if (!iframeSrc.value?.startsWith('/_airi/plugins/')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (resolvedIframeSrc.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'Plugin asset loopback server is unavailable.'
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
moduleSnapshot,
|
||||
moduleConfig,
|
||||
widgetConfig,
|
||||
iframeConfig,
|
||||
iframeSrc,
|
||||
iframeSrcdoc,
|
||||
resolvedIframeSrc,
|
||||
iframeMountError,
|
||||
pluginAssetBaseUrl,
|
||||
|
||||
refreshPluginAssetBaseUrl,
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import type { MaybeElementRef } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
|
||||
import { unrefElement } from '@vueuse/core'
|
||||
import { onBeforeUnmount, shallowRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
extensionUiBridgeEventaChannel,
|
||||
extensionUiBridgeInitEvent,
|
||||
extensionUiBridgeReadyEvent,
|
||||
} from '../shared/eventa'
|
||||
import { createWindowMessageEventaContext } from '../shared/eventa-runtime'
|
||||
|
||||
/**
|
||||
* Manages typed parent-to-iframe messaging for one extension UI iframe.
|
||||
*
|
||||
* Use when:
|
||||
* - A renderer widget mounts an extension iframe and needs to keep it synchronized with host state
|
||||
* - A module iframe needs a typed postMessage transport for init and ready handshakes
|
||||
*
|
||||
* Expects:
|
||||
* - `target` resolves to the mounted iframe element when available
|
||||
* - `moduleId` changes when the mounted extension module changes
|
||||
* - `moduleSnapshot`, `moduleConfig`, and `propsPayload` stay structured-clone-safe for postMessage transport
|
||||
*
|
||||
* Returns:
|
||||
* - Eventa iframe context for optional host-side message handling
|
||||
* - Reactive iframe load error state
|
||||
* - iframe load/error handlers for the host component template
|
||||
*/
|
||||
export function useIframeMessagePort(
|
||||
target: MaybeElementRef,
|
||||
options: {
|
||||
moduleId: ComputedRef<string | undefined>
|
||||
moduleSnapshot: ComputedRef<PluginHostModuleSummary | undefined>
|
||||
moduleConfig: ComputedRef<Record<string, unknown>>
|
||||
propsPayload: ComputedRef<Record<string, unknown>>
|
||||
},
|
||||
) {
|
||||
const iframeLoadError = shallowRef<string>()
|
||||
|
||||
const iframeRuntime = createWindowMessageEventaContext({
|
||||
channel: extensionUiBridgeEventaChannel,
|
||||
currentWindow: window,
|
||||
expectedSource: () => {
|
||||
const iframeElement = unrefElement(target)
|
||||
return iframeElement instanceof HTMLIFrameElement ? iframeElement.contentWindow : null
|
||||
},
|
||||
targetWindow: () => {
|
||||
const iframeElement = unrefElement(target)
|
||||
return iframeElement instanceof HTMLIFrameElement ? iframeElement.contentWindow : null
|
||||
},
|
||||
})
|
||||
|
||||
function createInitPayload() {
|
||||
return {
|
||||
moduleId: options.moduleSnapshot.value?.moduleId,
|
||||
module: options.moduleSnapshot.value,
|
||||
config: options.moduleConfig.value,
|
||||
props: options.propsPayload.value,
|
||||
}
|
||||
}
|
||||
|
||||
function emitInitPayload() {
|
||||
iframeRuntime.context.emit(extensionUiBridgeInitEvent, createInitPayload())
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
iframeLoadError.value = undefined
|
||||
emitInitPayload()
|
||||
}
|
||||
|
||||
function onIframeError() {
|
||||
iframeLoadError.value = 'Failed to load extension UI iframe source.'
|
||||
}
|
||||
|
||||
iframeRuntime.context.on(extensionUiBridgeReadyEvent, () => {
|
||||
emitInitPayload()
|
||||
})
|
||||
|
||||
watch(options.moduleId, () => {
|
||||
emitInitPayload()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(options.propsPayload, () => {
|
||||
emitInitPayload()
|
||||
})
|
||||
|
||||
watch(options.moduleConfig, () => {
|
||||
emitInitPayload()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
iframeRuntime.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
context: iframeRuntime.context,
|
||||
iframeLoadError,
|
||||
onIframeLoad,
|
||||
onIframeError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PluginHostModuleSummary } from '../../../shared/eventa'
|
||||
|
||||
const extensionUiDispatchReservedPropKeys = new Set([
|
||||
'modelValue',
|
||||
'module',
|
||||
'moduleConfig',
|
||||
'model-value',
|
||||
'module-config',
|
||||
])
|
||||
|
||||
const extensionUiRenderReservedPropKeys = new Set([
|
||||
'title',
|
||||
...extensionUiDispatchReservedPropKeys,
|
||||
])
|
||||
|
||||
function sanitizeExtensionUiProps(record: Record<string, any>, reservedKeys: Set<string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(([key]) => !reservedKeys.has(key)),
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeExtensionUiDispatchProps(record: Record<string, any>) {
|
||||
return sanitizeExtensionUiProps(record, extensionUiDispatchReservedPropKeys)
|
||||
}
|
||||
|
||||
export function sanitizeExtensionUiRenderProps(record: Record<string, any>) {
|
||||
return sanitizeExtensionUiProps(record, extensionUiRenderReservedPropKeys)
|
||||
}
|
||||
|
||||
export function canRenderExtensionUi(options: {
|
||||
loading: boolean
|
||||
error?: string
|
||||
iframeLoadError?: string
|
||||
iframeMountError?: string
|
||||
moduleSnapshot?: PluginHostModuleSummary
|
||||
iframeSrc?: string
|
||||
iframeSrcdoc?: string
|
||||
}) {
|
||||
return Boolean(
|
||||
options.moduleSnapshot
|
||||
&& (options.iframeSrc || options.iframeSrcdoc)
|
||||
&& !options.loading
|
||||
&& !options.error
|
||||
&& !options.iframeLoadError
|
||||
&& !options.iframeMountError,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ExtensionUiHost as ExtensionUi } from './components'
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extensionUiBridgeInitEvent,
|
||||
extensionUiBridgePublishEvent,
|
||||
} from './eventa'
|
||||
import { createWindowMessageEventaContext } from './eventa-runtime'
|
||||
|
||||
class MockWindow {
|
||||
peer?: MockWindow
|
||||
|
||||
private readonly listeners = new Map<string, Map<EventListenerOrEventListenerObject, (event: Event) => void>>()
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Map())
|
||||
}
|
||||
|
||||
const handler = typeof listener === 'function'
|
||||
? listener
|
||||
: (event: Event) => listener.handleEvent(event)
|
||||
|
||||
this.listeners.get(type)?.set(listener, handler)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
postMessage(data: unknown) {
|
||||
const messageEvent = {
|
||||
data,
|
||||
source: this.peer ?? null,
|
||||
} as MessageEvent
|
||||
|
||||
for (const listener of this.listeners.get('message')?.values() ?? []) {
|
||||
listener(messageEvent as unknown as Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* describe('createWindowMessageEventaContext', () => {
|
||||
* it('relays typed events between parent and iframe windows', async () => {
|
||||
* expect(true).toBe(true)
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
describe('createWindowMessageEventaContext', () => {
|
||||
/**
|
||||
* @example
|
||||
* it('relays typed events between parent and iframe windows', async () => {
|
||||
* expect(payload.moduleId).toBe('module-chess')
|
||||
* })
|
||||
*/
|
||||
it('relays typed events between parent and iframe windows', async () => {
|
||||
const parentWindow = new MockWindow()
|
||||
const iframeWindow = new MockWindow()
|
||||
parentWindow.peer = iframeWindow
|
||||
iframeWindow.peer = parentWindow
|
||||
|
||||
const host = createWindowMessageEventaContext({
|
||||
channel: 'test:extension-ui',
|
||||
currentWindow: parentWindow as unknown as Window,
|
||||
expectedSource: () => iframeWindow as unknown as Window,
|
||||
targetWindow: () => iframeWindow as unknown as Window,
|
||||
})
|
||||
const iframe = createWindowMessageEventaContext({
|
||||
channel: 'test:extension-ui',
|
||||
currentWindow: iframeWindow as unknown as Window,
|
||||
expectedSource: () => parentWindow as unknown as Window,
|
||||
targetWindow: () => parentWindow as unknown as Window,
|
||||
})
|
||||
|
||||
const initPayload = new Promise<{ moduleId: string }>((resolve) => {
|
||||
iframe.context.on(extensionUiBridgeInitEvent, (event) => {
|
||||
if (!event.body?.moduleId) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ moduleId: event.body.moduleId })
|
||||
})
|
||||
})
|
||||
|
||||
host.context.emit(extensionUiBridgeInitEvent, {
|
||||
moduleId: 'module-chess',
|
||||
config: {},
|
||||
module: undefined,
|
||||
props: {},
|
||||
})
|
||||
|
||||
await expect(initPayload).resolves.toEqual(expect.objectContaining({
|
||||
moduleId: 'module-chess',
|
||||
}))
|
||||
|
||||
const publishedPayload = new Promise<Record<string, unknown>>((resolve) => {
|
||||
host.context.on(extensionUiBridgePublishEvent, (event) => {
|
||||
if (!event.body) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(event.body)
|
||||
})
|
||||
})
|
||||
|
||||
iframe.context.emit(extensionUiBridgePublishEvent, {
|
||||
topic: {
|
||||
namespace: 'plugin.chess',
|
||||
name: 'request',
|
||||
},
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(publishedPayload).resolves.toEqual(expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
requestId: 'req-1',
|
||||
}),
|
||||
}))
|
||||
|
||||
host.dispose()
|
||||
iframe.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createContext } from '@moeru/eventa/adapters/event-target'
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
const EVENTA_MESSAGE_EVENT = 'eventa:message'
|
||||
|
||||
interface WindowMessageEventaEnvelope {
|
||||
__eventa: true
|
||||
channel: string
|
||||
sourceId: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
type RuntimeEventListener = (event: Event) => void
|
||||
|
||||
class WindowMessageEventTarget implements EventTarget {
|
||||
private readonly listeners = new Map<string, Map<EventListenerOrEventListenerObject, RuntimeEventListener>>()
|
||||
|
||||
constructor(private readonly send: (message: WindowMessageEventaEnvelope) => void) {}
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Map())
|
||||
}
|
||||
|
||||
const handler: RuntimeEventListener = typeof listener === 'function'
|
||||
? listener
|
||||
: event => listener.handleEvent(event)
|
||||
|
||||
this.listeners.get(type)?.set(listener, handler)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event) {
|
||||
const detail = 'detail' in event ? (event as CustomEvent).detail : undefined
|
||||
this.send({
|
||||
__eventa: true,
|
||||
channel: '',
|
||||
sourceId: '',
|
||||
detail,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
emit(type: string, detail?: unknown) {
|
||||
const event = { type, detail } as CustomEvent
|
||||
for (const listener of this.listeners.get(type)?.values() ?? []) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowMessageEventaEnvelope(value: unknown, channel: string): value is WindowMessageEventaEnvelope {
|
||||
if (!isPlainObject(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value.__eventa === true
|
||||
&& value.channel === channel
|
||||
&& typeof value.sourceId === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Eventa context backed by `window.postMessage`.
|
||||
*
|
||||
* Use when:
|
||||
* - A host window needs typed messaging with an iframe
|
||||
* - An iframe wants Eventa ergonomics without a bespoke adapter package
|
||||
*
|
||||
* Expects:
|
||||
* - `currentWindow` is the window receiving `message` events
|
||||
* - `targetWindow` resolves to the peer window when outbound events are emitted
|
||||
* - `channel` uniquely scopes one logical bridge on the page
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context plus a disposer that removes window listeners
|
||||
*/
|
||||
export function createWindowMessageEventaContext(options: {
|
||||
channel: string
|
||||
currentWindow: Window
|
||||
targetWindow: () => Window | null | undefined
|
||||
expectedSource?: () => MessageEventSource | null | undefined
|
||||
targetOrigin?: string
|
||||
}) {
|
||||
const sourceId = Math.random().toString(36).slice(2, 10)
|
||||
const eventTarget = new WindowMessageEventTarget((message) => {
|
||||
const targetWindow = options.targetWindow()
|
||||
if (!targetWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
targetWindow.postMessage({
|
||||
...message,
|
||||
channel: options.channel,
|
||||
sourceId,
|
||||
}, options.targetOrigin ?? '*')
|
||||
})
|
||||
|
||||
const { context, dispose } = createContext(eventTarget, {
|
||||
messageEventName: EVENTA_MESSAGE_EVENT,
|
||||
errorEventName: false,
|
||||
})
|
||||
|
||||
const onWindowMessage = (event: MessageEvent) => {
|
||||
if (!isWindowMessageEventaEnvelope(event.data, options.channel)) {
|
||||
return
|
||||
}
|
||||
|
||||
const expectedSource = options.expectedSource?.()
|
||||
if (expectedSource && event.source !== expectedSource) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.sourceId === sourceId) {
|
||||
return
|
||||
}
|
||||
|
||||
eventTarget.emit(EVENTA_MESSAGE_EVENT, event.data.detail)
|
||||
}
|
||||
|
||||
options.currentWindow.addEventListener('message', onWindowMessage)
|
||||
|
||||
return {
|
||||
context,
|
||||
dispose: () => {
|
||||
options.currentWindow.removeEventListener('message', onWindowMessage)
|
||||
dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PluginHostModuleSummary } from '../../../../shared/eventa'
|
||||
|
||||
import { defineEventa } from '@moeru/eventa'
|
||||
|
||||
export const extensionUiBridgeEventaChannel = 'airi:extension-ui:bridge'
|
||||
|
||||
/**
|
||||
* Initializes an extension UI iframe with the latest host-side snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - A module iframe finishes loading and needs its initial model
|
||||
* - Host-side props or config changed and the iframe should resync
|
||||
*
|
||||
* Expects:
|
||||
* - `config` and `props` are structured-clone-safe records
|
||||
* - `module` is the currently inspected module snapshot when available
|
||||
*
|
||||
* Returns:
|
||||
* - The payload forwarded from host to iframe over the Eventa bridge
|
||||
*/
|
||||
export interface ExtensionUiBridgeInitPayload {
|
||||
moduleId?: string
|
||||
module?: PluginHostModuleSummary
|
||||
config: Record<string, unknown>
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured-clone-safe module bridge payload.
|
||||
*
|
||||
* Use when:
|
||||
* - An iframe publishes a channel envelope to the host
|
||||
* - The host forwards a channel envelope back into the iframe
|
||||
*
|
||||
* Expects:
|
||||
* - Consumers validate envelope fields at the boundary
|
||||
*
|
||||
* Returns:
|
||||
* - A generic message envelope that stays transport-agnostic
|
||||
*/
|
||||
export type ExtensionUiBridgeEnvelope = Record<string, unknown>
|
||||
|
||||
export const extensionUiBridgeInitEvent = defineEventa<ExtensionUiBridgeInitPayload>('eventa:event:extension-ui:bridge:init')
|
||||
export const extensionUiBridgeReadyEvent = defineEventa<void>('eventa:event:extension-ui:bridge:ready')
|
||||
export const extensionUiBridgePublishEvent = defineEventa<ExtensionUiBridgeEnvelope>('eventa:event:extension-ui:bridge:publish')
|
||||
export const extensionUiBridgeBroadcastEvent = defineEventa<ExtensionUiBridgeEnvelope>('eventa:event:extension-ui:bridge:broadcast')
|
||||
@@ -53,10 +53,12 @@ export const electronSetUpdaterPreferences = defineInvokeEventa<ElectronUpdaterP
|
||||
|
||||
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'
|
||||
@@ -95,12 +97,31 @@ export function createRequestWindowEventa(namespace: string) {
|
||||
export const noticeWindowEventa = createRequestWindowEventa('notice')
|
||||
|
||||
// Widgets / Adhoc window events
|
||||
export interface WidgetWindowSize {
|
||||
width: number
|
||||
height: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
maxWidth?: number
|
||||
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
|
||||
componentProps?: Record<string, any>
|
||||
// size presets or explicit spans; renderer decides mapping
|
||||
size?: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
windowSize?: WidgetWindowSize
|
||||
// auto-dismiss in ms; if omitted, persistent until closed by user
|
||||
ttlMs?: number
|
||||
}
|
||||
@@ -110,14 +131,24 @@ export interface WidgetSnapshot {
|
||||
componentName: string
|
||||
componentProps: Record<string, any>
|
||||
size: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs: number
|
||||
}
|
||||
|
||||
export interface WidgetsUpdatePayload {
|
||||
id: string
|
||||
componentProps?: Record<string, any>
|
||||
size?: 's' | 'm' | 'l' | { cols?: number, rows?: number }
|
||||
windowSize?: WidgetWindowSize
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface PluginManifestSummary {
|
||||
name: string
|
||||
entrypoints: Record<string, string | undefined>
|
||||
path: string
|
||||
enabled: boolean
|
||||
autoReload: boolean
|
||||
loaded: boolean
|
||||
isNew: boolean
|
||||
}
|
||||
@@ -151,13 +182,57 @@ export interface PluginHostSessionSummary {
|
||||
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[]
|
||||
@@ -217,12 +292,19 @@ 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')
|
||||
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
||||
export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:widgets:clear')
|
||||
export const widgetsUpdate = defineInvokeEventa<void, { id: string, componentProps?: Record<string, any> }>('eventa:invoke:electron:windows:widgets:update')
|
||||
export const widgetsUpdate = defineInvokeEventa<void, WidgetsUpdatePayload>('eventa:invoke:electron:windows:widgets:update')
|
||||
export const widgetsFetch = defineInvokeEventa<WidgetSnapshot | void, { id: string }>('eventa:invoke:electron:windows:widgets:fetch')
|
||||
export const widgetsPrepareWindow = defineInvokeEventa<string | undefined, { id?: string }>('eventa:invoke:electron:windows:widgets:prepare')
|
||||
|
||||
@@ -278,7 +360,7 @@ export const stageThreeRuntimeTraceRemoteDisableEvent = defineEventa<StageThreeR
|
||||
export const widgetsRenderEvent = defineEventa<WidgetSnapshot>('eventa:event:electron:windows:widgets:render')
|
||||
export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove')
|
||||
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
|
||||
export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record<string, any> }>('eventa:event:electron:windows:widgets:update')
|
||||
export const widgetsUpdateEvent = defineEventa<WidgetsUpdatePayload>('eventa:event:electron:windows:widgets:update')
|
||||
|
||||
// Onboarding window events
|
||||
export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close')
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { WidgetWindowSize } from '../../../eventa'
|
||||
|
||||
/**
|
||||
* Normalizes widget window size input before it is applied to an Electron window.
|
||||
*
|
||||
* Use when:
|
||||
* - Widget payloads provide optional window size overrides
|
||||
* - Main and renderer callers need one shared sanitization path before display clamping
|
||||
*
|
||||
* Expects:
|
||||
* - `width` and `height` are finite positive numbers when present
|
||||
*
|
||||
* Returns:
|
||||
* - A floored size object with invalid constraints removed, or `undefined` when the payload is unusable
|
||||
*
|
||||
* Before:
|
||||
* - `{ width: 620.9, height: 480.2, minWidth: -10, maxHeight: 720.8 }`
|
||||
* - `{ width: 0, height: 480 }`
|
||||
*
|
||||
* After:
|
||||
* - `{ width: 620, height: 480, maxHeight: 720 }`
|
||||
* - `undefined`
|
||||
*/
|
||||
export function normalizeWidgetWindowSize(
|
||||
windowSize?: WidgetWindowSize | Record<string, unknown>,
|
||||
): WidgetWindowSize | undefined {
|
||||
if (!windowSize || typeof windowSize !== 'object' || Array.isArray(windowSize))
|
||||
return undefined
|
||||
|
||||
const width = Number(windowSize.width)
|
||||
const height = Number(windowSize.height)
|
||||
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0)
|
||||
return undefined
|
||||
|
||||
const normalized: WidgetWindowSize = {
|
||||
width: Math.floor(width),
|
||||
height: Math.floor(height),
|
||||
}
|
||||
|
||||
for (const key of ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'] as const) {
|
||||
const value = windowSize[key]
|
||||
if (value === undefined)
|
||||
continue
|
||||
|
||||
const numericValue = Number(value)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0)
|
||||
normalized[key] = Math.floor(numericValue)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
Reference in New Issue
Block a user