diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 84c95d6ff..4c976b62f 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -279,6 +279,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { id: payload.id ?? Math.random().toString(36).slice(2, 10), componentName: payload.componentName, componentProps: payload.componentProps ?? {}, + alwaysOnTop: payload.alwaysOnTop ?? false, size: payload.size ?? 'm', windowSize: payload.windowSize, ttlMs: payload.ttlMs ?? 0, @@ -296,6 +297,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { widgetSnapshots.set(payload.id, { ...existing, componentProps: payload.componentProps ?? existing.componentProps, + alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, size: payload.size ?? existing.size, windowSize: payload.windowSize ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts index d62b5172d..e2a967747 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts @@ -14,6 +14,7 @@ describe('widget invoke validation', () => { id: ' widget-1 ', componentName: ' weather ', componentProps: { city: 'Tokyo' }, + alwaysOnTop: true, ttlMs: 2500.9, windowSize: { width: 620.8, @@ -24,6 +25,7 @@ describe('widget invoke validation', () => { id: 'widget-1', componentName: 'weather', componentProps: { city: 'Tokyo' }, + alwaysOnTop: true, ttlMs: 2500, windowSize: { width: 620, @@ -52,6 +54,11 @@ describe('widget invoke validation', () => { componentName: 'weather', windowSize: { width: 0, height: 320 }, } as any)).toThrow('windowSize must contain a positive finite width and height.') + + expect(() => validateWidgetsAddPayload({ + componentName: 'weather', + alwaysOnTop: 'yes' as any, + })).toThrow('alwaysOnTop must be a boolean when provided.') }) }) @@ -60,10 +67,12 @@ describe('widget invoke validation', () => { expect(validateWidgetsUpdatePayload({ id: ' widget-1 ', componentProps: { city: 'Taipei' }, + alwaysOnTop: false, ttlMs: 1500.4, })).toEqual({ id: 'widget-1', componentProps: { city: 'Taipei' }, + alwaysOnTop: false, ttlMs: 1500, windowSize: undefined, }) @@ -83,6 +92,11 @@ describe('widget invoke validation', () => { id: 'widget-1', windowSize: { width: Number.NaN, height: 400 }, } as any)).toThrow('windowSize must contain a positive finite width and height.') + + expect(() => validateWidgetsUpdatePayload({ + id: 'widget-1', + alwaysOnTop: 'yes' as any, + })).toThrow('alwaysOnTop must be a boolean when provided.') }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts index 32252cd01..9e644c72a 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts @@ -35,6 +35,16 @@ function normalizeComponentProps(componentProps?: Record): Reco return componentProps } +function normalizeOptionalBoolean(value: boolean | undefined, fieldName: string): boolean | undefined { + if (value === undefined) + return undefined + + if (typeof value !== 'boolean') + throw new Error(`${fieldName} must be a boolean when provided.`) + + return value +} + /** * Validates and normalizes widget spawn payloads at the Electron invoke boundary. * @@ -44,6 +54,7 @@ function normalizeComponentProps(componentProps?: Record): Reco * Expects: * - `componentName` is a non-empty string * - `componentProps`, when provided, is a plain object + * - `alwaysOnTop`, when provided, is a boolean * - `ttlMs`, when provided, is a non-negative finite number * * Returns: @@ -69,6 +80,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA id: normalizeWidgetId(payload.id), componentName, componentProps: normalizeComponentProps(payload.componentProps), + alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), ttlMs: normalizeTtlMs(payload.ttlMs), windowSize: normalizedWindowSize, } @@ -83,6 +95,7 @@ export function validateWidgetsAddPayload(payload?: WidgetsAddPayload): WidgetsA * Expects: * - `id` is a non-empty string after trimming * - `componentProps`, when provided, is a plain object + * - `alwaysOnTop`, when provided, is a boolean * * Returns: * - A normalized payload safe to pass into the widgets manager @@ -108,6 +121,7 @@ export function validateWidgetsUpdatePayload(payload?: WidgetsUpdatePayload): Wi componentProps: payload.componentProps === undefined ? undefined : normalizeComponentProps(payload.componentProps), + alwaysOnTop: normalizeOptionalBoolean(payload.alwaysOnTop, 'alwaysOnTop'), ttlMs: payload.ttlMs === undefined ? undefined : normalizeTtlMs(payload.ttlMs), diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 624afb32c..a7afbc895 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -204,8 +204,6 @@ function createWidgetsWindow() { ...spotlightLikeWindowConfig(), }) - // Keep on top like caption/main overlays - window.setAlwaysOnTop(true, 'screen-saver', 1) window.setFullScreenable(false) window.setVisibleOnAllWorkspaces(true) if (isMacOS) @@ -220,6 +218,15 @@ function createWidgetsWindow() { return window } +function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) { + if (enabled) { + window.setAlwaysOnTop(true, 'screen-saver', 1) + return + } + + window.setAlwaysOnTop(false) +} + interface WidgetRecord extends WidgetSnapshot { timer?: ReturnType } @@ -462,11 +469,12 @@ export function setupWidgetsWindowManager(params: { return resolved } - async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick) { + async function showWindowWithRoute(route: string, context?: WidgetWindowContext, snapshot?: Pick) { pendingRoute = route const window = await getWindowFromContext(context) pendingRoute = undefined applyWindowLayout(window, snapshot) + applyAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false) if (currentRoute !== route) await loadWithRoute(window, route) window.show() @@ -529,6 +537,7 @@ export function setupWidgetsWindowManager(params: { id, componentName: payload.componentName, componentProps: payload.componentProps ?? {}, + alwaysOnTop: payload.alwaysOnTop ?? false, size: payload.size ?? 'm', windowSize: resolveWindowSizeFromPayload(payload), ttlMs: payload.ttlMs ?? 0, @@ -564,6 +573,7 @@ export function setupWidgetsWindowManager(params: { const nextSnapshot: WidgetSnapshot = { ...toSnapshot(existing), componentProps: payload.componentProps ?? existing.componentProps, + alwaysOnTop: payload.alwaysOnTop ?? existing.alwaysOnTop, size: payload.size ?? existing.size, windowSize: normalizeWidgetWindowSize(payload.windowSize) ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, @@ -573,12 +583,15 @@ export function setupWidgetsWindowManager(params: { const context = windowContexts.get(payload.id) const window = context?.window - if (window && !window.isDestroyed()) + if (window && !window.isDestroyed()) { applyWindowLayout(window, nextSnapshot) + applyAlwaysOnTop(window, nextSnapshot.alwaysOnTop) + } eventaContext?.emit(widgetsUpdateEvent, { id: nextSnapshot.id, componentProps: nextSnapshot.componentProps, + alwaysOnTop: nextSnapshot.alwaysOnTop, size: nextSnapshot.size, windowSize: nextSnapshot.windowSize, ttlMs: nextSnapshot.ttlMs, diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue index 13e045d7d..bdf556ed5 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue @@ -1,6 +1,6 @@