diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 4120ce96..7cbe1a63 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -207,6 +207,8 @@ const agentSetupEditorToolsSource = readFileSync(join(appDir, "src", "agent-setu const agentSetupEditorToolsOpenCodeSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools-opencode.ts"), "utf8"); const agentSetupEditorToolsCursorSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools-cursor.ts"), "utf8"); const agentSetupSupportSource = readFileSync(join(appDir, "src", "agent-setup-support.ts"), "utf8"); +const pluginFamiliarRegistrySource = readFileSync(join(appDir, "src", "plugin-familiar-registry.ts"), "utf8"); +const pluginFamiliarRegistryActionsSource = readFileSync(join(appDir, "src", "plugin-familiar-registry-actions.ts"), "utf8"); const pluginSdkBridgeSource = readFileSync(join(appDir, "src", "plugin-sdk-bridge.ts"), "utf8"); const pluginSdkApiBuilderSource = readFileSync(join(appDir, "src", "plugin-sdk-api-builder.ts"), "utf8"); const pluginSdkApiBuilderPlatformSource = readFileSync(join(appDir, "src", "plugin-sdk-api-builder-platform.ts"), "utf8"); @@ -604,6 +606,10 @@ assert.match(agentSetupEditorToolsOpenCodeSource, /export function installOpenCo assert.match(agentSetupEditorToolsCursorSource, /export function buildCursorSetupSnapshot/, "agent-setup Cursor helper seam must export setup snapshot building."); assert.match(agentSetupEditorToolsCursorSource, /export function replaceCursorGlobalConfig/, "agent-setup Cursor helper seam must export replace writes."); assert.match(pluginServiceDefaultPetSource, /export async function buildDefaultPetPluginCommands/, "plugin-service default-pet helper must export default familiar command shaping."); +assert.match(pluginFamiliarRegistrySource, /from "\.\/plugin-familiar-registry-actions(?:\.js)?"/, "plugin familiar registry must compose the extracted action seam."); +assert.match(pluginFamiliarRegistryActionsSource, /export function reactPluginPetAction/, "plugin familiar action seam must export reaction routing."); +assert.match(pluginFamiliarRegistryActionsSource, /export async function movePluginPetByAction/, "plugin familiar action seam must export bounded motion routing."); +assert.match(pluginFamiliarRegistryActionsSource, /export function getPluginPetArbiterAction/, "plugin familiar action seam must export bubble arbiter routing."); assert.match(pluginServiceSupportSource, /from "\.\/plugin-service-actions(?:\.js)?"/, "plugin-service support barrel must re-export the service action seam."); assert.match(pluginServiceActionsSource, /export async function buildPluginCatalogSnapshot/, "plugin-service action seam must export catalog snapshot shaping."); assert.match(pluginServiceActionsSource, /export async function installOrUpdateCatalogPlugin/, "plugin-service action seam must export catalog install and update flows."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 20d892ef..2ac12386 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -318,6 +318,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `plugin-state.ts`: Persistent plugin state store (`familiaros-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason. - `plugin-runtime.ts`: Runtime that compiles enabled declarative timer triggers, starts/stops JavaScript plugin hosts, verifies approved permissions, exposes public command/status state, validates actions, schedules cancellable timers, and marks broken plugins on validation/action failure. - `plugin-familiar-api.ts`: Narrow adapter from plugin actions to default familiar external `say`/`react` controller calls. +- `plugin-familiar-registry.ts`: Plugin-spawned familiar lifecycle ownership, list/change subscriptions, per-plugin teardown, and delegation into the extracted familiar action seam. +- `plugin-familiar-registry-actions.ts`: Extracted plugin familiar reaction/animation/status/motion/state/arbiter routing for default and spawned familiar handles. - `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, local load, bundled seeding, and delegation into the extracted bundled lifecycle plus catalog/install/uninstall/config-sound seams. - `plugin-service-app.ts`: Extracted app-global plugin-service singleton, default familiar command/menu bridge, and test override hooks - `plugin-service-support.ts`: Service barrel for the extracted plugin-service action/bundled-lifecycle/bundled/catalog/default-pet/dialog/local/snapshot/text helper seams diff --git a/apps/desktop/src/plugin-familiar-registry-actions.ts b/apps/desktop/src/plugin-familiar-registry-actions.ts new file mode 100644 index 00000000..bc42ce0d --- /dev/null +++ b/apps/desktop/src/plugin-familiar-registry-actions.ts @@ -0,0 +1,252 @@ +import type { BrowserWindow } from "electron"; + +import type { PetScaleValue } from "./app-state.js"; +import type { Point } from "./display.js"; +import type { WindowAccessor } from "./familiar-motion-engine.js"; +import type { FamiliarOSReaction } from "./local-ipc-protocol.js"; +import type { PetPluginBubbles, PetStatusBadgeReaction } from "./familiar-window.js"; +import type { PetBubbleArbiter } from "./plugin-bubble-arbiter.js"; +import type { PluginAnimationSpec, PluginPetState } from "./plugin-sdk-types.js"; +import type { UniversalSpriteState } from "./reaction-animation-mapping.js"; + +export type PluginPetSpriteOverride = { + readonly filePath: string; + readonly fps: number; + readonly loop: boolean; +}; + +export type PluginFamiliarRecord = { + readonly handleId: string; + window: BrowserWindow | null; + readonly arbiter: PetBubbleArbiter; + bubbles: PetPluginBubbles; + statusReaction: PetStatusBadgeReaction | null; + currentAnimation: string; + spriteOverride: PluginPetSpriteOverride | null; + scale: PetScaleValue; + reactionRevert: NodeJS.Timeout | null; +}; + +export type PluginSpawnedFamiliarRecord = PluginFamiliarRecord & { + readonly petId: string; +}; + +type PluginFamiliarActionDependencies = { + readonly requireWindow: (petHandleId: string) => BrowserWindow; + readonly getSpawnedPet: (petHandleId: string) => PluginSpawnedFamiliarRecord | undefined; + readonly refreshSpawnedPet: (familiar: PluginSpawnedFamiliarRecord) => void; + readonly applyExternalPetReaction: (reaction: FamiliarOSReaction, options?: { readonly showMessage?: boolean }) => void; + readonly applyExternalPetStatusReaction: (reaction: FamiliarOSReaction | null) => void; + readonly getDefaultPetPaused: () => boolean; + readonly getDefaultAnimation: () => string; + readonly setDefaultAnimation: (animation: string) => void; + readonly setPetReactionState: (window: BrowserWindow, state: UniversalSpriteState) => void; + readonly setPetSpriteOverride: (window: BrowserWindow, override: PluginPetSpriteOverride | null) => void; + readonly setPetWindowScale: (window: BrowserWindow, scale: number) => void; + readonly resolveReactionState: (reaction: FamiliarOSReaction) => UniversalSpriteState; + readonly motionMoveTo: (petHandleId: string, accessor: WindowAccessor, point: Point, opts: { readonly durationMs?: number; readonly easing?: string }) => Promise; + readonly motionSetFollowCursor: (petHandleId: string, accessor: WindowAccessor, opts: { readonly enabled: boolean; readonly lag?: number }) => void; + readonly motionSetPhysics: (petHandleId: string, accessor: WindowAccessor, opts: { readonly gravity?: boolean; readonly bounce?: number }) => void; + readonly getWindowAccessor: (petHandleId: string) => WindowAccessor; + readonly isPetWindowDragging: (window: BrowserWindow) => boolean; + readonly getDefaultPetHomePosition: () => Point; + readonly getDefaultPetArbiter: () => PetBubbleArbiter; +}; + +export function reactPluginPetAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + reaction: FamiliarOSReaction, + options: { readonly showMessage?: boolean } = {}, +): void { + if (petHandleId === "default") { + dependencies.applyExternalPetReaction(reaction, options); + dependencies.setDefaultAnimation(String(reaction)); + return; + } + + const familiar = dependencies.getSpawnedPet(petHandleId); + if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); + applySpawnedPetAnimationAction(dependencies, familiar, { kind: "reaction", reaction }); +} + +export function setPluginPetAnimationAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + spec: PluginAnimationSpec, +): void { + if (petHandleId === "default") { + const window = dependencies.requireWindow("default"); + if (spec.kind === "reaction") { + dependencies.applyExternalPetReaction(spec.reaction); + dependencies.setPetSpriteOverride(window, null); + dependencies.setDefaultAnimation(String(spec.reaction)); + } else { + dependencies.setPetSpriteOverride(window, { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop }); + dependencies.setDefaultAnimation("sprite"); + } + return; + } + + const familiar = dependencies.getSpawnedPet(petHandleId); + if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); + applySpawnedPetAnimationAction(dependencies, familiar, spec); +} + +export function applySpawnedPetAnimationAction( + dependencies: PluginFamiliarActionDependencies, + familiar: PluginFamiliarRecord, + spec: PluginAnimationSpec, +): void { + if (!familiar.window || familiar.window.isDestroyed()) throw new Error(`Familiar is not available: ${familiar.handleId}`); + if (familiar.reactionRevert) { + clearTimeout(familiar.reactionRevert); + familiar.reactionRevert = null; + } + + if (spec.kind === "reaction") { + familiar.spriteOverride = null; + dependencies.setPetSpriteOverride(familiar.window, null); + dependencies.setPetReactionState(familiar.window, dependencies.resolveReactionState(spec.reaction)); + familiar.currentAnimation = String(spec.reaction); + familiar.reactionRevert = setTimeout(() => { + familiar.reactionRevert = null; + if (familiar.window && !familiar.window.isDestroyed()) dependencies.setPetReactionState(familiar.window, "idle"); + familiar.currentAnimation = "idle"; + }, 4_000); + familiar.reactionRevert.unref?.(); + return; + } + + familiar.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop }; + dependencies.setPetSpriteOverride(familiar.window, familiar.spriteOverride); + familiar.currentAnimation = "sprite"; +} + +export function setPluginPetScaleAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + scale: number, +): void { + dependencies.setPetWindowScale(dependencies.requireWindow(petHandleId), scale); + const familiar = dependencies.getSpawnedPet(petHandleId); + if (familiar) familiar.scale = scale as PetScaleValue; +} + +export function setPluginPetStatusReactionAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + reaction: FamiliarOSReaction | null, +): void { + if (petHandleId === "default") { + dependencies.applyExternalPetStatusReaction(reaction); + return; + } + + const familiar = dependencies.getSpawnedPet(petHandleId); + if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); + familiar.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction; + dependencies.refreshSpawnedPet(familiar); +} + +export async function movePluginPetByAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + opts: { readonly x: number; readonly y: number; readonly durationMs?: number }, +): Promise { + const window = dependencies.requireWindow(petHandleId); + const [x, y] = window.getPosition(); + const distance = Math.min(Math.hypot(opts.x, opts.y), 160); + const magnitude = Math.hypot(opts.x, opts.y); + const scale = distance > 0 && magnitude > 0 ? distance / magnitude : 0; + await dependencies.motionMoveTo( + petHandleId, + dependencies.getWindowAccessor(petHandleId), + { x: x + opts.x * scale, y: y + opts.y * scale }, + { durationMs: opts.durationMs ?? 700 }, + ); +} + +export async function wanderPluginPetAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + opts: { readonly distance?: number; readonly durationMs?: number }, + random: () => number = Math.random, +): Promise { + const distance = Math.min(Math.max(opts.distance ?? 80, 0), 160); + const angle = random() * Math.PI * 2; + await movePluginPetByAction(dependencies, petHandleId, { + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + durationMs: opts.durationMs, + }); +} + +export async function movePluginPetToHomeAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, +): Promise { + await dependencies.motionMoveTo( + petHandleId, + dependencies.getWindowAccessor(petHandleId), + dependencies.getDefaultPetHomePosition(), + { durationMs: 1_200 }, + ); +} + +export async function movePluginPetToAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + point: Point, + opts: { readonly durationMs?: number; readonly easing?: string } = {}, +): Promise { + dependencies.requireWindow(petHandleId); + await dependencies.motionMoveTo(petHandleId, dependencies.getWindowAccessor(petHandleId), point, opts); +} + +export function setPluginPetFollowCursorAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + opts: { readonly enabled: boolean; readonly lag?: number }, +): void { + dependencies.requireWindow(petHandleId); + dependencies.motionSetFollowCursor(petHandleId, dependencies.getWindowAccessor(petHandleId), opts); +} + +export function setPluginPetPhysicsAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, + opts: { readonly gravity?: boolean; readonly bounce?: number }, +): void { + dependencies.requireWindow(petHandleId); + dependencies.motionSetPhysics(petHandleId, dependencies.getWindowAccessor(petHandleId), opts); +} + +export function getPluginPetStateAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, +): PluginPetState { + const window = dependencies.requireWindow(petHandleId); + const [x, y] = window.getPosition(); + const [width, height] = window.getSize(); + const familiar = dependencies.getSpawnedPet(petHandleId); + return { + position: { x, y }, + bounds: { x, y, width, height }, + currentAnimation: petHandleId === "default" + ? (dependencies.getDefaultPetPaused() ? "paused" : dependencies.getDefaultAnimation()) + : familiar?.currentAnimation ?? "idle", + visible: window.isVisible(), + dragging: dependencies.isPetWindowDragging(window), + }; +} + +export function getPluginPetArbiterAction( + dependencies: PluginFamiliarActionDependencies, + petHandleId: string, +): PetBubbleArbiter { + if (petHandleId === "default") return dependencies.getDefaultPetArbiter(); + const familiar = dependencies.getSpawnedPet(petHandleId); + if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); + return familiar.arbiter; +} diff --git a/apps/desktop/src/plugin-familiar-registry.ts b/apps/desktop/src/plugin-familiar-registry.ts index a11ee512..d3871482 100644 --- a/apps/desktop/src/plugin-familiar-registry.ts +++ b/apps/desktop/src/plugin-familiar-registry.ts @@ -10,6 +10,21 @@ import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./familiar-window.js"; import { PetBubbleArbiter, type PetBubbleSink } from "./plugin-bubble-arbiter.js"; import { publishPluginPetEvent } from "./plugin-events-source.js"; +import { + getPluginPetArbiterAction, + getPluginPetStateAction, + movePluginPetByAction, + movePluginPetToAction, + movePluginPetToHomeAction, + reactPluginPetAction, + setPluginPetAnimationAction, + setPluginPetFollowCursorAction, + setPluginPetPhysicsAction, + setPluginPetScaleAction, + setPluginPetStatusReactionAction, + type PluginSpawnedFamiliarRecord, + wanderPluginPetAction, +} from "./plugin-familiar-registry-actions.js"; import { resolveReactionSpriteState } from "./reaction-animation-mapping.js"; import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-types.js"; @@ -19,19 +34,9 @@ import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugi * arbiters, and liveness loops, and are torn down with their owning plugin. */ -type SpawnedPet = { - readonly handleId: string; +type SpawnedPet = PluginSpawnedFamiliarRecord & { readonly ownerPluginId: string; - readonly petId: string; readonly name: string; - window: BrowserWindow | null; - readonly arbiter: PetBubbleArbiter; - bubbles: PetPluginBubbles; - statusReaction: PetStatusBadgeReaction | null; - currentAnimation: string; - spriteOverride: { filePath: string; fps: number; loop: boolean } | null; - scale: PetScaleValue; - reactionRevert: NodeJS.Timeout | null; }; const spawnedPets = new Map(); @@ -42,6 +47,30 @@ const tickIntervalMs = 100; let nextSpawnId = 0; let defaultAnimation = "idle"; +const pluginPetActionDependencies = { + requireWindow, + getSpawnedPet: (petHandleId: string) => spawnedPets.get(petHandleId), + refreshSpawnedPet, + applyExternalPetReaction, + applyExternalPetStatusReaction, + getDefaultPetPaused, + getDefaultAnimation: () => defaultAnimation, + setDefaultAnimation: (animation: string) => { + defaultAnimation = animation; + }, + setPetReactionState, + setPetSpriteOverride, + setPetWindowScale, + resolveReactionState: (reaction: FamiliarOSReaction) => resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides), + motionMoveTo, + motionSetFollowCursor, + motionSetPhysics, + getWindowAccessor: windowAccessor, + isPetWindowDragging, + getDefaultPetHomePosition: () => getDefaultPetInitialPosition(defaultPetWindowSize), + getDefaultPetArbiter: () => defaultPetBubbleArbiter, +} satisfies Parameters[0]; + function windowAccessor(petHandleId: string): WindowAccessor { if (petHandleId === "default") return getDefaultPetWindowForPlugins; return () => { @@ -76,7 +105,7 @@ export function onPluginPetsChange(listener: (familiars: PluginPetInfo[]) => voi return () => changeListeners.delete(listener); } -function refreshSpawnedPet(familiar: SpawnedPet): void { +function refreshSpawnedPet(familiar: PluginSpawnedFamiliarRecord): void { if (!familiar.window || familiar.window.isDestroyed()) return; void loadExplicitPetContent(familiar.window, familiar.petId, null, familiar.statusReaction, undefined, familiar.scale, familiar.bubbles.transient || familiar.bubbles.pinned ? familiar.bubbles : null).then(() => { if (familiar.window && !familiar.window.isDestroyed() && familiar.spriteOverride) setPetSpriteOverride(familiar.window, familiar.spriteOverride); @@ -171,115 +200,47 @@ export function hidePluginPet(petHandleId: string): void { } export function reactPluginPet(petHandleId: string, reaction: FamiliarOSReaction, options: { readonly showMessage?: boolean } = {}): void { - if (petHandleId === "default") { applyExternalPetReaction(reaction, options); defaultAnimation = String(reaction); return; } - const familiar = spawnedPets.get(petHandleId); - if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); - applySpawnedPetAnimation(familiar, { kind: "reaction", reaction }); + reactPluginPetAction(pluginPetActionDependencies, petHandleId, reaction, options); } export function setPluginPetAnimation(petHandleId: string, spec: PluginAnimationSpec): void { - if (petHandleId === "default") { - const window = requireWindow("default"); - if (spec.kind === "reaction") { - applyExternalPetReaction(spec.reaction); - setPetSpriteOverride(window, null); - defaultAnimation = String(spec.reaction); - } else { - setPetSpriteOverride(window, { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop }); - defaultAnimation = "sprite"; - } - return; - } - const familiar = spawnedPets.get(petHandleId); - if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); - applySpawnedPetAnimation(familiar, spec); -} - -function applySpawnedPetAnimation(familiar: SpawnedPet, spec: PluginAnimationSpec): void { - if (!familiar.window || familiar.window.isDestroyed()) throw new Error(`Familiar is not available: ${familiar.handleId}`); - if (familiar.reactionRevert) { clearTimeout(familiar.reactionRevert); familiar.reactionRevert = null; } - if (spec.kind === "reaction") { - familiar.spriteOverride = null; - setPetSpriteOverride(familiar.window, null); - const spriteState = resolveReactionSpriteState(spec.reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides); - setPetReactionState(familiar.window, spriteState); - familiar.currentAnimation = String(spec.reaction); - familiar.reactionRevert = setTimeout(() => { - familiar.reactionRevert = null; - if (familiar.window && !familiar.window.isDestroyed()) setPetReactionState(familiar.window, "idle"); - familiar.currentAnimation = "idle"; - }, 4_000); - familiar.reactionRevert.unref?.(); - } else { - familiar.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop }; - setPetSpriteOverride(familiar.window, familiar.spriteOverride); - familiar.currentAnimation = "sprite"; - } + setPluginPetAnimationAction(pluginPetActionDependencies, petHandleId, spec); } export function setPluginPetScale(petHandleId: string, scale: number): void { - setPetWindowScale(requireWindow(petHandleId), scale); - const familiar = spawnedPets.get(petHandleId); - if (familiar) familiar.scale = scale as PetScaleValue; + setPluginPetScaleAction(pluginPetActionDependencies, petHandleId, scale); } export function setPluginPetStatusReaction(petHandleId: string, reaction: FamiliarOSReaction | null): void { - if (petHandleId === "default") { - applyExternalPetStatusReaction(reaction); - return; - } - const familiar = spawnedPets.get(petHandleId); - if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); - familiar.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction; - refreshSpawnedPet(familiar); + setPluginPetStatusReactionAction(pluginPetActionDependencies, petHandleId, reaction); } export async function movePluginPetBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise { - const window = requireWindow(petHandleId); - const [x, y] = window.getPosition(); - const distance = Math.min(Math.hypot(opts.x, opts.y), 160); - const scale = distance > 0 ? distance / Math.hypot(opts.x, opts.y) : 0; - await motionMoveTo(petHandleId, windowAccessor(petHandleId), { x: x + opts.x * scale, y: y + opts.y * scale }, { durationMs: opts.durationMs ?? 700 }); + await movePluginPetByAction(pluginPetActionDependencies, petHandleId, opts); } export async function wanderPluginPet(petHandleId: string, opts: { distance?: number; durationMs?: number }): Promise { - const distance = Math.min(Math.max(opts.distance ?? 80, 0), 160); - const angle = Math.random() * Math.PI * 2; - await movePluginPetBy(petHandleId, { x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, durationMs: opts.durationMs }); + await wanderPluginPetAction(pluginPetActionDependencies, petHandleId, opts); } export async function movePluginPetToHome(petHandleId: string): Promise { - const home = getDefaultPetInitialPosition(defaultPetWindowSize); - await motionMoveTo(petHandleId, windowAccessor(petHandleId), home, { durationMs: 1_200 }); + await movePluginPetToHomeAction(pluginPetActionDependencies, petHandleId); } export async function movePluginPetTo(petHandleId: string, point: Point, opts: { durationMs?: number; easing?: string } = {}): Promise { - requireWindow(petHandleId); - await motionMoveTo(petHandleId, windowAccessor(petHandleId), point, opts); + await movePluginPetToAction(pluginPetActionDependencies, petHandleId, point, opts); } export function setPluginPetFollowCursor(petHandleId: string, opts: { enabled: boolean; lag?: number }): void { - requireWindow(petHandleId); - motionSetFollowCursor(petHandleId, windowAccessor(petHandleId), opts); + setPluginPetFollowCursorAction(pluginPetActionDependencies, petHandleId, opts); } export function setPluginPetPhysics(petHandleId: string, opts: { gravity?: boolean; bounce?: number }): void { - requireWindow(petHandleId); - motionSetPhysics(petHandleId, windowAccessor(petHandleId), opts); + setPluginPetPhysicsAction(pluginPetActionDependencies, petHandleId, opts); } export function getPluginPetState(petHandleId: string): PluginPetState { - const window = requireWindow(petHandleId); - const [x, y] = window.getPosition(); - const [width, height] = window.getSize(); - const familiar = spawnedPets.get(petHandleId); - return { - position: { x, y }, - bounds: { x, y, width, height }, - currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : familiar?.currentAnimation ?? "idle", - visible: window.isVisible(), - dragging: isPetWindowDragging(window), - }; + return getPluginPetStateAction(pluginPetActionDependencies, petHandleId); } /** Host-driven behavior loop: throttled, auto-paused while hidden/dragging. */ @@ -317,10 +278,7 @@ function stopTicker(petHandleId: string): void { /** Show a plugin bubble on a familiar surface (the bubbles capability entry point). */ export function getPluginPetArbiter(petHandleId: string): PetBubbleArbiter { - if (petHandleId === "default") return defaultPetBubbleArbiter; - const familiar = spawnedPets.get(petHandleId); - if (!familiar) throw new Error(`Familiar is not available: ${petHandleId}`); - return familiar.arbiter; + return getPluginPetArbiterAction(pluginPetActionDependencies, petHandleId); } export function closeAllPluginPets(): void { diff --git a/apps/desktop/tests/plugin-familiar-registry-actions.test.ts b/apps/desktop/tests/plugin-familiar-registry-actions.test.ts new file mode 100644 index 00000000..7b603106 --- /dev/null +++ b/apps/desktop/tests/plugin-familiar-registry-actions.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BrowserWindow } from "electron"; + +import { + applySpawnedPetAnimationAction, + getPluginPetArbiterAction, + getPluginPetStateAction, + movePluginPetByAction, + reactPluginPetAction, + type PluginSpawnedFamiliarRecord, +} from "../src/plugin-familiar-registry-actions.js"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pluginFamiliarRegistrySource = readFileSync(resolve(desktopRoot, "src/plugin-familiar-registry.ts"), "utf8"); +const pluginFamiliarRegistryActionsSource = readFileSync(resolve(desktopRoot, "src/plugin-familiar-registry-actions.ts"), "utf8"); + +assert.match(pluginFamiliarRegistrySource, /from "\.\/plugin-familiar-registry-actions(?:\.js)?"/, "plugin familiar registry must compose the extracted action seam."); +assert.match(pluginFamiliarRegistryActionsSource, /export function reactPluginPetAction/, "plugin familiar action seam must export reaction routing."); +assert.match(pluginFamiliarRegistryActionsSource, /export async function movePluginPetByAction/, "plugin familiar action seam must export bounded motion routing."); +assert.match(pluginFamiliarRegistryActionsSource, /export function getPluginPetArbiterAction/, "plugin familiar action seam must export bubble arbiter routing."); + +let defaultAnimation = "idle"; +const externalReactions: Array<{ reaction: string; showMessage?: boolean }> = []; +reactPluginPetAction(createActionDependencies({ + applyExternalPetReaction: (reaction, options) => { + externalReactions.push({ reaction, showMessage: options?.showMessage }); + }, + getDefaultAnimation: () => defaultAnimation, + setDefaultAnimation: (animation) => { + defaultAnimation = animation; + }, +}), "default", "thinking", { showMessage: false }); +assert.deepEqual(externalReactions, [{ reaction: "thinking", showMessage: false }], "default familiar reactions must delegate through the external familiar controller seam."); +assert.equal(defaultAnimation, "thinking", "default familiar reactions must preserve the registry animation state."); + +const animationStates: string[] = []; +const spriteOverrides: Array | null> = []; +const spawnedWindow = createWindowStub({ x: 12, y: 20, width: 96, height: 96, visible: true }); +const spawnedPet = createSpawnedPet("plugin-familiar-1", spawnedWindow); +applySpawnedPetAnimationAction(createActionDependencies({ + setPetReactionState: (_window, state) => { + animationStates.push(state); + }, + setPetSpriteOverride: (_window, override) => { + spriteOverrides.push(override); + }, + resolveReactionState: () => "waiting", +}), spawnedPet, { kind: "reaction", reaction: "thinking" }); +assert.equal(spawnedPet.currentAnimation, "thinking", "spawned familiar reactions must update the current animation state."); +assert.deepEqual(animationStates, ["waiting"], "spawned familiar reactions must route through the injected reaction-state mapper."); +assert.deepEqual(spriteOverrides, [null], "spawned familiar reactions must clear sprite overrides before applying reaction animation."); +if (spawnedPet.reactionRevert) clearTimeout(spawnedPet.reactionRevert); + +const moveTargets: Array<{ petHandleId: string; point: { x: number; y: number }; durationMs?: number }> = []; +await movePluginPetByAction(createActionDependencies({ + requireWindow: () => createWindowStub({ x: 10, y: 20, width: 96, height: 96, visible: true }), + motionMoveTo: async (petHandleId, _accessor, point, opts) => { + moveTargets.push({ petHandleId, point, durationMs: opts.durationMs }); + }, +}), "plugin-familiar-2", { x: 300, y: 0, durationMs: 450 }); +assert.deepEqual(moveTargets, [{ petHandleId: "plugin-familiar-2", point: { x: 170, y: 20 }, durationMs: 450 }], "plugin familiar movement must clamp travel distance before routing to motion."); + +const defaultState = getPluginPetStateAction(createActionDependencies({ + requireWindow: () => createWindowStub({ x: 30, y: 40, width: 120, height: 140, visible: false }), + getDefaultPetPaused: () => true, + getDefaultAnimation: () => "wave", + isPetWindowDragging: () => true, +}), "default"); +assert.deepEqual(defaultState, { + position: { x: 30, y: 40 }, + bounds: { x: 30, y: 40, width: 120, height: 140 }, + currentAnimation: "paused", + visible: false, + dragging: true, +}, "plugin familiar state must preserve paused default familiar semantics."); + +const defaultArbiter = { kind: "default" }; +const spawnedArbiter = { kind: "spawned" }; +const arbiterPet = createSpawnedPet("plugin-familiar-3", createWindowStub({ x: 0, y: 0, width: 80, height: 80, visible: true }), spawnedArbiter); +assert.equal(getPluginPetArbiterAction(createActionDependencies({ getDefaultPetArbiter: () => defaultArbiter as never }), "default"), defaultArbiter, "default familiar arbiter routing must stay on the default familiar controller surface."); +assert.equal(getPluginPetArbiterAction(createActionDependencies({ getSpawnedPet: () => arbiterPet }), "plugin-familiar-3"), spawnedArbiter, "spawned familiar arbiter routing must preserve per-window bubble arbiters."); + +console.error("Plugin familiar registry action seam validation passed."); + +function createActionDependencies(overrides: Partial[0]> = {}): Parameters[0] { + return { + requireWindow: () => createWindowStub({ x: 0, y: 0, width: 96, height: 96, visible: true }), + getSpawnedPet: () => undefined, + refreshSpawnedPet: () => undefined, + applyExternalPetReaction: () => undefined, + applyExternalPetStatusReaction: () => undefined, + getDefaultPetPaused: () => false, + getDefaultAnimation: () => "idle", + setDefaultAnimation: () => undefined, + setPetReactionState: () => undefined, + setPetSpriteOverride: () => undefined, + setPetWindowScale: () => undefined, + resolveReactionState: () => "idle", + motionMoveTo: async () => undefined, + motionSetFollowCursor: () => undefined, + motionSetPhysics: () => undefined, + getWindowAccessor: () => () => createWindowStub({ x: 0, y: 0, width: 96, height: 96, visible: true }), + isPetWindowDragging: () => false, + getDefaultPetHomePosition: () => ({ x: 0, y: 0 }), + getDefaultPetArbiter: () => ({}) as never, + ...overrides, + }; +} + +function createSpawnedPet( + handleId: string, + window: BrowserWindow, + arbiter: unknown = { kind: "spawned" }, +): PluginSpawnedFamiliarRecord { + return { + handleId, + petId: "builtin:test", + window, + arbiter: arbiter as never, + bubbles: { transient: null, pinned: null }, + statusReaction: null, + currentAnimation: "idle", + spriteOverride: null, + scale: 1, + reactionRevert: null, + } as PluginSpawnedFamiliarRecord; +} + +function createWindowStub({ + x, + y, + width, + height, + visible, +}: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly visible: boolean; +}): BrowserWindow { + return { + getPosition: () => [x, y] as const, + getSize: () => [width, height] as const, + isVisible: () => visible, + isDestroyed: () => false, + } as unknown as BrowserWindow; +}