diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts index eca23a6fc..913a5e206 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts @@ -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['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) { defineInvokeHandlers(params.context, { widgetsPrepareWindow, @@ -31,37 +58,43 @@ export function createWidgetsService(params: { context: ReturnType { 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.'), + ) }, }) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts new file mode 100644 index 000000000..d62b5172d --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts @@ -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') + }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts new file mode 100644 index 000000000..9bc48ce79 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts @@ -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): Record { + 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) +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts new file mode 100644 index 000000000..560ae0b2e --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts @@ -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, + }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 360d22bf7..8425262f9 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -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 + /** + * 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 + /** + * 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 - updateWidget: (payload: { id: string, componentProps?: Record }) => Promise + /** + * 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 + /** + * 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 + /** + * 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 + /** + * 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) { + 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) { + 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 { 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) { 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 { 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 { 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 }) { + /** + * 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) diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue index 1b4198d9d..13e045d7d 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue @@ -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() +}