Extract default familiar window lifecycle seam

This commit is contained in:
OpenPets Dev 2026-06-19 06:34:16 +00:00
parent 5caf9b1c39
commit 430ccdb661
6 changed files with 256 additions and 60 deletions

View file

@ -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",

View file

@ -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.");

View file

@ -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

View file

@ -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);
}
}

View file

@ -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() {

View file

@ -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}`);
}