From 99ce4bdcd01e9df37486f44818db635df0acd6b4 Mon Sep 17 00:00:00 2001 From: Neko Date: Sun, 30 Aug 2026 14:11:14 +0800 Subject: [PATCH] fix(stage-tamagotchi): enforce widget window lifecycle (#2409) --- .../widgets/iframe-request-coordinator.ts | 6 +- .../src/main/windows/widgets/index.ts | 341 +++++++++--------- .../main/windows/widgets/lifecycle.test.ts | 215 +++++++++++ .../windows/widgets/rpc/index.electron.ts | 14 +- .../src/renderer/pages/widgets.vue | 41 +-- .../renderer/stores/tools/builtin/weather.ts | 1 - .../stores/tools/builtin/widgets.test.ts | 2 +- .../renderer/stores/tools/builtin/widgets.ts | 6 +- .../src/shared/eventa/index.ts | 3 +- 9 files changed, 419 insertions(+), 210 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts index d160fb194..c62243b61 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts @@ -23,8 +23,8 @@ export interface WidgetIframeRequestCoordinatorOptions { emitRequest: (payload: WidgetsIframeRequestPayload) => void /** Returns whether the widget id currently has a mounted main-process record. */ hasWidget: (id: string) => boolean - /** Returns whether a renderer relay is available to receive iframe request events. */ - hasRelay: () => boolean + /** Returns whether the widget id has a renderer relay for iframe request events. */ + hasRelay: (id: string) => boolean } /** @@ -55,7 +55,7 @@ export function createWidgetIframeRequestCoordinator(options: WidgetIframeReques ): Promise { if (!options.hasWidget(id)) return Promise.reject(new Error(`Gamelet \`${id}\` is not open.`)) - if (!options.hasRelay()) + if (!options.hasRelay(id)) return Promise.reject(new Error('Gamelet iframe relay is not available.')) const requestId = randomUUID() diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index e648f1446..e6b48d2c5 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -26,28 +26,26 @@ import { widgetsClearEvent, widgetsIframeRequestEvent, widgetsRemoveEvent, widge 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 { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window' import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator' import { setupWidgetsWindowInvokes } from './rpc/index.electron' /** - * Controls the overlay widget window lifecycle and widget registry. + * Controls each overlay widget instance and its Electron window. * * Use when: * - Electron services need to spawn or update overlay widgets - * - Renderer invokes need a stable window-management bridge + * - Renderer invokes need one lifecycle owner for widget state and windows * * Expects: - * - A reusable Electron widget window managed by {@link setupWidgetsWindowManager} - * - Widget ids remain stable across updates for the same widget surface + * - Widget ids identify one widget instance and its window * * Returns: - * - An imperative manager for opening the widget window and mutating widget state + * - A manager that opens, updates, and destroys widget instances */ export interface WidgetsWindowManager { /** - * Resolves the shared widgets window instance. + * Resolves the default widgets window. * * Use when: * - A caller needs direct access to the backing Electron window @@ -56,7 +54,7 @@ export interface WidgetsWindowManager { * - The window manager has already been initialized * * Returns: - * - The live widgets {@link BrowserWindow}, creating it if necessary + * - The live default widgets {@link BrowserWindow}, creating it if necessary */ getWindow: () => Promise /** @@ -74,11 +72,11 @@ export interface WidgetsWindowManager { */ openWindow: (params?: { id?: string }) => Promise /** - * Inserts or replaces a widget snapshot and renders it in the widgets window. + * Creates a widget instance and renders it in its own 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 + * - A caller has already prepared an id and wants to attach widget content * * Expects: * - `payload.componentName` identifies a registered renderer widget @@ -110,7 +108,7 @@ export interface WidgetsWindowManager { * - `id` matches a widget previously created or prepared through this manager * * Returns: - * - Resolves after the widget has been removed and the renderer notified + * - Resolves after the widget record and its Electron window are destroyed */ removeWidget: (id: string) => Promise /** @@ -179,7 +177,7 @@ export interface WidgetsWindowManager { * - The caller wants a stable route or window context before rendering * * Expects: - * - `options.id`, when provided, should be stable for later reuse + * - `options.id`, when provided, identifies the reserved widget instance * * Returns: * - The prepared widget id bound to a future window context @@ -253,8 +251,12 @@ interface WidgetRecord extends WidgetSnapshot { interface WidgetWindowContext { widgetId: string - windowBuilder: () => Promise + currentRoute?: string + disposeInvokes?: () => void + eventa?: ReturnType + persistBounds: boolean window?: BrowserWindow + windowSetupPromise?: Promise } /** @@ -269,12 +271,12 @@ interface WidgetWindowContext { * - Renderer widget routes are available under the widgets page * * Returns: - * - A {@link WidgetsWindowManager} that coordinates widget state and window reuse + * - A {@link WidgetsWindowManager} that owns widget state and per-instance windows * * Call stack: * * setupWidgetsWindowManager (./index) - * -> {@link createReusableWindow} + * -> createWindowForContext (./index) * -> {@link setupWidgetsWindowInvokes} * -> {@link createContext} */ @@ -289,86 +291,24 @@ export function setupWidgetsWindowManager(params: { const getConfig = (): WidgetsWindowConfig => getConfigRaw() ?? {} setup() - let eventaContext: ReturnType['context'] | undefined const widgetRecords = new Map() const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() const windowContexts = new Map() + const defaultWindowContext: WidgetWindowContext = { + widgetId: '', + persistBounds: true, + } const iframeRequests = createWidgetIframeRequestCoordinator({ hasWidget: id => widgetRecords.has(id), - hasRelay: () => Boolean(eventaContext), - emitRequest: payload => eventaContext?.emit(widgetsIframeRequestEvent, payload), + hasRelay: id => Boolean(windowContexts.get(id)?.eventa), + emitRequest: payload => windowContexts.get(payload.id)?.eventa?.context.emit(widgetsIframeRequestEvent, payload), }) const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const defaultRoute = '/widgets' - let pendingRoute: string | undefined - let currentRoute: string | undefined - let activeWidgetsWindow: BrowserWindow | undefined - let persistWindowBounds = true - let widgetsManager: WidgetsWindowManager | undefined - const reusable = createReusableWindow(async () => { - // TODO: once we refactored eventa to support window-namespaced contexts, - // we can remove the setMaxListeners call below since eventa will be able to dispatch and - // manage events within eventa's context system. - ipcMain.setMaxListeners(0) - - const window = createWidgetsWindow() - activeWidgetsWindow = window - const { context } = createContext(ipcMain, window) - eventaContext = context - - const saved = getConfig().bounds - if (saved) { - const work = screen.getDisplayMatching(saved).workArea - const clamped: Rectangle = { - x: Math.min(Math.max(saved.x, work.x), work.x + work.width - saved.width), - y: Math.min(Math.max(saved.y, work.y), work.y + work.height - saved.height), - width: Math.min(saved.width, work.width), - height: Math.min(saved.height, work.height), - } - window.setBounds(clamped) - } - else { - window.setBounds(computeDefaultBounds()) - } - - const persist = () => { - if (!persistWindowBounds) - return - update({ bounds: window.getBounds() }) - } - window.on('resize', persist) - window.on('move', persist) - - const initialRoute = pendingRoute ?? defaultRoute - await setupWidgetsWindowInvokes({ - widgetWindow: window, - widgetsManager: widgetsManager!, - i18n: params.i18n, - serverChannel: params.serverChannel, - }) - - await loadWithRoute(window, initialRoute) - - pendingRoute = undefined - - window.on('closed', () => { - iframeRequests.rejectAllPendingWidgetIframeRequests() - eventaContext = undefined - currentRoute = undefined - if (activeWidgetsWindow === window) - activeWidgetsWindow = undefined - windowContexts.forEach((context) => { - if (context.window === window) - context.window = undefined - }) - }) - return window - }) - /** * Reserves a widget id and its window context before rendering. * @@ -377,7 +317,7 @@ export function setupWidgetsWindowManager(params: { * - `openWindow({ id })` should target a dedicated widget route * * Expects: - * - `options.id`, when supplied, should be reused for future updates + * - `options.id`, when supplied, identifies the reserved widget instance * * Returns: * - The prepared widget id @@ -387,8 +327,7 @@ export function setupWidgetsWindowManager(params: { if (!windowContexts.has(id)) { windowContexts.set(id, { widgetId: id, - windowBuilder: () => getWindow(), - window: undefined, + persistBounds: true, }) } return id @@ -399,42 +338,47 @@ export function setupWidgetsWindowManager(params: { return snapshot } - function upsertRecord(snapshot: WidgetSnapshot) { - const existing = widgetRecords.get(snapshot.id) - if (existing?.timer) - clearTimeout(existing.timer) + function scheduleDestruction(record: WidgetRecord) { + if (record.timer) + clearTimeout(record.timer) + record.timer = record.ttlMs > 0 + ? setTimeout(destroyWidget, record.ttlMs, record.id) + : undefined + } + + function createRecord(snapshot: WidgetSnapshot) { const record: WidgetRecord = { ...snapshot } - - if (snapshot.ttlMs > 0) { - record.timer = setTimeout(removeWidgetInternal, snapshot.ttlMs, snapshot.id) - } - + scheduleDestruction(record) widgetRecords.set(snapshot.id, record) } - function removeWidgetInternal(id: string, emitEvent = true) { - const existing = widgetRecords.get(id) - if (!existing) + // Each widget id owns one record, one timer, and one window context. + // Delete the owned state before window.close() so the closed handler can safely re-enter this operation. + function destroyWidget(id: string) { + const record = widgetRecords.get(id) + const windowContext = windowContexts.get(id) + if (!record && !windowContext) return - if (existing.timer) - clearTimeout(existing.timer) + if (record?.timer) + clearTimeout(record.timer) widgetRecords.delete(id) windowContexts.delete(id) iframeRequests.rejectPendingWidgetIframeRequests(id) + windowContext?.eventa?.context.emit(widgetsRemoveEvent, { id }) - if (emitEvent) { - eventaContext?.emit(widgetsRemoveEvent, { id }) - } + const window = windowContext?.window + if (window && !window.isDestroyed()) + safeClose(window) } - async function loadWithRoute(window: BrowserWindow, route: string) { + async function loadWithRoute(windowContext: WidgetWindowContext, window: BrowserWindow, route: string) { await load(window, withHashRoute(rendererBase, route, { query: { 'synced-leader': 'false' }, })) - currentRoute = route + windowContext.currentRoute = route } function applyStoredOrDefaultBounds(window: BrowserWindow) { @@ -456,20 +400,20 @@ export function setupWidgetsWindowManager(params: { window.setBounds(computeDefaultBounds()) } - function applyWindowLayout(window: BrowserWindow, snapshot?: Pick) { + function applyWindowLayout(windowContext: WidgetWindowContext, window: BrowserWindow, snapshot?: Pick) { const display = screen.getDisplayMatching(window.getBounds()) const work = display.workArea const windowSize = normalizeWidgetWindowSize(snapshot?.windowSize) if (!windowSize) { - persistWindowBounds = true + windowContext.persistBounds = true window.setMinimumSize(0, 0) window.setMaximumSize(work.width, work.height) applyStoredOrDefaultBounds(window) return } - persistWindowBounds = false + windowContext.persistBounds = 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) @@ -488,44 +432,115 @@ export function setupWidgetsWindowManager(params: { }) } - async function getWindowFromContext(context?: WidgetWindowContext): Promise { - if (!context) - return getWindow() - if (context.window && !context.window.isDestroyed()) - return context.window - const resolved = await context.windowBuilder() - context.window = resolved - return resolved + async function createWindowForContext(windowContext: WidgetWindowContext, initialRoute: string): Promise { + // TODO: once we refactored eventa to support window-namespaced contexts, + // we can remove the setMaxListeners call below since eventa will be able to dispatch and + // manage events within eventa's context system. + ipcMain.setMaxListeners(0) + + const window = createWidgetsWindow() + windowContext.window = window + windowContext.eventa = createContext(ipcMain, window) + + /** + * Releases the state owned by one closed widget window. + * + * Triggering workflow: + * + * {@link BrowserWindow} + * -> `window.on` + * -> `closed` + * -> handleWindowClosed + * + * Upstream: + * - The Electron `closed` event from this widget window + * + * Downstream: + * - {@link destroyWidget} + * - The Eventa adapter `dispose` operation + */ + function handleWindowClosed() { + windowContext.disposeInvokes?.() + windowContext.disposeInvokes = undefined + windowContext.eventa?.dispose() + windowContext.eventa = undefined + windowContext.currentRoute = undefined + windowContext.window = undefined + + if (windowContext.widgetId) + destroyWidget(windowContext.widgetId) + } + + window.on('closed', handleWindowClosed) + applyStoredOrDefaultBounds(window) + + const persist = () => { + if (windowContext.persistBounds) + update({ bounds: window.getBounds() }) + } + window.on('resize', persist) + window.on('move', persist) + + try { + const disposeInvokes = await setupWidgetsWindowInvokes({ + widgetWindow: window, + widgetsManager: widgetsManager!, + i18n: params.i18n, + serverChannel: params.serverChannel, + }) + if (window.isDestroyed()) { + disposeInvokes() + return window + } + windowContext.disposeInvokes = disposeInvokes + + await loadWithRoute(windowContext, window, initialRoute) + return window + } + catch (error) { + if (!window.isDestroyed()) + safeClose(window) + throw error + } } - async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick) { - pendingRoute = route - const window = await getWindowFromContext(context) - pendingRoute = undefined - applyWindowLayout(window, snapshot) + async function getWindowFromContext(windowContext: WidgetWindowContext, initialRoute: string): Promise { + if (windowContext.window && !windowContext.window.isDestroyed()) + return windowContext.window + if (windowContext.windowSetupPromise) + return windowContext.windowSetupPromise + + windowContext.windowSetupPromise = createWindowForContext(windowContext, initialRoute) + .finally(() => { + windowContext.windowSetupPromise = undefined + }) + return windowContext.windowSetupPromise + } + + async function showWindowWithRoute(route: string, windowContext: WidgetWindowContext, snapshot?: Pick) { + const window = await getWindowFromContext(windowContext, route) + applyWindowLayout(windowContext, window, snapshot) setWindowAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false) - if (currentRoute !== route) - await loadWithRoute(window, route) + if (windowContext.currentRoute !== route) + await loadWithRoute(windowContext, window, route) window.show() - if (context) - context.window = window return window } /** - * Resolves the shared widgets window instance for callers that need direct access. + * Resolves the default widgets window 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 + * - The renderer widgets route is available * * Returns: * - The widgets {@link BrowserWindow} */ async function getWindow(): Promise { - return reusable.getWindow() + return getWindowFromContext(defaultWindowContext, defaultRoute) } /** @@ -543,13 +558,13 @@ export function setupWidgetsWindowManager(params: { 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 + const windowContext = id ? windowContexts.get(id)! : defaultWindowContext const snapshot = id ? widgetRecords.get(id) : undefined - await showWindowWithRoute(route, context, snapshot) + await showWindowWithRoute(route, windowContext, snapshot) } /** - * Creates or replaces a widget snapshot and renders it in the widget window. + * Creates a widget instance and renders it in its own window. * * Use when: * - A renderer or tool wants to spawn overlay content @@ -558,10 +573,14 @@ export function setupWidgetsWindowManager(params: { * - `payload.componentName` matches a renderer component known by the widgets page * * Returns: - * - The stable widget id that was rendered + * - The widget instance id that was rendered */ async function pushWidget(payload: WidgetsAddPayload): Promise { - const id = prepareWidgetWindow({ id: payload.id }) + const id = payload.id ?? Math.random().toString(36).slice(2, 10) + if (widgetRecords.has(id)) + destroyWidget(id) + + prepareWidgetWindow({ id }) const snapshot: WidgetSnapshot = { id, componentName: payload.componentName, @@ -571,10 +590,10 @@ export function setupWidgetsWindowManager(params: { windowSize: resolveWindowSizeFromPayload(payload), ttlMs: payload.ttlMs ?? 0, } - upsertRecord(snapshot) - const context = windowContexts.get(id) - await showWindowWithRoute(`${defaultRoute}?id=${id}`, context, snapshot) - eventaContext?.emit(widgetsRenderEvent, snapshot) + createRecord(snapshot) + const windowContext = windowContexts.get(id)! + await showWindowWithRoute(`${defaultRoute}?id=${id}`, windowContext, snapshot) + windowContext.eventa?.context.emit(widgetsRenderEvent, snapshot) return id } @@ -608,16 +627,22 @@ export function setupWidgetsWindowManager(params: { ttlMs: payload.ttlMs ?? existing.ttlMs, } - upsertRecord(nextSnapshot) + const nextRecord: WidgetRecord = { + ...nextSnapshot, + timer: existing.timer, + } + if (payload.ttlMs !== undefined) + scheduleDestruction(nextRecord) + widgetRecords.set(payload.id, nextRecord) - const context = windowContexts.get(payload.id) - const window = context?.window + const windowContext = windowContexts.get(payload.id) + const window = windowContext?.window if (window && !window.isDestroyed()) { - applyWindowLayout(window, nextSnapshot) + applyWindowLayout(windowContext, window, nextSnapshot) setWindowAlwaysOnTop(window, nextSnapshot.alwaysOnTop) } - eventaContext?.emit(widgetsUpdateEvent, { + windowContext?.eventa?.context.emit(widgetsUpdateEvent, { id: nextSnapshot.id, componentProps: nextSnapshot.componentProps, alwaysOnTop: nextSnapshot.alwaysOnTop, @@ -637,13 +662,12 @@ export function setupWidgetsWindowManager(params: { * - `id` references a widget managed by this instance * * Returns: - * - Resolves after the widget has been removed from memory and renderer state + * - Resolves after the widget record and its Electron window are destroyed */ async function removeWidget(id: string) { if (!id) return - removeWidgetInternal(id, false) - eventaContext?.emit(widgetsRemoveEvent, { id }) + destroyWidget(id) } /** @@ -659,25 +683,14 @@ export function setupWidgetsWindowManager(params: { * - Resolves after state, renderer events, and windows have been cleared */ async function clearWidgets() { - const ids = [...widgetRecords.keys()] + const ids = [...windowContexts.keys()] for (const id of ids) - removeWidgetInternal(id, false) + destroyWidget(id) - eventaContext?.emit(widgetsClearEvent, undefined) - - const windowsToClose = new Set() - if (activeWidgetsWindow && !activeWidgetsWindow.isDestroyed()) - windowsToClose.add(activeWidgetsWindow) - - windowContexts.forEach((context) => { - if (context.window && !context.window.isDestroyed()) - windowsToClose.add(context.window) - }) - - for (const window of windowsToClose) - safeClose(window) - - windowContexts.clear() + defaultWindowContext.eventa?.context.emit(widgetsClearEvent, undefined) + const defaultWindow = defaultWindowContext.window + if (defaultWindow && !defaultWindow.isDestroyed()) + safeClose(defaultWindow) } /** @@ -727,8 +740,8 @@ export function setupWidgetsWindowManager(params: { async function hideWindow(params?: { id?: string }) { const id = params?.id - const context = id ? windowContexts.get(id) : undefined - const window = context?.window || activeWidgetsWindow + const windowContext = id ? windowContexts.get(id) : defaultWindowContext + const window = windowContext?.window if (window && !window.isDestroyed()) window.hide() } diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts new file mode 100644 index 000000000..33eb398fd --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/widgets/lifecycle.test.ts @@ -0,0 +1,215 @@ +import type { BrowserWindow, Rectangle } from 'electron' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { setupWidgetsWindowManager } from './index' + +const mocks = vi.hoisted(() => { + let nextWebContentsId = 1 + const windows: FakeBrowserWindow[] = [] + + class FakeBrowserWindow { + readonly webContents = { + id: nextWebContentsId++, + isCrashed: vi.fn(() => false), + isDestroyed: vi.fn(() => false), + send: vi.fn(), + } + + readonly close = vi.fn(() => { + if (this.destroyed) + return + + this.destroyed = true + this.emit('closed') + }) + + readonly getBounds = vi.fn(() => ({ ...this.bounds })) + readonly hide = vi.fn() + readonly isDestroyed = vi.fn(() => this.destroyed) + readonly setAlwaysOnTop = vi.fn() + readonly setBounds = vi.fn((bounds: Rectangle) => { + this.bounds = { ...bounds } + }) + + readonly setFullScreenable = vi.fn() + readonly setMaximumSize = vi.fn() + readonly setMinimumSize = vi.fn() + readonly setVisibleOnAllWorkspaces = vi.fn() + readonly setWindowButtonVisibility = vi.fn() + readonly show = vi.fn() + + private bounds: Rectangle = { x: 0, y: 0, width: 620, height: 760 } + private destroyed = false + private readonly listeners = new Map void>>() + + constructor() { + windows.push(this) + } + + on(event: string, listener: () => void) { + const listeners = this.listeners.get(event) ?? [] + listeners.push(listener) + this.listeners.set(event, listeners) + return this + } + + private emit(event: string) { + for (const listener of this.listeners.get(event) ?? []) + listener() + } + } + + return { + FakeBrowserWindow, + reset() { + windows.length = 0 + }, + windows, + } +}) + +vi.mock('electron', () => ({ + BrowserWindow: mocks.FakeBrowserWindow, + ipcMain: { + off: vi.fn(), + on: vi.fn(), + setMaxListeners: vi.fn(), + }, + screen: { + getDisplayMatching: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), + getPrimaryDisplay: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })), + }, +})) + +vi.mock('std-env', () => ({ isMacOS: false })) + +vi.mock('@moeru/eventa/adapters/electron/main', () => ({ + createContext: vi.fn(() => ({ + context: { + emit: vi.fn(), + on: vi.fn(() => vi.fn()), + }, + dispose: vi.fn(), + })), +})) + +vi.mock('@proj-airi/electron-vueuse/main', () => ({ + safeClose: vi.fn((window: BrowserWindow) => { + window.close() + return true + }), +})) + +vi.mock('../../libs/electron/location', () => ({ + baseUrl: vi.fn(() => 'http://localhost:5173'), + getElectronMainDirname: vi.fn(() => '/tmp/airi-main'), + load: vi.fn(async () => undefined), + withHashRoute: vi.fn((_base: string, route: string) => route), +})) + +vi.mock('../../libs/electron/persistence', () => ({ + createConfig: vi.fn(() => ({ + get: vi.fn(() => ({})), + setup: vi.fn(), + update: vi.fn(), + })), +})) + +vi.mock('../shared/window', () => ({ + protectPrivilegedWindowNavigation: vi.fn(), + setWindowAlwaysOnTop: vi.fn(), + spotlightLikeWindowConfig: vi.fn(() => ({})), + transparentWindowConfig: vi.fn(() => ({})), +})) + +vi.mock('./rpc/index.electron', () => ({ + setupWidgetsWindowInvokes: vi.fn(async () => vi.fn()), +})) + +function createManager() { + return setupWidgetsWindowManager({ + i18n: {} as never, + serverChannel: {} as never, + }) +} + +describe('widget window lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.reset() + }) + + it('destroys the widget window when its TTL expires', async () => { + // ROOT CAUSE: + // + // The TTL removed only the widget record. The reusable BrowserWindow stayed open. + // The renderer then displayed a waiting state for a widget that no longer existed. + // + // We fixed this by making one manager operation destroy the record and its window. + const manager = createManager() + const id = await manager.pushWidget({ + componentName: 'weather', + ttlMs: 1000, + }) + const window = mocks.windows[0] + + await vi.advanceTimersByTimeAsync(1000) + + expect(manager.getWidgetSnapshot(id)).toBeUndefined() + expect(window.close).toHaveBeenCalledOnce() + }) + + it('does not restart the TTL for a content-only update', async () => { + // ROOT CAUSE: + // + // Each update cleared and recreated the timer, even when the update omitted ttlMs. + // Frequent content updates could keep a widget alive after its original expiry time. + // + // We fixed this by changing the timer only when an update includes ttlMs. + const manager = createManager() + const id = await manager.pushWidget({ + componentName: 'weather', + ttlMs: 1000, + }) + + await vi.advanceTimersByTimeAsync(600) + await manager.updateWidget({ id, componentProps: { temperature: '20°C' } }) + await vi.advanceTimersByTimeAsync(400) + + expect(manager.getWidgetSnapshot(id)).toBeUndefined() + expect(mocks.windows[0].close).toHaveBeenCalledOnce() + }) + + it('keeps widget instances and their windows isolated', async () => { + // ROOT CAUSE: + // + // All widget ids resolved to one reusable BrowserWindow. + // An expired record could not destroy its window without also closing another widget. + // + // We fixed this by assigning one window context to each generated widget id. + const manager = createManager() + const firstId = await manager.pushWidget({ componentName: 'weather', ttlMs: 1000 }) + const secondId = await manager.pushWidget({ componentName: 'weather', ttlMs: 2000 }) + + expect(firstId).not.toBe(secondId) + expect(mocks.windows).toHaveLength(2) + + await vi.advanceTimersByTimeAsync(1000) + + expect(mocks.windows[0].close).toHaveBeenCalledOnce() + expect(mocks.windows[1].close).not.toHaveBeenCalled() + expect(manager.getWidgetSnapshot(firstId)).toBeUndefined() + expect(manager.getWidgetSnapshot(secondId)).toBeDefined() + }) + + it('removes the widget record when the user closes its window', async () => { + const manager = createManager() + const id = await manager.pushWidget({ componentName: 'weather' }) + + mocks.windows[0].close() + + expect(manager.getWidgetSnapshot(id)).toBeUndefined() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts index bcb4334b3..e0bab102c 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts @@ -10,6 +10,16 @@ import { ipcMain } from 'electron' import { createWidgetsService } from '../../../services/airi/widgets' import { setupBaseWindowElectronInvokes } from '../../shared/window' +/** + * Registers Eventa invokes for one widget window and returns their cleanup operation. + * + * Call stack: + * + * setupWidgetsWindowInvokes (./index.electron) + * -> {@link setupBaseWindowElectronInvokes} + * -> {@link createWidgetsService} + * -> `dispose` + */ export async function setupWidgetsWindowInvokes(params: { widgetWindow: BrowserWindow widgetsManager: WidgetsWindowManager @@ -21,9 +31,11 @@ export async function setupWidgetsWindowInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.widgetWindow) + const { context, dispose } = createContext(ipcMain, params.widgetWindow, { onlySameWindow: true }) setupBaseWindowElectronInvokes({ context, window: params.widgetWindow, i18n: params.i18n, serverChannel: params.serverChannel }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.widgetWindow }) + + return dispose } diff --git a/apps/stage-tamagotchi/src/renderer/pages/widgets.vue b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue index 1416f5aa7..f5f313ad3 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/widgets.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue @@ -7,7 +7,7 @@ import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, on import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' -import { widgetsClearEvent, widgetsFetch, widgetsIframeRequestEvent, widgetsIframeRequestResultEvent, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa' +import { widgetsClearEvent, widgetsFetch, widgetsIframeRequestEvent, widgetsIframeRequestResultEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa' const { t } = useI18n() @@ -20,7 +20,6 @@ interface WidgetItem { alwaysOnTop: boolean size: SizePreset windowSize?: WidgetWindowSize - ttlMs: number } const route = useRoute() @@ -38,34 +37,13 @@ const widget = ref(null) const loading = ref(false) const context = useElectronEventaContext() -const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove) const fetchWidget = useElectronEventaInvoke(widgetsFetch) const updateWidgetInvoke = useElectronEventaInvoke(widgetsUpdate) const pinUpdating = shallowRef(false) const pendingIframeRequests = shallowRef([]) const eventDisposers: Array<() => void> = [] -let ttlTimer: ReturnType | undefined - -function clearTtl() { - if (ttlTimer) { - clearTimeout(ttlTimer) - ttlTimer = undefined - } -} - -async function requestRemoval(id: string) { - clearTtl() - try { - await removeWidgetInvoke({ id }) - } - catch (error) { - console.warn('Failed to remove widget', error) - } -} - function applySnapshot(snapshot: WidgetSnapshot) { - clearTtl() widget.value = { id: snapshot.id, componentName: snapshot.componentName, @@ -73,11 +51,6 @@ function applySnapshot(snapshot: WidgetSnapshot) { alwaysOnTop: snapshot.alwaysOnTop ?? false, size: snapshot.size ?? 'm', windowSize: snapshot.windowSize, - ttlMs: snapshot.ttlMs ?? 0, - } - - if (snapshot.ttlMs && snapshot.ttlMs > 0) { - ttlTimer = setTimeout(requestRemoval, snapshot.ttlMs, snapshot.id) } } @@ -104,7 +77,6 @@ async function requestSnapshot(id: string) { const { trackWidgetOpened } = useAnalytics() watch(widgetId, (id) => { - clearTtl() widget.value = null pendingIframeRequests.value = [] loading.value = false @@ -149,14 +121,13 @@ onMounted(() => { return } - applySnapshot({ + widget.value = { ...widget.value, componentProps: body.componentProps ?? widget.value.componentProps, alwaysOnTop: body.alwaysOnTop ?? widget.value.alwaysOnTop, size: body.size ?? widget.value.size, - windowSize: body.windowSize ?? widget.value.windowSize, - ttlMs: body.ttlMs ?? widget.value.ttlMs, - }) + windowSize: body.windowSize as WidgetWindowSize | undefined ?? widget.value.windowSize, + } })) } catch {} @@ -166,7 +137,6 @@ onMounted(() => { const body = evt?.body if (!body || body.id !== widgetId.value) return - clearTtl() widget.value = null pendingIframeRequests.value = [] loading.value = false @@ -176,7 +146,6 @@ onMounted(() => { try { eventDisposers.push(context.value.on(widgetsClearEvent, () => { - clearTtl() widget.value = null pendingIframeRequests.value = [] loading.value = false @@ -189,7 +158,6 @@ onBeforeUnmount(() => { for (const dispose of eventDisposers.splice(0)) { dispose() } - clearTtl() }) const Registry: Record> = { @@ -230,7 +198,6 @@ function resolveWidgetComponent(name: string) { } function handleClose() { - clearTtl() window.close() } diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts index e12d355e2..f2dc77fc8 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/weather.ts @@ -20,7 +20,6 @@ async function executeGetWeather(input: { city: string }): Promise { await executeWidgetAction({ action: 'spawn', - id: `weather-${weather.city.toLowerCase().replace(/\s+/g, '-')}`, componentName: 'weather', componentProps: weather, size: 'm', diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts index 46170ce93..8a3d8a238 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.test.ts @@ -520,7 +520,7 @@ describe('widgets tool helpers', () => { ttlSeconds: 0, }, { invokers }) - expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 }, alwaysOnTop: false }) + expect(invokers.updateWidget).toHaveBeenCalledWith({ id: 'xyz', componentProps: { foo: 1 }, alwaysOnTop: false, ttlMs: 0 }) }) it('removes when id provided', async () => { diff --git a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts index 6b66f7d82..e3288b816 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/tools/builtin/widgets.ts @@ -18,7 +18,7 @@ type SizePreset = 's' | 'm' | 'l' type WidgetActionInput = | { action: 'spawn' - id: string + id?: string componentName: string componentProps: string | Record alwaysOnTop?: boolean @@ -114,7 +114,7 @@ const widgetParams = z.object({ alwaysOnTop: z.boolean().describe('Whether the widget window should stay above other windows. Defaults to false when omitted by internal callers.'), size: z.enum(['s', 'm', 'l']), windowSize: z.union([widgetWindowSizeParams, z.null()]).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)'), + ttlSeconds: z.number().int().nonnegative().describe('Automatic destruction delay in seconds. Use 0 to keep the widget until manual close.'), }).strict() type WidgetToolInput = z.infer @@ -277,11 +277,13 @@ export async function executeWidgetAction(input: WidgetActionInput, deps?: { inv const componentProps = normalizeComponentProps(input.componentProps) const sanitizedComponentProps = sanitizeComponentPropsForDispatch(input.componentName, componentProps) const windowSize = resolveWindowSize(input.componentName, sanitizedComponentProps, input.windowSize) + const ttlMs = input.ttlSeconds === undefined ? undefined : Math.floor(input.ttlSeconds * 1000) await invokers.updateWidget({ id: normalizedId, componentProps: sanitizedComponentProps, ...(input.alwaysOnTop === undefined ? {} : { alwaysOnTop: input.alwaysOnTop }), ...(windowSize === undefined ? {} : { windowSize }), + ...(ttlMs === undefined ? {} : { ttlMs }), }) return `Updated widget (${normalizedId}).` diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 036229a3b..8279f1dcb 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -128,7 +128,7 @@ export interface WidgetsAddPayload { // size presets or explicit spans; renderer decides mapping size?: WidgetGridSize windowSize?: WidgetWindowSize | Record - // auto-dismiss in ms; if omitted, persistent until closed by user + /** Automatic destruction delay in milliseconds. If omitted or zero, the widget stays until an explicit close. */ ttlMs?: number } @@ -138,6 +138,7 @@ export interface WidgetsUpdatePayload { alwaysOnTop?: boolean size?: WidgetGridSize windowSize?: WidgetWindowSize | Record + /** Replaces the automatic destruction delay. If omitted, the current expiry time does not change. */ ttlMs?: number }