fix(stage-tamagotchi): enforce widget window lifecycle (#2409)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user