diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 54009de2..f8173299 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -44,6 +44,7 @@ const behaviorTests = [ ".test-dist/tests/default-familiar-external-show.test.js", ".test-dist/tests/default-familiar-controller-bubbles.test.js", ".test-dist/tests/default-familiar-controller-motion.test.js", + ".test-dist/tests/default-familiar-controller-display-support.test.js", ".test-dist/tests/default-familiar-controller-window.test.js", ".test-dist/tests/app-state-preferences.test.js", ".test-dist/tests/app-state-analytics.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index e1487255..920ab585 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -222,6 +222,7 @@ const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"), const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8"); const defaultPetControllerBubbleSource = readFileSync(join(appDir, "src", "default-familiar-controller-bubbles.ts"), "utf8"); const defaultPetControllerDisplaySource = readFileSync(join(appDir, "src", "default-familiar-controller-display.ts"), "utf8"); +const defaultPetControllerDisplaySupportSource = readFileSync(join(appDir, "src", "default-familiar-controller-display-support.ts"), "utf8"); const defaultPetControllerMotionSource = readFileSync(join(appDir, "src", "default-familiar-controller-motion.ts"), "utf8"); const defaultPetControllerWindowSource = readFileSync(join(appDir, "src", "default-familiar-controller-window.ts"), "utf8"); const agentPetControllerSourceForLogging = readFileSync(join(appDir, "src", "agent-familiar-controller.ts"), "utf8"); @@ -384,6 +385,7 @@ assert.match(petPreloadSource, /familiaros:familiar-ready/, "familiar preload mu assert.match(petPreloadSource, /familiaros:familiar-drag-start/, "familiar preload must start controlled familiar dragging from the sprite."); assert.match(petPreloadSource, /familiaros:familiar-open-prompt/, "familiar preload must request the floating prompt window on double-click."); assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-display(?:\.js)?"/, "default familiar controller must compose the extracted display seam."); +assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-display-support(?:\.js)?"/, "default familiar controller must compose the extracted display-support seam."); assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-motion(?:\.js)?"/, "default familiar controller must compose the extracted motion seam."); assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-bubbles(?:\.js)?"/, "default familiar controller must compose the extracted bubble seam."); assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-window(?:\.js)?"/, "default familiar controller must compose the extracted window seam."); @@ -393,6 +395,7 @@ assert.match(defaultPetControllerBubbleSource, /refreshDefaultPetContent\(\)/, " assert.match(defaultPetControllerDisplaySource, /export function setDefaultPetTransientDisplay/, "default familiar display seam must export transient display handling."); assert.match(defaultPetControllerDisplaySource, /export function setDefaultPetStatusBadge/, "default familiar display seam must export status badge handling."); assert.match(defaultPetControllerDisplaySource, /export function getDefaultPetDismissToken/, "default familiar display seam must export dismiss token shaping."); +assert.match(defaultPetControllerDisplaySupportSource, /export function createDefaultPetDisplaySupport/, "default familiar display-support seam must export the controller helper factory."); assert.match(defaultPetControllerMotionSource, /export async function moveDefaultPetWindowBy/, "default familiar motion seam must export bounded plugin window movement."); assert.match(defaultPetControllerMotionSource, /export function getPetMovementBlockedReason/, "default familiar motion seam must export movement block-state resolution."); assert.match(defaultPetControllerWindowSource, /export function loadDefaultPetWindowContent/, "default familiar window seam must export content reload handling."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 63d28309..238b3ac1 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -253,6 +253,7 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `default-familiar-controller.ts`: Default familiar visibility, position persistence, transient reactions, status badges, logging, and display/motion/bubble/window-shell orchestration - `default-familiar-controller-bubbles.ts`: Extracted default familiar plugin-bubble slot state, arbiter routing, and familiar-surface refresh/show callbacks - `default-familiar-controller-display.ts`: Extracted transient display timers, status badge lifetimes, and dismiss-token shaping +- `default-familiar-controller-display-support.ts`: Extracted controller-local display helper factory for dismiss handling, badge wiring, and scale persistence - `default-familiar-controller-motion.ts`: Extracted default familiar movement blocking and animated reposition helpers - `default-familiar-controller-window.ts`: Extracted default familiar content reload, hide/destroy lifecycle, display handler wiring, bounds recovery, and resume/topology mouse-interop helpers - `agent-familiar-controller.ts`: Lease-triggered familiar windows, dismissal tracking, transient displays, status badges, logging diff --git a/apps/desktop/src/default-familiar-controller-display-support.ts b/apps/desktop/src/default-familiar-controller-display-support.ts new file mode 100644 index 00000000..15d390f9 --- /dev/null +++ b/apps/desktop/src/default-familiar-controller-display-support.ts @@ -0,0 +1,98 @@ +import type { BrowserWindow } from "electron"; + +import { + clearDefaultPetDisplayState, + clearDefaultPetStatusBadge, + getDefaultPetDismissToken, + setDefaultPetStatusBadge, + setDefaultPetTransientDisplay, + type DefaultPetDisplayState, +} from "./default-familiar-controller-display.js"; +import type { FamiliarOSReaction } from "./local-ipc-protocol.js"; +import type { PetPluginBubbles, PetStatusBadgeReaction, PetTransientDisplay } from "./familiar-window.js"; + +interface DefaultPetBubbleDismissHandler { + handleDismissed: (dismissToken: string) => boolean; +} + +interface DefaultPetDisplaySupportOptions { + readonly displayState: DefaultPetDisplayState; + readonly bubbleArbiter: DefaultPetBubbleDismissHandler; + readonly debug: (message: string, fields?: Record) => void; + readonly refreshContent: () => void; + readonly getWindow: () => BrowserWindow | null; + readonly getPaused: () => boolean; + readonly getPluginBubbles: () => PetPluginBubbles | null; + readonly loadContent: ( + window: BrowserWindow, + paused: boolean, + display: PetTransientDisplay | null, + badge: PetStatusBadgeReaction | null, + dismissToken?: string, + pluginBubbles?: PetPluginBubbles | null, + ) => Promise; + readonly updateScalePreference: (scale: number) => void | Promise; +} + +export function createDefaultPetDisplaySupport(options: DefaultPetDisplaySupportOptions) { + function getDefaultPetDisplayDeps() { + return { + debug: options.debug, + refreshContent: options.refreshContent, + getWindowForReaction: () => options.getWindow(), + }; + } + + function setTransientDisplay(display: PetTransientDisplay): void { + setDefaultPetTransientDisplay(options.displayState, display, getDefaultPetDisplayDeps()); + } + + function setStatusBadge(reaction: FamiliarOSReaction): void { + setDefaultPetStatusBadge(options.displayState, reaction, getDefaultPetDisplayDeps()); + } + + function clearStatusBadge(): void { + clearDefaultPetStatusBadge(options.displayState, getDefaultPetDisplayDeps()); + } + + function clearDefaultPetDisplayTimers(): void { + clearDefaultPetDisplayState(options.displayState); + } + + function getCurrentDismissToken(): string | undefined { + return getDefaultPetDismissToken(options.displayState); + } + + function handleBubbleDismissed(dismissToken: string): void { + options.debug("bubble dismissed callback", { windowId: options.getWindow()?.id, dismissToken, currentGeneration: options.displayState.displayGeneration }); + if (options.bubbleArbiter.handleDismissed(dismissToken)) { + return; + } + if (dismissToken !== String(options.displayState.displayGeneration)) { + options.debug("bubble dismissed stale token", { dismissToken, currentGeneration: options.displayState.displayGeneration }); + return; + } + clearDefaultPetDisplayTimers(); + const window = options.getWindow(); + if (window) { + void options.loadContent(window, options.getPaused(), null, null, undefined, options.getPluginBubbles()); + } + } + + function handleScaleChanged(scale: number): void { + options.debug("scale changed", { scale }); + void Promise.resolve(options.updateScalePreference(scale)).catch((error: unknown) => { + console.error("Failed to update familiar scale preference.", error); + }); + } + + return { + clearDefaultPetDisplayTimers, + clearStatusBadge, + getCurrentDismissToken, + handleBubbleDismissed, + handleScaleChanged, + setStatusBadge, + setTransientDisplay, + }; +} diff --git a/apps/desktop/src/default-familiar-controller.ts b/apps/desktop/src/default-familiar-controller.ts index 40ff1c14..5d79a4f0 100644 --- a/apps/desktop/src/default-familiar-controller.ts +++ b/apps/desktop/src/default-familiar-controller.ts @@ -3,13 +3,9 @@ import { BrowserWindow } from "electron"; import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js"; import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js"; import { - clearDefaultPetDisplayState, - clearDefaultPetStatusBadge, createDefaultPetDisplayState, - getDefaultPetDismissToken, - setDefaultPetStatusBadge, - setDefaultPetTransientDisplay, } from "./default-familiar-controller-display.js"; +import { createDefaultPetDisplaySupport } from "./default-familiar-controller-display-support.js"; import { getPetMovementBlockedReason, moveDefaultPetWindowBy } from "./default-familiar-controller-motion.js"; import { createDefaultPetBubbleRuntime } from "./default-familiar-controller-bubbles.js"; import { @@ -52,6 +48,28 @@ const defaultPetBubbleRuntime = createDefaultPetBubbleRuntime({ showDefaultPetForExternalEvent: () => showDefaultPetForExternalEvent(), refreshDefaultPetContent: () => refreshDefaultPetContent(), }); +const defaultPetDisplaySupport = createDefaultPetDisplaySupport({ + displayState, + bubbleArbiter: defaultPetBubbleRuntime, + debug: (message, fields) => debug("familiar.default", message, fields), + refreshContent: () => refreshDefaultPetContent(), + getWindow: () => (defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null), + getPaused: () => paused, + getPluginBubbles: () => getDefaultPetPluginBubbles(), + loadContent: (window, pausedState, display, badge, dismissToken, pluginBubbles) => loadDefaultPetContent(window, pausedState, display, badge, dismissToken, pluginBubbles), + updateScalePreference: (scale) => import("./app-state.js").then(({ updatePreferences }) => { + updatePreferences({ petScale: scale }); + }), +}); +const { + clearDefaultPetDisplayTimers, + clearStatusBadge, + getCurrentDismissToken, + handleBubbleDismissed, + handleScaleChanged, + setStatusBadge, + setTransientDisplay, +} = defaultPetDisplaySupport; /** The default familiar's bubble arbiter — the Electron bubbles capability targets this. */ export const defaultPetBubbleArbiter = defaultPetBubbleRuntime.arbiter; @@ -218,30 +236,6 @@ export function getDefaultPetWindowBounds(): Electron.Rectangle | null { return getManagedDefaultPetWindowBounds(defaultPetWindow); } -function handleBubbleDismissed(dismissToken: string): void { - debug("familiar.default", "bubble dismissed callback", { windowId: defaultPetWindow?.id, dismissToken, currentGeneration: displayState.displayGeneration }); - if (defaultPetBubbleRuntime.handleDismissed(dismissToken)) { - return; - } - if (dismissToken !== String(displayState.displayGeneration)) { - debug("familiar.default", "bubble dismissed stale token", { dismissToken, currentGeneration: displayState.displayGeneration }); - return; - } - clearDefaultPetDisplayTimers(); - if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { - void loadDefaultPetContent(defaultPetWindow, paused, null, null, undefined, getDefaultPetPluginBubbles()); - } -} - -function handleScaleChanged(scale: number): void { - debug("familiar.default", "scale changed", { scale }); - import("./app-state.js").then(({ updatePreferences }) => { - updatePreferences({ petScale: scale }); - }).catch((error) => { - console.error("Failed to update familiar scale preference.", error); - }); -} - function getOrCreateDefaultPetWindow(): BrowserWindow { if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { return defaultPetWindow; @@ -275,10 +269,6 @@ function getOrCreateDefaultPetWindow(): BrowserWindow { return defaultPetWindow; } -function setTransientDisplay(display: PetTransientDisplay): void { - setDefaultPetTransientDisplay(displayState, display, getDefaultPetDisplayDeps()); -} - function showDefaultPetForExternalEvent(): void { const state = getAppStateSnapshot(); const visible = isDefaultPetVisible(); @@ -327,22 +317,6 @@ function getMovementBlockedReason(window: BrowserWindow, allowMoving = false): s }); } -function setStatusBadge(reaction: FamiliarOSReaction): void { - setDefaultPetStatusBadge(displayState, reaction, getDefaultPetDisplayDeps()); -} - -function clearStatusBadge(): void { - clearDefaultPetStatusBadge(displayState, getDefaultPetDisplayDeps()); -} - -function clearDefaultPetDisplayTimers(): void { - clearDefaultPetDisplayState(displayState); -} - -function getCurrentDismissToken(): string | undefined { - return getDefaultPetDismissToken(displayState); -} - function reclampDefaultPetWindow(): void { reclampManagedDefaultPetWindow(defaultPetWindow, recoverDefaultPetMouseInterop); } @@ -358,11 +332,3 @@ export function shouldOpenDefaultPetOnLaunch(): boolean { export function resetDefaultPetToInitialPosition(): void { resetManagedDefaultPetWindowToInitialPosition(defaultPetWindow); } - -function getDefaultPetDisplayDeps() { - return { - debug: (message: string, fields?: Record) => debug("familiar.default", message, fields), - refreshContent: refreshDefaultPetContent, - getWindowForReaction: () => (defaultPetWindow && !defaultPetWindow.isDestroyed() ? defaultPetWindow : null), - }; -} diff --git a/apps/desktop/tests/default-familiar-controller-display-support.test.ts b/apps/desktop/tests/default-familiar-controller-display-support.test.ts new file mode 100644 index 00000000..98d6a2cc --- /dev/null +++ b/apps/desktop/tests/default-familiar-controller-display-support.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const controllerSource = readFileSync(resolve(desktopRoot, "src/default-familiar-controller.ts"), "utf8"); +const controllerDisplaySupportSource = readFileSync(resolve(desktopRoot, "src/default-familiar-controller-display-support.ts"), "utf8"); + +assert.match(controllerSource, /from "\.\/default-familiar-controller-display-support(?:\.js)?"/, "default familiar controller must compose the extracted display-support seam."); +assert.match(controllerDisplaySupportSource, /export function createDefaultPetDisplaySupport/, "default familiar display-support seam must export the display helper factory."); +assert.match(controllerDisplaySupportSource, /setDefaultPetTransientDisplay\(options\.displayState, display, getDefaultPetDisplayDeps\(\)\)/, "default familiar display-support seam must drive transient display updates through the shared display seam."); +assert.match(controllerDisplaySupportSource, /setDefaultPetStatusBadge\(options\.displayState, reaction, getDefaultPetDisplayDeps\(\)\)/, "default familiar display-support seam must drive status badges through the shared display seam."); +assert.match(controllerDisplaySupportSource, /if \(options\.bubbleArbiter\.handleDismissed\(dismissToken\)\) \{\s*return;/, "default familiar display-support seam must short-circuit plugin-managed bubble dismissals."); +assert.match(controllerDisplaySupportSource, /void options\.loadContent\(window, options\.getPaused\(\), null, null, undefined, options\.getPluginBubbles\(\)\);/, "default familiar display-support seam must reload cleared familiar content after dismissing a live transient bubble."); +assert.match(controllerDisplaySupportSource, /Promise\.resolve\(options\.updateScalePreference\(scale\)\)/, "default familiar display-support seam must persist scale changes through the injected preference updater."); + +console.error("Default familiar display-support seam validation passed.");