fix(stage-tamagotchi): enforce widget window lifecycle (#2409)

This commit is contained in:
Neko
2026-08-30 14:11:14 +08:00
committed by GitHub
parent d8e62f12f0
commit 99ce4bdcd0
9 changed files with 419 additions and 210 deletions
@@ -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<TResponse> {
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()
@@ -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<BrowserWindow>
/**
@@ -74,11 +72,11 @@ export interface WidgetsWindowManager {
*/
openWindow: (params?: { id?: string }) => Promise<void>
/**
* 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<void>
/**
@@ -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<BrowserWindow>
currentRoute?: string
disposeInvokes?: () => void
eventa?: ReturnType<typeof createContext>
persistBounds: boolean
window?: BrowserWindow
windowSetupPromise?: Promise<BrowserWindow>
}
/**
@@ -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<typeof createContext>['context'] | undefined
const widgetRecords = new Map<string, WidgetRecord>()
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
const windowContexts = new Map<string, WidgetWindowContext>()
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<WidgetSnapshot, 'windowSize'>) {
function applyWindowLayout(windowContext: WidgetWindowContext, 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
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<BrowserWindow> {
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<BrowserWindow> {
// 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<WidgetSnapshot, 'alwaysOnTop' | 'windowSize'>) {
pendingRoute = route
const window = await getWindowFromContext(context)
pendingRoute = undefined
applyWindowLayout(window, snapshot)
async function getWindowFromContext(windowContext: WidgetWindowContext, initialRoute: string): Promise<BrowserWindow> {
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<WidgetSnapshot, 'alwaysOnTop' | 'windowSize'>) {
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<BrowserWindow> {
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<string> {
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<BrowserWindow>()
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()
}
@@ -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<string, Array<() => 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()
})
})
@@ -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
}
@@ -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<WidgetItem | null>(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<WidgetsIframeRequestPayload[]>([])
const eventDisposers: Array<() => void> = []
let ttlTimer: ReturnType<typeof setTimeout> | 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<string, ReturnType<typeof defineAsyncComponent>> = {
@@ -230,7 +198,6 @@ function resolveWidgetComponent(name: string) {
}
function handleClose() {
clearTtl()
window.close()
}
@@ -20,7 +20,6 @@ async function executeGetWeather(input: { city: string }): Promise<string> {
await executeWidgetAction({
action: 'spawn',
id: `weather-${weather.city.toLowerCase().replace(/\s+/g, '-')}`,
componentName: 'weather',
componentProps: weather,
size: 'm',
@@ -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 () => {
@@ -18,7 +18,7 @@ type SizePreset = 's' | 'm' | 'l'
type WidgetActionInput
= | {
action: 'spawn'
id: string
id?: string
componentName: string
componentProps: string | Record<string, any>
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<typeof widgetParams>
@@ -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}).`
@@ -128,7 +128,7 @@ export interface WidgetsAddPayload {
// size presets or explicit spans; renderer decides mapping
size?: WidgetGridSize
windowSize?: WidgetWindowSize | Record<string, unknown>
// 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<string, unknown>
/** Replaces the automatic destruction delay. If omitted, the current expiry time does not change. */
ttlMs?: number
}