openpetswithchatandmcp/apps/desktop/src/plugin-pet-registry.ts
Alvin Unreal 4f4c02eacd working
2026-06-11 20:33:53 +02:00

329 lines
15 KiB
TypeScript

import { BrowserWindow } from "electron";
import { getAppStateSnapshot, type PetScaleValue } from "./app-state.js";
import { applyExternalPetReaction, applyExternalPetStatusReaction, getDefaultPetPaused, getDefaultPetWindowForPlugins, defaultPetBubbleArbiter } from "./default-pet-controller.js";
import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { debug, info } from "./logger.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { motionMoveTo, motionSetFollowCursor, motionSetPhysics, motionStop, type WindowAccessor } from "./pet-motion-engine.js";
import { createAgentPetWindow, isPetWindowDragging, loadExplicitPetContent, setPetReactionState, setPetSpriteOverride, setPetWindowScale, type PetPluginBubbles, type PetStatusBadgeReaction } from "./pet-window.js";
import { PetBubbleArbiter, type PetBubbleSink } from "./plugin-bubble-arbiter.js";
import { publishPluginPetEvent } from "./plugin-events-source.js";
import { resolveReactionSpriteState } from "./reaction-animation-mapping.js";
import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-bridge.js";
/**
* Multi-pet registry (§4): addressable pet handles for plugins. "default" is
* the user's default pet; plugin-spawned pets get their own windows, bubble
* arbiters, and liveness loops, and are torn down with their owning plugin.
*/
type SpawnedPet = {
readonly handleId: string;
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<string, SpawnedPet>();
const changeListeners = new Set<(pets: PluginPetInfo[]) => void>();
const tickSubscribers = new Map<string, Set<(dtMs: number) => void>>();
const tickLoops = new Map<string, { timer: NodeJS.Timeout; lastAt: number }>();
const tickIntervalMs = 100;
let nextSpawnId = 0;
let defaultAnimation = "idle";
function windowAccessor(petHandleId: string): WindowAccessor {
if (petHandleId === "default") return getDefaultPetWindowForPlugins;
return () => {
const pet = spawnedPets.get(petHandleId);
return pet?.window && !pet.window.isDestroyed() ? pet.window : null;
};
}
function requireWindow(petHandleId: string): BrowserWindow {
const window = windowAccessor(petHandleId)();
if (!window) throw new Error(`Pet is not available: ${petHandleId}`);
return window;
}
function notifyChange(): void {
const pets = listPluginPets();
for (const listener of changeListeners) { try { listener(pets); } catch { /* listener errors are isolated */ } }
}
export function listPluginPets(): PluginPetInfo[] {
const state = getAppStateSnapshot();
const defaultName = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId)?.displayName ?? "Default pet";
const out: PluginPetInfo[] = [{ id: "default", name: defaultName, kind: "default", visible: getDefaultPetWindowForPlugins()?.isVisible() ?? false }];
for (const pet of spawnedPets.values()) {
out.push({ id: pet.handleId, name: pet.name, kind: "plugin", visible: pet.window !== null && !pet.window.isDestroyed() && pet.window.isVisible() });
}
return out;
}
export function onPluginPetsChange(listener: (pets: PluginPetInfo[]) => void): () => void {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
}
function refreshSpawnedPet(pet: SpawnedPet): void {
if (!pet.window || pet.window.isDestroyed()) return;
void loadExplicitPetContent(pet.window, pet.petId, null, pet.statusReaction, undefined, pet.scale, pet.bubbles.transient || pet.bubbles.pinned ? pet.bubbles : null).then(() => {
if (pet.window && !pet.window.isDestroyed() && pet.spriteOverride) setPetSpriteOverride(pet.window, pet.spriteOverride);
});
}
export async function spawnPluginPet(opts: { pluginId: string; petId: string; name?: string; position?: Point }): Promise<string> {
const state = getAppStateSnapshot();
const installed = state.pets.installed.find((pet) => pet.id === opts.petId);
if (!installed || installed.broken || installed.id === builtInPet.id) throw new Error(`Pet is not installed or cannot be spawned: ${opts.petId}`);
const handleId = `plugin-pet-${++nextSpawnId}`;
const base = getDefaultPetInitialPosition(defaultPetWindowSize);
const offset = (spawnedPets.size + 1) * 60;
const position = clampToVisibleWorkArea(opts.position ?? { x: base.x - offset, y: base.y }, defaultPetWindowSize);
const pet: SpawnedPet = {
handleId,
ownerPluginId: opts.pluginId,
petId: opts.petId,
name: opts.name ?? installed.displayName,
window: null,
arbiter: null as unknown as PetBubbleArbiter,
bubbles: { transient: null, pinned: null },
statusReaction: null,
currentAnimation: "idle",
spriteOverride: null,
scale: state.preferences.petScale as PetScaleValue,
reactionRevert: null,
};
const sink: PetBubbleSink = {
present(slot, content) {
pet.bubbles = slot === "pinned" ? { ...pet.bubbles, pinned: content } : { ...pet.bubbles, transient: content };
refreshSpawnedPet(pet);
},
};
(pet as { arbiter: PetBubbleArbiter }).arbiter = new PetBubbleArbiter(sink);
const window = createAgentPetWindow({
petId: opts.petId,
displayName: pet.name,
scale: pet.scale,
position,
display: null,
badge: null,
onCloseRequested: () => closePluginPet(opts.pluginId, handleId, true),
onBubbleDismissed: (token) => { if (PetBubbleArbiter.isArbiterToken(token)) pet.arbiter.handleDismissed(token); },
onBubbleAction: (token, actionId) => pet.arbiter.handleAction(token, actionId),
onBubbleSubmit: (token, values) => pet.arbiter.handleSubmit(token, values),
onPetEvent: (name, payload) => publishPluginPetEvent(handleId, name, payload),
});
pet.window = window;
window.once("closed", () => { pet.window = null; });
window.showInactive();
spawnedPets.set(handleId, pet);
info("plugin", "plugin pet spawned", { handleId, petId: opts.petId, pluginId: opts.pluginId });
notifyChange();
return handleId;
}
export async function closePluginPet(pluginId: string, petHandleId: string, force = false): Promise<void> {
const pet = spawnedPets.get(petHandleId);
if (!pet) return;
if (!force && pet.ownerPluginId !== pluginId) throw new Error("Plugins may only close pets they spawned.");
spawnedPets.delete(petHandleId);
motionStop(petHandleId);
stopTicker(petHandleId);
if (pet.reactionRevert) clearTimeout(pet.reactionRevert);
pet.arbiter.clearPlugin(pet.ownerPluginId);
if (pet.window && !pet.window.isDestroyed()) pet.window.destroy();
info("plugin", "plugin pet closed", { petHandleId, pluginId });
notifyChange();
}
/** Tear down everything a plugin spawned or animated (plugin stop/reload). */
export function clearPluginPetsForPlugin(pluginId: string): void {
for (const pet of [...spawnedPets.values()]) {
if (pet.ownerPluginId === pluginId) void closePluginPet(pluginId, pet.handleId, true);
}
defaultPetBubbleArbiter.clearPlugin(pluginId);
}
export function showPluginPet(petHandleId: string): void {
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ showDefaultPet }) => showDefaultPet()).catch(() => undefined); notifyChange(); return; }
requireWindow(petHandleId).showInactive();
notifyChange();
}
export function hidePluginPet(petHandleId: string): void {
if (petHandleId === "default") { import("./default-pet-controller.js").then(({ hideDefaultPet }) => hideDefaultPet()).catch(() => undefined); notifyChange(); return; }
requireWindow(petHandleId).hide();
notifyChange();
}
export function reactPluginPet(petHandleId: string, reaction: OpenPetsReaction, options: { readonly showMessage?: boolean } = {}): void {
if (petHandleId === "default") { applyExternalPetReaction(reaction, options); defaultAnimation = String(reaction); return; }
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
applySpawnedPetAnimation(pet, { kind: "reaction", reaction });
}
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 pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
applySpawnedPetAnimation(pet, spec);
}
function applySpawnedPetAnimation(pet: SpawnedPet, spec: PluginAnimationSpec): void {
if (!pet.window || pet.window.isDestroyed()) throw new Error(`Pet is not available: ${pet.handleId}`);
if (pet.reactionRevert) { clearTimeout(pet.reactionRevert); pet.reactionRevert = null; }
if (spec.kind === "reaction") {
pet.spriteOverride = null;
setPetSpriteOverride(pet.window, null);
const spriteState = resolveReactionSpriteState(spec.reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
setPetReactionState(pet.window, spriteState);
pet.currentAnimation = String(spec.reaction);
pet.reactionRevert = setTimeout(() => {
pet.reactionRevert = null;
if (pet.window && !pet.window.isDestroyed()) setPetReactionState(pet.window, "idle");
pet.currentAnimation = "idle";
}, 4_000);
pet.reactionRevert.unref?.();
} else {
pet.spriteOverride = { filePath: spec.spritePath, fps: spec.fps, loop: spec.loop };
setPetSpriteOverride(pet.window, pet.spriteOverride);
pet.currentAnimation = "sprite";
}
}
export function setPluginPetScale(petHandleId: string, scale: number): void {
setPetWindowScale(requireWindow(petHandleId), scale);
const pet = spawnedPets.get(petHandleId);
if (pet) pet.scale = scale as PetScaleValue;
}
export function setPluginPetStatusReaction(petHandleId: string, reaction: OpenPetsReaction | null): void {
if (petHandleId === "default") {
applyExternalPetStatusReaction(reaction);
return;
}
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
pet.statusReaction = reaction === null || reaction === "idle" ? null : reaction as PetStatusBadgeReaction;
refreshSpawnedPet(pet);
}
export async function movePluginPetBy(petHandleId: string, opts: { x: number; y: number; durationMs?: number }): Promise<void> {
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 });
}
export async function wanderPluginPet(petHandleId: string, opts: { distance?: number; durationMs?: number }): Promise<void> {
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 });
}
export async function movePluginPetToHome(petHandleId: string): Promise<void> {
const home = getDefaultPetInitialPosition(defaultPetWindowSize);
await motionMoveTo(petHandleId, windowAccessor(petHandleId), home, { durationMs: 1_200 });
}
export async function movePluginPetTo(petHandleId: string, point: Point, opts: { durationMs?: number; easing?: string } = {}): Promise<void> {
requireWindow(petHandleId);
await motionMoveTo(petHandleId, windowAccessor(petHandleId), point, opts);
}
export function setPluginPetFollowCursor(petHandleId: string, opts: { enabled: boolean; lag?: number }): void {
requireWindow(petHandleId);
motionSetFollowCursor(petHandleId, windowAccessor(petHandleId), opts);
}
export function setPluginPetPhysics(petHandleId: string, opts: { gravity?: boolean; bounce?: number }): void {
requireWindow(petHandleId);
motionSetPhysics(petHandleId, windowAccessor(petHandleId), opts);
}
export function getPluginPetState(petHandleId: string): PluginPetState {
const window = requireWindow(petHandleId);
const [x, y] = window.getPosition();
const [width, height] = window.getSize();
const pet = spawnedPets.get(petHandleId);
return {
position: { x, y },
bounds: { x, y, width, height },
currentAnimation: petHandleId === "default" ? (getDefaultPetPaused() ? "paused" : defaultAnimation) : pet?.currentAnimation ?? "idle",
visible: window.isVisible(),
dragging: isPetWindowDragging(window),
};
}
/** Host-driven behavior loop: throttled, auto-paused while hidden/dragging. */
export function onPluginPetTick(petHandleId: string, handler: (dtMs: number) => void): () => void {
let subscribers = tickSubscribers.get(petHandleId);
if (!subscribers) { subscribers = new Set(); tickSubscribers.set(petHandleId, subscribers); }
subscribers.add(handler);
if (!tickLoops.has(petHandleId)) {
const loop = { timer: null as unknown as NodeJS.Timeout, lastAt: Date.now() };
loop.timer = setInterval(() => {
const window = windowAccessor(petHandleId)();
const now = Date.now();
if (!window || window.isDestroyed() || !window.isVisible() || isPetWindowDragging(window)) { loop.lastAt = now; return; }
const dt = now - loop.lastAt;
loop.lastAt = now;
const live = tickSubscribers.get(petHandleId);
if (!live || live.size === 0) return;
for (const subscriber of live) { try { subscriber(dt); } catch { /* isolated upstream */ } }
}, tickIntervalMs);
loop.timer.unref?.();
tickLoops.set(petHandleId, loop);
}
return () => {
const live = tickSubscribers.get(petHandleId);
live?.delete(handler);
if (live && live.size === 0) stopTicker(petHandleId);
};
}
function stopTicker(petHandleId: string): void {
const loop = tickLoops.get(petHandleId);
if (loop) { clearInterval(loop.timer); tickLoops.delete(petHandleId); }
tickSubscribers.delete(petHandleId);
}
/** Show a plugin bubble on a pet surface (the bubbles capability entry point). */
export function getPluginPetArbiter(petHandleId: string): PetBubbleArbiter {
if (petHandleId === "default") return defaultPetBubbleArbiter;
const pet = spawnedPets.get(petHandleId);
if (!pet) throw new Error(`Pet is not available: ${petHandleId}`);
return pet.arbiter;
}
export function closeAllPluginPets(): void {
for (const pet of [...spawnedPets.values()]) void closePluginPet(pet.ownerPluginId, pet.handleId, true);
debug("plugin", "all plugin pets closed");
}