diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index b2230315..7b58505a 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -41,6 +41,7 @@ const behaviorTests = [ ".test-dist/tests/settings-tts-group-split.test.js", ".test-dist/tests/default-familiar-external-show.test.js", ".test-dist/tests/default-familiar-controller-motion.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", ".test-dist/tests/app-state-familiar-records.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 190b6cdb..baad729c 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -213,6 +213,7 @@ const leaseManagerSource = readFileSync(join(appDir, "src", "lease-manager.ts"), const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-familiar-controller.ts"), "utf8"); const defaultPetControllerDisplaySource = readFileSync(join(appDir, "src", "default-familiar-controller-display.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"); const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8"); const controlCenterInternalUiSource = [ @@ -363,15 +364,18 @@ assert.match(petPreloadSource, /familiaros:familiar-hit-test/, "familiar preload assert.match(petPreloadSource, /familiaros:familiar-ready/, "familiar preload must report readiness after installing mouse handlers."); 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, /powerMonitor\.on\("resume", recoverDefaultPetWindowAfterResume\)/, "default familiar must recover mouse interop after Windows sleep or resume."); -assert.match(defaultPetControllerSource, /recoverDefaultPetMouseInterop\("display-change"\)/, "default familiar must recover mouse interop after monitor topology changes."); 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-motion(?:\.js)?"/, "default familiar controller must compose the extracted motion seam."); +assert.match(defaultPetControllerSource, /from "\.\/default-familiar-controller-window(?:\.js)?"/, "default familiar controller must compose the extracted window seam."); 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(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."); +assert.match(defaultPetControllerWindowSource, /export function installDefaultPetWindowDisplayHandlers/, "default familiar window seam must export display and resume handler installation."); +assert.match(defaultPetControllerWindowSource, /powerMonitor\.on\("resume", recoverAfterResume\)/, "default familiar must recover mouse interop after Windows sleep or resume."); +assert.match(defaultPetControllerWindowSource, /recoverMouseInterop\("display-change"\)/, "default familiar must recover mouse interop after monitor topology changes."); assert.match(controlCenterInternalUiSource, /recoverDefaultPetMouseInterop\("default-familiar-changed"\)/, "changing default familiar must recover mouse interop for dragging without app restart."); assert.match(petWindowPluginMenuSource, /function installPetContextMenu/, "familiar windows must install a native right-click context menu."); assert.match(petWindowPluginMenuSource, /webContents\.on\("context-menu"/, "familiar context menu must be handled in the Electron main process."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index c6d96aac..6595c807 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -246,9 +246,10 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `familiar-window-renderer-bridge.ts`: Extracted renderer IPC helpers for bubble layout updates, reaction state, sprite overrides, audio playback, and TTS - `familiar-window-render.ts`: Shared familiar-window CSS/sprite render shell plus stage markup and bubble seam re-exports - `familiar-window-render-bubbles.ts`: Extracted familiar-window bubble/plugin HUD markup, status badge, and HTML escaping helpers -- `default-familiar-controller.ts`: Default familiar visibility, position persistence, transient reactions, status badges, logging, and motion-shell orchestration +- `default-familiar-controller.ts`: Default familiar visibility, position persistence, transient reactions, status badges, logging, and display/motion/window-shell orchestration - `default-familiar-controller-display.ts`: Extracted transient display timers, status badge lifetimes, and dismiss-token shaping - `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 - `plugins-view-presentation.tsx`: Extracted Control Center plugins route iconography, label maps, permission tones, and primary/status badge helpers - `plugins-view-state.ts`: Extracted Control Center plugins route state, selection/filter derivation, and install/update/load actions diff --git a/apps/desktop/src/default-familiar-controller-window.ts b/apps/desktop/src/default-familiar-controller-window.ts new file mode 100644 index 00000000..0b83b827 --- /dev/null +++ b/apps/desktop/src/default-familiar-controller-window.ts @@ -0,0 +1,154 @@ +import { powerMonitor, screen, type BrowserWindow } from "electron"; + +import { resetDefaultPetPosition, setDefaultPetPosition } from "./app-state.js"; +import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js"; +import { + getSafeDefaultPetPosition, + loadDefaultPetContent, + readWindowPosition, + recoverPetMouseInterop, + type PetPluginBubbles, + type PetStatusBadgeReaction, + type PetTransientDisplay, +} from "./familiar-window.js"; +import { debug, info } from "./logger.js"; + +export interface DefaultPetWindowContentSnapshot { + readonly paused: boolean; + readonly display: PetTransientDisplay | null; + readonly badge: PetStatusBadgeReaction | null; + readonly dismissToken?: string; + readonly pluginBubbles: PetPluginBubbles | null; +} + +export function loadDefaultPetWindowContent( + window: BrowserWindow, + snapshot: DefaultPetWindowContentSnapshot, +): void { + void loadDefaultPetContent( + window, + snapshot.paused, + snapshot.display, + snapshot.badge, + snapshot.dismissToken, + snapshot.pluginBubbles, + ); +} + +export function hideManagedDefaultPetWindow( + window: BrowserWindow | null, + petId: string | undefined, +): void { + if (!window || window.isDestroyed()) { + debug("familiar.default", "hide skipped", { reason: "no-window" }); + return; + } + + info("familiar.default", "hide requested", { + windowId: window.id, + position: readWindowPosition(window), + petId, + }); + setDefaultPetPosition(readWindowPosition(window)); + window.hide(); +} + +export function recoverManagedDefaultPetMouseInterop( + window: BrowserWindow | null, + reason: string, + petId: string | undefined, +): void { + if (!window || window.isDestroyed()) { + debug("familiar.default", "mouse interop recovery skipped", { + reason, + skippedReason: "no-window", + }); + return; + } + + debug("familiar.default", "mouse interop recovery requested", { + windowId: window.id, + reason, + petId, + }); + recoverPetMouseInterop(window, reason); +} + +export function destroyManagedDefaultPetWindow( + window: BrowserWindow | null, + petId: string | undefined, + clearDisplayTimers: () => void, +): BrowserWindow | null { + clearDisplayTimers(); + + if (!window || window.isDestroyed()) { + debug("familiar.default", "destroy skipped", { reason: "no-window" }); + return null; + } + + info("familiar.default", "destroy requested", { + windowId: window.id, + position: readWindowPosition(window), + petId, + }); + setDefaultPetPosition(readWindowPosition(window)); + window.setIgnoreMouseEvents(false); + window.destroy(); + return null; +} + +export function installDefaultPetWindowDisplayHandlers( + reclampWindow: () => void, + recoverAfterResume: () => void, +): void { + screen.on("display-added", reclampWindow); + screen.on("display-removed", reclampWindow); + screen.on("display-metrics-changed", reclampWindow); + powerMonitor.on("resume", recoverAfterResume); +} + +export function getManagedDefaultPetWindowBounds( + window: BrowserWindow | null, +): Electron.Rectangle | null { + if (!window || window.isDestroyed()) return null; + return window.getBounds(); +} + +export function reclampManagedDefaultPetWindow( + window: BrowserWindow | null, + recoverMouseInterop: (reason: string) => void, +): void { + if (!window || window.isDestroyed()) { + return; + } + + const safePosition = readWindowPosition(window); + info("familiar.default", "reclamp position", { + windowId: window.id, + position: safePosition, + }); + window.setPosition(safePosition.x, safePosition.y, false); + setDefaultPetPosition(safePosition); + recoverMouseInterop("display-change"); +} + +export function recoverManagedDefaultPetWindowAfterResume( + recoverMouseInterop: (reason: string) => void, + scheduleTimeout: typeof setTimeout = setTimeout, +): void { + recoverMouseInterop("power-resume"); + scheduleTimeout(() => recoverMouseInterop("power-resume+500ms"), 500).unref?.(); +} + +export function resetManagedDefaultPetWindowToInitialPosition( + window: BrowserWindow | null, +): void { + const safePosition = getSafeDefaultPetPosition( + getDefaultPetInitialPosition(defaultPetWindowSize), + ); + resetDefaultPetPosition(safePosition); + + if (window && !window.isDestroyed()) { + window.setPosition(safePosition.x, safePosition.y, false); + } +} diff --git a/apps/desktop/src/default-familiar-controller.ts b/apps/desktop/src/default-familiar-controller.ts index b6c6caf1..7143922d 100644 --- a/apps/desktop/src/default-familiar-controller.ts +++ b/apps/desktop/src/default-familiar-controller.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, powerMonitor, screen } from "electron"; +import { BrowserWindow } from "electron"; import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js"; import { shouldShowDefaultPetForExternalEvent } from "./app-state-core.js"; @@ -11,10 +11,21 @@ import { setDefaultPetTransientDisplay, } from "./default-familiar-controller-display.js"; import { getPetMovementBlockedReason, moveDefaultPetWindowBy } from "./default-familiar-controller-motion.js"; +import { + destroyManagedDefaultPetWindow, + getManagedDefaultPetWindowBounds, + hideManagedDefaultPetWindow, + installDefaultPetWindowDisplayHandlers, + loadDefaultPetWindowContent, + reclampManagedDefaultPetWindow, + recoverManagedDefaultPetMouseInterop, + recoverManagedDefaultPetWindowAfterResume, + resetManagedDefaultPetWindowToInitialPosition, +} from "./default-familiar-controller-window.js"; import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js"; import { debug, info } from "./logger.js"; import { type FamiliarOSReaction } from "./local-ipc-protocol.js"; -import { createDefaultPetWindow, getSafeDefaultPetPosition, isPetWindowDragging, loadDefaultPetContent, readWindowPosition, recoverPetMouseInterop, type PetPluginBubbles, type PetTransientDisplay } from "./familiar-window.js"; +import { createDefaultPetWindow, getSafeDefaultPetPosition, isPetWindowDragging, loadDefaultPetContent, readWindowPosition, type PetPluginBubbles, type PetTransientDisplay } from "./familiar-window.js"; import { PetBubbleArbiter, type ActiveBubble, type PetBubbleSink } from "./plugin-bubble-arbiter.js"; import { publishPluginPetEvent } from "./plugin-events-source.js"; @@ -78,15 +89,10 @@ function showDefaultPetWindow(source: "user" | "external-event"): void { export function hideDefaultPet(): void { updatePreferences({ openDefaultPetOnLaunch: false }); - - if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { - debug("familiar.default", "hide skipped", { reason: "no-window" }); - return; - } - - info("familiar.default", "hide requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId }); - setDefaultPetPosition(readWindowPosition(defaultPetWindow)); - defaultPetWindow.hide(); + hideManagedDefaultPetWindow( + defaultPetWindow, + getAppStateSnapshot().preferences.defaultPetId, + ); } export function isDefaultPetVisible(): boolean { @@ -101,7 +107,13 @@ export function setDefaultPetPaused(nextPaused: boolean): void { return; } - void loadDefaultPetContent(defaultPetWindow, paused, displayState.transientDisplay, displayState.statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles()); + loadDefaultPetWindowContent(defaultPetWindow, { + paused, + display: displayState.transientDisplay, + badge: displayState.statusBadge, + dismissToken: getCurrentDismissToken(), + pluginBubbles: getDefaultPetPluginBubbles(), + }); } export function getDefaultPetPaused(): boolean { @@ -119,17 +131,21 @@ export function refreshDefaultPetContent(): void { } debug("familiar.default", "refresh content", { windowId: defaultPetWindow.id, paused, hasDisplay: Boolean(displayState.transientDisplay), badge: displayState.statusBadge, petId: getAppStateSnapshot().preferences.defaultPetId }); - void loadDefaultPetContent(defaultPetWindow, paused, displayState.transientDisplay, displayState.statusBadge, getCurrentDismissToken(), getDefaultPetPluginBubbles()); + loadDefaultPetWindowContent(defaultPetWindow, { + paused, + display: displayState.transientDisplay, + badge: displayState.statusBadge, + dismissToken: getCurrentDismissToken(), + pluginBubbles: getDefaultPetPluginBubbles(), + }); } export function recoverDefaultPetMouseInterop(reason: string): void { - if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { - debug("familiar.default", "mouse interop recovery skipped", { reason, skippedReason: "no-window" }); - return; - } - - debug("familiar.default", "mouse interop recovery requested", { windowId: defaultPetWindow.id, reason, petId: getAppStateSnapshot().preferences.defaultPetId }); - recoverPetMouseInterop(defaultPetWindow, reason); + recoverManagedDefaultPetMouseInterop( + defaultPetWindow, + reason, + getAppStateSnapshot().preferences.defaultPetId, + ); } export function applyExternalPetReaction(reaction: FamiliarOSReaction, options: PetReactionOptions = {}): { readonly shown: boolean; readonly reason?: string } { @@ -194,33 +210,23 @@ export function applyExternalPetMoveToHome(): Promise<{ readonly moved: boolean; } export function destroyDefaultPet(): void { - clearDefaultPetDisplayTimers(); - - if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { - debug("familiar.default", "destroy skipped", { reason: "no-window" }); - defaultPetWindow = null; - return; - } - - info("familiar.default", "destroy requested", { windowId: defaultPetWindow.id, position: readWindowPosition(defaultPetWindow), petId: getAppStateSnapshot().preferences.defaultPetId }); - setDefaultPetPosition(readWindowPosition(defaultPetWindow)); - const window = defaultPetWindow; - defaultPetWindow = null; - window.setIgnoreMouseEvents(false); - window.destroy(); + defaultPetWindow = destroyManagedDefaultPetWindow( + defaultPetWindow, + getAppStateSnapshot().preferences.defaultPetId, + clearDefaultPetDisplayTimers, + ); } export function installDefaultPetDisplayHandlers(options: { readonly onPromptRequested?: () => void } = {}): void { promptRequestHandler = options.onPromptRequested; - screen.on("display-added", reclampDefaultPetWindow); - screen.on("display-removed", reclampDefaultPetWindow); - screen.on("display-metrics-changed", reclampDefaultPetWindow); - powerMonitor.on("resume", recoverDefaultPetWindowAfterResume); + installDefaultPetWindowDisplayHandlers( + reclampDefaultPetWindow, + recoverDefaultPetWindowAfterResume, + ); } export function getDefaultPetWindowBounds(): Electron.Rectangle | null { - if (!defaultPetWindow || defaultPetWindow.isDestroyed()) return null; - return defaultPetWindow.getBounds(); + return getManagedDefaultPetWindowBounds(defaultPetWindow); } function handleBubbleDismissed(dismissToken: string): void { @@ -350,20 +356,11 @@ function getCurrentDismissToken(): string | undefined { } function reclampDefaultPetWindow(): void { - if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { - return; - } - - const safePosition = readWindowPosition(defaultPetWindow); - info("familiar.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition }); - defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); - setDefaultPetPosition(safePosition); - recoverDefaultPetMouseInterop("display-change"); + reclampManagedDefaultPetWindow(defaultPetWindow, recoverDefaultPetMouseInterop); } function recoverDefaultPetWindowAfterResume(): void { - recoverDefaultPetMouseInterop("power-resume"); - setTimeout(() => recoverDefaultPetMouseInterop("power-resume+500ms"), 500).unref?.(); + recoverManagedDefaultPetWindowAfterResume(recoverDefaultPetMouseInterop); } export function shouldOpenDefaultPetOnLaunch(): boolean { @@ -371,12 +368,7 @@ export function shouldOpenDefaultPetOnLaunch(): boolean { } export function resetDefaultPetToInitialPosition(): void { - const safePosition = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize)); - resetDefaultPetPosition(safePosition); - - if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { - defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); - } + resetManagedDefaultPetWindowToInitialPosition(defaultPetWindow); } function getDefaultPetDisplayDeps() { diff --git a/apps/desktop/tests/default-familiar-controller-window.test.ts b/apps/desktop/tests/default-familiar-controller-window.test.ts new file mode 100644 index 00000000..185599ce --- /dev/null +++ b/apps/desktop/tests/default-familiar-controller-window.test.ts @@ -0,0 +1,44 @@ +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 controllerWindowSource = readFileSync(resolve(desktopRoot, "src/default-familiar-controller-window.ts"), "utf8"); +const getManagedDefaultPetWindowBounds = extractFunction(controllerWindowSource, "getManagedDefaultPetWindowBounds"); +const recoverManagedDefaultPetWindowAfterResume = extractFunction(controllerWindowSource, "recoverManagedDefaultPetWindowAfterResume"); + +assert.match(controllerSource, /from "\.\/default-familiar-controller-window(?:\.js)?"/, "default familiar controller must compose the extracted window seam."); +assert.match(controllerWindowSource, /export function loadDefaultPetWindowContent/, "default familiar window seam must export content reload handling."); +assert.match(controllerWindowSource, /export function installDefaultPetWindowDisplayHandlers/, "default familiar window seam must export display and resume handler installation."); +assert.match(controllerWindowSource, /powerMonitor\.on\("resume", recoverAfterResume\)/, "default familiar window seam must recover mouse interop after system resume."); +assert.match(controllerWindowSource, /recoverMouseInterop\("display-change"\)/, "default familiar window seam must recover mouse interop after monitor topology changes."); +assert.match(getManagedDefaultPetWindowBounds, /if \(!window \|\| window\.isDestroyed\(\)\) return null;/, "window bounds seam must guard absent or destroyed windows."); +assert.match(getManagedDefaultPetWindowBounds, /return window\.getBounds\(\);/, "window bounds seam must proxy BrowserWindow bounds for active familiars."); +assert.match(recoverManagedDefaultPetWindowAfterResume, /recoverMouseInterop\("power-resume"\)/, "resume recovery seam must trigger an immediate mouse interop refresh."); +assert.match(recoverManagedDefaultPetWindowAfterResume, /recoverMouseInterop\("power-resume\+500ms"\)/, "resume recovery seam must trigger the delayed follow-up refresh."); +assert.match(recoverManagedDefaultPetWindowAfterResume, /scheduleTimeout\(\(\) => recoverMouseInterop\("power-resume\+500ms"\), 500\)\.unref\?\.\(\);/, "resume recovery seam must unref the delayed follow-up timer when available."); + +console.error("Default familiar window seam validation passed."); + +function extractFunction(source: string, functionName: string): string { + const declaration = `function ${functionName}`; + const start = source.indexOf(declaration); + assert.notEqual(start, -1, `missing function ${functionName}`); + + const bodyStart = source.indexOf("{\n", start); + assert.notEqual(bodyStart, -1, `missing body for ${functionName}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + + assert.fail(`unterminated function ${functionName}`); +}