feat(stage-tamagotchi): widget now supports customizable ui from plugin/extension

This commit is contained in:
Neko Ayaka
2026-04-21 02:23:34 +08:00
parent 033e76c2f1
commit a406ffb67c
21 changed files with 1937 additions and 41 deletions
@@ -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}).`
@@ -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'
@@ -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,
}
}
@@ -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'
@@ -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')
+84 -2
View File
@@ -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
}