openpetswithchatandmcp/apps/desktop/src/pet-window.ts
Alvin Unreal 7d92db9724 Fix drag
2026-05-27 15:01:14 +02:00

890 lines
45 KiB
TypeScript

import { app, BrowserWindow, ipcMain, Menu, screen, type IpcMainEvent } from "electron";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js";
import { clampToPrimaryWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js";
import { builtInPet } from "./built-in-pet.js";
import { getInstalledPetDir } from "./pet-paths.js";
import type { OpenPetsReaction } from "./local-ipc-protocol.js";
import { pickReactionMessage } from "./reaction-messages.js";
import { debug, error as logError, info } from "./logger.js";
import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js";
export interface DefaultPetWindowOptions {
readonly position: Point;
readonly paused: boolean;
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly onPositionChanged: (position: Point) => void;
readonly onHideRequested: () => void;
readonly onBubbleDismissed?: (dismissToken: string) => void;
}
export interface AgentPetWindowOptions {
readonly petId: string;
readonly displayName: string;
readonly scale: PetScaleValue;
readonly position: Point;
readonly display: PetTransientDisplay | null;
readonly badge: PetStatusBadgeReaction | null;
readonly onCloseRequested: () => void;
readonly onBubbleDismissed?: (dismissToken: string) => void;
}
export interface PetTransientDisplay {
readonly reaction?: OpenPetsReaction;
readonly message?: string;
readonly reactionMessage?: string;
readonly dismissToken?: string;
}
export type PetStatusBadgeReaction = Exclude<OpenPetsReaction, "idle">;
interface PetContentRender {
readonly html: string;
readonly bodyHtml: string;
readonly reactionState: UniversalSpriteState;
readonly cacheKey: string;
}
const petWindowRenderCache = new WeakMap<BrowserWindow, string>();
const windowLoadChains = new WeakMap<BrowserWindow, Promise<void>>();
const windowLoadSequences = new WeakMap<BrowserWindow, number>();
const petMouseInteropRecovery = new WeakMap<BrowserWindow, (reason: string) => void>();
export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismissToken?: string): BrowserWindow {
const window = createBasePetWindow("OpenPets — Default Pet", options.position);
info("pet.window", "default window create", { windowId: window.id, position: options.position, paused: options.paused, hasDisplay: Boolean(options.display), badge: options.badge });
installMousePassthroughAndDrag(window, options.onBubbleDismissed);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested });
const savePosition = debounce(() => {
if (window.isDestroyed()) {
return;
}
options.onPositionChanged(readWindowPosition(window));
}, 150);
window.on("move", savePosition);
window.on("moved", savePosition);
window.on("close", () => {
info("pet.window", "default window close", { windowId: window.id, position: readWindowPosition(window) });
options.onPositionChanged(readWindowPosition(window));
});
void loadDefaultPetContent(window, options.paused, options.display, options.badge, dismissToken);
return window;
}
export function createAgentPetWindow(options: AgentPetWindowOptions, dismissToken?: string): BrowserWindow {
const window = createBasePetWindow(`OpenPets — ${options.displayName}`, options.position);
info("pet.window", "agent window create", { windowId: window.id, petId: options.petId, displayName: options.displayName, position: options.position, hasDisplay: Boolean(options.display), badge: options.badge });
installMousePassthroughAndDrag(window, options.onBubbleDismissed);
installMotionStatePublisher(window);
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
void loadExplicitPetContent(window, options.petId, options.display, options.badge, dismissToken, options.scale);
return window;
}
export function recoverPetMouseInterop(window: BrowserWindow, reason: string): void {
if (window.isDestroyed()) return;
const recover = petMouseInteropRecovery.get(window);
if (recover) {
recover(reason);
return;
}
debug("pet.window", "mouse interop recovery skipped", { windowId: window.id, reason, skippedReason: "unregistered-window" });
}
function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void }): void {
const webContents = window.webContents;
const handleContextMenu = (event: Electron.Event): void => {
event.preventDefault();
if (window.isDestroyed()) return;
Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window });
};
webContents.on("context-menu", handleContextMenu);
window.once("closed", () => {
if (!webContents.isDestroyed()) webContents.off("context-menu", handleContextMenu);
});
}
function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed?: (dismissToken: string) => void): void {
let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null;
let rendererReady = false;
let listenersRemoved = false;
let lastInteractive = false;
let forwardingWatchTimer: NodeJS.Timeout | null = null;
const rearmTimers = new Set<NodeJS.Timeout>();
const windowId = window.id;
const webContents = window.webContents;
const canForwardMouseEvents = process.platform === "darwin" || process.platform === "win32";
const scheduleMouseInteropRecovery = (reason: string): void => {
if (window.isDestroyed()) return;
dragging = null;
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("pet.window", "mouse interop recovery", { windowId, reason });
setPassthrough(false);
if (process.platform === "win32") {
requestCursorHitTestProbe(reason);
scheduleWindowsMouseForwardingRearm(`${reason}+250ms`, 250);
scheduleWindowsMouseForwardingRearm(`${reason}+500ms`, 500);
scheduleWindowsMouseForwardingRearm(`${reason}+1000ms`, 1_000);
scheduleWindowsMouseForwardingRearm(`${reason}+1500ms`, 1_500);
return;
}
rearmPassthrough(reason);
};
const isFromWindow = (event: IpcMainEvent): boolean => event.sender === webContents;
const setPassthrough = (passthrough: boolean): void => {
if (window.isDestroyed()) return;
if (process.platform === "linux") {
// Electron does not support forwarded mouse events on Linux, so ignored
// windows cannot receive the renderer events required to start dragging.
// Keep Linux pet windows interactive; this trades click-through for reliable drag.
window.setIgnoreMouseEvents(false);
return;
}
if (passthrough && canForwardMouseEvents) window.setIgnoreMouseEvents(true, { forward: true });
else if (passthrough) window.setIgnoreMouseEvents(true);
else window.setIgnoreMouseEvents(false);
};
const clearRearmTimers = (): void => {
for (const timer of rearmTimers) clearTimeout(timer);
rearmTimers.clear();
};
const clearWindowsForwardingWatch = (): void => {
if (!forwardingWatchTimer) return;
clearTimeout(forwardingWatchTimer);
forwardingWatchTimer = null;
};
const getCursorProbe = (): { readonly inside: boolean; readonly cursor: Point; readonly bounds: Electron.Rectangle; readonly clientX: number; readonly clientY: number } => {
const cursor = screen.getCursorScreenPoint();
const bounds = window.getContentBounds();
const clientX = cursor.x - bounds.x;
const clientY = cursor.y - bounds.y;
return {
cursor,
bounds,
clientX,
clientY,
inside: clientX >= 0 && clientX < bounds.width && clientY >= 0 && clientY < bounds.height,
};
};
const requestCursorHitTestProbe = (reason: string, logProbe = true): void => {
if (window.isDestroyed() || webContents.isDestroyed()) return;
const probe = getCursorProbe();
if (logProbe) debug("pet.window", "cursor hit-test probe", { windowId, reason, inside: probe.inside, cursor: probe.cursor, bounds: probe.bounds });
if (!probe.inside) return;
webContents.send("openpets:pet-probe-hit-test", { clientX: probe.clientX, clientY: probe.clientY, reason });
};
const rearmWindowsMouseForwarding = (reason: string, logRearm = true): void => {
if (window.isDestroyed()) return;
if (dragging || lastInteractive) {
if (logRearm) debug("pet.window", "windows mouse forwarding rearm skipped", { windowId, reason, dragging: Boolean(dragging), interactive: lastInteractive });
return;
}
if (logRearm) debug("pet.window", "windows mouse forwarding rearm", { windowId, reason });
window.setIgnoreMouseEvents(false);
window.setIgnoreMouseEvents(true, { forward: true });
requestCursorHitTestProbe(reason, logRearm);
};
const scheduleWindowsMouseForwardingRearm = (reason: string, delayMs: number): void => {
const timer = setTimeout(() => {
rearmTimers.delete(timer);
rearmWindowsMouseForwarding(reason);
}, delayMs);
rearmTimers.add(timer);
};
const scheduleWindowsForwardingWatch = (reason: string): void => {
if (process.platform !== "win32" || forwardingWatchTimer || dragging || lastInteractive || window.isDestroyed()) return;
forwardingWatchTimer = setTimeout(() => {
forwardingWatchTimer = null;
if (window.isDestroyed() || dragging || lastInteractive) return;
if (getCursorProbe().inside) rearmWindowsMouseForwarding(reason, false);
scheduleWindowsForwardingWatch(reason);
}, 750);
forwardingWatchTimer.unref?.();
};
const rearmPassthrough = (reason: string): void => {
if (window.isDestroyed()) return;
if (process.platform !== "win32") {
setPassthrough(true);
return;
}
// On Windows, rapid pet HTML reloads can leave Chromium's forwarded mouse
// tracking stale while the cursor is already over the transparent window.
// Toggle immediately, probe the current cursor hit target, then repeat the
// toggle shortly after load because Windows sometimes re-registers mouse
// forwarding after Chromium finishes late compositing work.
rearmWindowsMouseForwarding(reason);
scheduleWindowsMouseForwardingRearm(`${reason}+75ms`, 75);
scheduleWindowsMouseForwardingRearm(`${reason}+175ms`, 175);
};
const rearmPassthroughAfterLoad = (): void => {
rearmPassthrough("did-finish-load");
};
const handleHitTest = (event: IpcMainEvent, interactive: unknown, source: unknown): void => {
if (!isFromWindow(event)) return;
rendererReady = true;
lastInteractive = Boolean(interactive);
const sourceName = typeof source === "string" ? source : undefined;
if (sourceName !== "idle-forwarding-watch" || lastInteractive) debug("pet.window", "hit test", { windowId, interactive: lastInteractive, dragging, source: sourceName });
setPassthrough(!lastInteractive && !dragging);
if (lastInteractive || dragging) clearWindowsForwardingWatch();
else scheduleWindowsForwardingWatch("idle-forwarding-watch");
};
const handleReady = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
rendererReady = true;
setPassthrough(true);
};
const handleDragStart = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !isScreenPoint(point) || window.isDestroyed()) return;
const [startWindowX, startWindowY] = window.getPosition();
dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX, startWindowY };
debug("pet.window", "drag start", { windowId, point, startWindowX, startWindowY });
clearWindowsForwardingWatch();
setPassthrough(false);
};
const handleDragMove = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !dragging || !isScreenPoint(point) || window.isDestroyed()) return;
window.setPosition(dragging.startWindowX + Math.round(point.screenX - dragging.startScreenX), dragging.startWindowY + Math.round(point.screenY - dragging.startScreenY), false);
};
const handleDragEnd = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
dragging = null;
debug("pet.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
};
const handleBubbleDismissed = (event: IpcMainEvent, dismissToken: unknown): void => {
if (!isFromWindow(event)) return;
debug("pet.window", "bubble dismissed", { windowId, dismissToken });
if (typeof dismissToken === "string") onBubbleDismissed?.(dismissToken);
};
const resetForNavigation = (): void => {
dragging = null;
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("pet.window", "navigation reset passthrough", { windowId });
setPassthrough(false);
};
const rearmAfterLoad = (): void => {
dragging = null;
lastInteractive = false;
debug("pet.window", "load rearm passthrough", { windowId });
rearmPassthroughAfterLoad();
};
const handleDomReady = (): void => {
if (!rendererReady) setPassthrough(true);
};
const handleLoadFailure = (): void => {
dragging = null;
lastInteractive = false;
debug("pet.window", "load failure rearm passthrough", { windowId });
setPassthrough(true);
};
const removeListeners = (): void => {
if (listenersRemoved) return;
listenersRemoved = true;
ipcMain.off("openpets:pet-ready", handleReady);
ipcMain.off("openpets:pet-hit-test", handleHitTest);
ipcMain.off("openpets:pet-drag-start", handleDragStart);
ipcMain.off("openpets:pet-drag-move", handleDragMove);
ipcMain.off("openpets:pet-drag-end", handleDragEnd);
ipcMain.off("openpets:bubble-dismissed", handleBubbleDismissed);
clearRearmTimers();
clearWindowsForwardingWatch();
petMouseInteropRecovery.delete(window);
if (!webContents.isDestroyed()) {
webContents.off("did-start-navigation", resetForNavigation);
webContents.off("did-start-loading", resetForNavigation);
webContents.off("did-finish-load", rearmAfterLoad);
webContents.off("dom-ready", handleDomReady);
webContents.off("did-fail-load", handleLoadFailure);
}
};
petMouseInteropRecovery.set(window, scheduleMouseInteropRecovery);
ipcMain.on("openpets:pet-ready", handleReady);
ipcMain.on("openpets:pet-hit-test", handleHitTest);
ipcMain.on("openpets:pet-drag-start", handleDragStart);
ipcMain.on("openpets:pet-drag-move", handleDragMove);
ipcMain.on("openpets:pet-drag-end", handleDragEnd);
ipcMain.on("openpets:bubble-dismissed", handleBubbleDismissed);
webContents.on("did-start-navigation", resetForNavigation);
webContents.on("did-start-loading", resetForNavigation);
webContents.on("did-finish-load", rearmAfterLoad);
webContents.on("dom-ready", handleDomReady);
webContents.on("did-fail-load", handleLoadFailure);
window.on("close", removeListeners);
window.once("closed", removeListeners);
}
function isScreenPoint(value: unknown): value is { readonly screenX: number; readonly screenY: number } {
return typeof value === "object" && value !== null && typeof (value as { readonly screenX?: unknown }).screenX === "number" && typeof (value as { readonly screenY?: unknown }).screenY === "number";
}
function createBasePetWindow(title: string, position: Point): BrowserWindow {
const window = new BrowserWindow({
title,
width: defaultPetWindowSize.width,
height: defaultPetWindowSize.height,
x: position.x,
y: position.y,
frame: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
hasShadow: false,
backgroundColor: "#00000000",
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: join(app.getAppPath(), "pet-preload.cjs"),
},
});
window.setMenu(null);
applyPetAlwaysOnTop(window);
window.on("show", () => applyPetAlwaysOnTop(window));
window.on("restore", () => applyPetAlwaysOnTop(window));
// Show the pet window on all macOS Spaces (desktop workspaces).
// Without this, the window is bound to the Space where it was created
// and disappears when the user switches to another Space.
if (process.platform === "darwin") {
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
if (isAllowedPetDocumentUrl(url)) return;
event.preventDefault();
});
window.webContents.on("will-redirect", (event) => {
event.preventDefault();
});
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => {
logError("pet.window", "renderer load failed", { windowId: window.id, errorCode, errorDescription });
console.error("Failed to load default pet window.", { errorCode, errorDescription });
});
window.webContents.on("render-process-gone", (_event, details) => {
logError("pet.window", "renderer process gone", { windowId: window.id, details });
console.error("Default pet renderer process gone.", details);
});
return window;
}
function applyPetAlwaysOnTop(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, process.platform === "linux" ? "screen-saver" : "floating");
if (process.platform === "linux") {
window.setVisibleOnAllWorkspaces(true);
}
}
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string): Promise<void> {
const sequence = allocateWindowLoadSequence(window);
debug("pet.window", "default content render begin", { windowId: window.id, sequence, paused, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), badge, defaultPetId: getAppStateSnapshot().preferences.defaultPetId });
const render = await createDefaultPetRender(paused, display, badge, dismissToken);
if (tryUpdateLoadedPetContent(window, render, "default", sequence)) return;
await loadPetHtmlFile(window, render.html, "default", sequence).then(() => {
petWindowRenderCache.set(window, render.cacheKey);
}).catch((error: unknown) => {
logError("pet.window", "default content load failed", error instanceof Error ? error : { error });
console.error("Failed to load default pet URL.", error);
});
}
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string, scaleOverride?: PetScaleValue): Promise<void> {
const sequence = allocateWindowLoadSequence(window);
try {
const state = getAppStateSnapshot();
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
if (!pet || pet.broken || pet.id === builtInPet.id) {
throw new Error(`Cannot render explicit pet: ${petId}`);
}
debug("pet.window", "explicit content render begin", { windowId: window.id, sequence, petId, displayName: pet.displayName, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), badge });
const scale = scaleOverride ?? state.preferences.petScale as PetScaleValue;
const render = await createInstalledPetRender(pet.id, pet.displayName, false, display, scale, badge, `explicit:${pet.id}`, dismissToken);
if (tryUpdateLoadedPetContent(window, render, `explicit-${pet.id}`, sequence)) return;
await loadPetHtmlFile(window, render.html, `explicit-${pet.id}`, sequence);
petWindowRenderCache.set(window, render.cacheKey);
} catch (error: unknown) {
logError("pet.window", "explicit content load failed", error instanceof Error ? error : { petId, error });
console.error(`Failed to load explicit pet ${petId} URL.`, error);
}
}
export function preparePetTransientDisplay(display: PetTransientDisplay): PetTransientDisplay {
if (!display.reaction || display.message || display.reactionMessage) return display;
return { ...display, reactionMessage: pickReactionMessage(display.reaction) };
}
export function mergePetTransientDisplay(current: PetTransientDisplay | null, next: PetTransientDisplay): PetTransientDisplay {
if (next.message || !next.reaction || !current?.message) return preparePetTransientDisplay(next);
return { ...current, reaction: next.reaction, dismissToken: next.dismissToken ?? current.dismissToken };
}
export function getTransientReactionAnimationMs(display: PetTransientDisplay): number | null {
if (!display.reaction) return null;
const state = getReactionSpriteState(display.reaction);
const row = defaultPetSprite.states[state];
const iterations = "iterations" in row ? row.iterations : "infinite";
return typeof iterations === "number" ? row.durationMs * iterations : null;
}
export function getTransientDisplayDurationMs(display: PetTransientDisplay): number {
const baseMs = display.reaction === "success" || display.reaction === "error" ? 5_000 : 4_000;
const message = display.message ?? display.reactionMessage;
if (!message) return baseMs;
return Math.min(12_000, Math.max(baseMs, message.length * 70));
}
export function clearTransientReaction(display: PetTransientDisplay): PetTransientDisplay {
if (!display.reaction) return display;
return { ...display, reaction: undefined };
}
export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void {
if (window.isDestroyed()) return;
window.webContents.send("openpets:pet-reaction-state", state);
}
function tryUpdateLoadedPetContent(window: BrowserWindow, render: PetContentRender, name: string, sequence: number): boolean {
if (window.isDestroyed() || window.webContents.isDestroyed()) return false;
if (petWindowRenderCache.get(window) !== render.cacheKey) return false;
const url = window.webContents.getURL();
if (!isAllowedPetDocumentUrl(url)) return false;
debug("pet.window", "content update in place", { windowId: window.id, name, sequence, reactionState: render.reactionState });
window.webContents.send("openpets:pet-content-state", { bodyHtml: render.bodyHtml, reactionState: render.reactionState });
return true;
}
export function getSafeDefaultPetPosition(position: Point | undefined): Point {
return clampToPrimaryWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize);
}
export function readWindowPosition(window: BrowserWindow): Point {
const [x, y] = window.getPosition();
return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize);
}
async function createDefaultPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null, dismissToken?: string): Promise<PetContentRender> {
const installedPetRender = await tryCreateInstalledPetRender(paused, display, badge, dismissToken);
if (installedPetRender) {
return installedPetRender;
}
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
const bodyHtml = createPetBodyMarkup("OpenPets default pet", createBubbleMarkup(display, paused, badge, dismissToken), `<div class="sprite" role="img" aria-label="Claude animated default pet"></div>`);
const reactionState = getReactionSpriteState(display?.reaction);
const stateRows = defaultPetSprite.states;
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
return {
cacheKey: `default:builtin:${paused}:${scale}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="en" data-reaction-state="${reactionState}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<style>
${createPetWindowCss(paused, scale)}
.sprite {
width: ${defaultPetSprite.frameWidth}px;
height: ${defaultPetSprite.frameHeight}px;
background-image: url("${escapeCssUrl(spriteUrl)}");
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
background-repeat: no-repeat;
--sprite-row-y: 0px;
--sprite-frames: ${stateRows.idle.frames};
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".sprite")}
@keyframes pet-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`,
};
}
async function tryCreateInstalledPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null, dismissToken?: string): Promise<PetContentRender | null> {
const state = getAppStateSnapshot();
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
if (!selected || selected.id === builtInPet.id || selected.broken) {
return null;
}
try {
return await createInstalledPetRender(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge, `default:${selected.id}`, dismissToken);
} catch (error) {
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
try {
markPetBroken(selected.id, error instanceof Error ? error.message : "Installed pet rendering failed.");
} catch (markError) {
console.error(`Failed to mark installed pet ${selected.id} broken.`, markError);
}
return null;
}
}
async function createInstalledPetRender(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null, cachePrefix: string, dismissToken?: string): Promise<PetContentRender> {
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
throw new Error("Installed pet spritesheet is missing or too large.");
}
const imageUrl = pathToFileURL(spritesheetPath).toString();
const bodyHtml = createPetBodyMarkup(escapeHtml(displayName), createBubbleMarkup(display, paused, badge, dismissToken), `<div class="installed-card" role="img" aria-label="${escapeHtml(displayName)}"><div class="installed-sprite"></div></div>`);
const reactionState = getReactionSpriteState(display?.reaction);
const stateRows = defaultPetSprite.states;
return {
cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}`,
bodyHtml,
reactionState,
html: `<!doctype html>
<html lang="en" data-reaction-state="${reactionState}" data-motion-state="idle">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenPets Default Pet</title>
<style>
${createPetWindowCss(paused, scale)}
.installed-card { width: ${Math.ceil(defaultPetSprite.frameWidth * scale)}px; height: ${Math.ceil(defaultPetSprite.frameHeight * scale)}px; overflow: visible; position: relative; }
.installed-sprite {
position: absolute;
left: 0;
top: 0;
width: ${defaultPetSprite.frameWidth}px;
height: ${defaultPetSprite.frameHeight}px;
background-image: url("${escapeCssUrl(imageUrl)}");
background-size: ${defaultPetSprite.frameWidth * defaultPetSprite.columns}px ${defaultPetSprite.frameHeight * defaultPetSprite.rows}px;
background-repeat: no-repeat;
--sprite-row-y: 0px;
--sprite-frames: ${stateRows.idle.frames};
--sprite-duration: ${stateRows.idle.durationMs}ms;
--sprite-iterations: ${stateRows.idle.iterations};
background-position: 0 var(--sprite-row-y);
animation: pet-frames var(--sprite-duration) steps(var(--sprite-frames)) var(--sprite-iterations);
animation-play-state: var(--play-state);
transform: scale(${scale});
transform-origin: top left;
}
${createSpriteStateCss(".installed-sprite")}
@keyframes pet-frames {
from { background-position: 0 var(--sprite-row-y); }
to { background-position: calc(-${defaultPetSprite.frameWidth}px * var(--sprite-frames)) var(--sprite-row-y); }
}
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`,
};
}
function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: string): string {
return `<div class="stage" aria-label="${stageLabel}">
${bubble}
<div class="pet-hitbox" aria-hidden="true">
<div class="pet-shell">
${spriteMarkup}
</div>
</div>
</div>`;
}
function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
const opacity = paused ? "0.62" : "1";
const playState = paused ? "paused" : "running";
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
const petBottom = 22;
const hitPadding = 18;
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
const petShellFilter = process.platform === "win32" ? "none" : "drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18))";
const bubbleBackdropFilter = process.platform === "win32" ? "none" : "blur(10px)";
return `
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
html { color: #172033; }
body { -webkit-app-region: no-drag; pointer-events: none; }
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
.pet-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.pet-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: ${petShellFilter}; transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(220px, calc(100vw - 18px)); max-height: 128px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
.bubble[data-dismiss-token] { cursor: pointer; }
.bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); }
.bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; }
.bubble-status-icon { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 18px; width: 18px; min-width: 18px; height: 18px; border-radius: 999px; background: #3b82f6; color: #fff; font: 900 12px/18px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: center; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.3); }
.bubble-status-icon::before { content: attr(data-icon); display: block; width: 18px; height: 18px; line-height: 18px; text-align: center; transform: none; }
.bubble-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bubble-divider { height: 1px; width: 100%; margin: 8px 0; background: rgba(30, 58, 138, 0.12); }
.bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.bubble-text { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-wrap: normal; overflow-wrap: break-word; }
.bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; }
.bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; }
.bubble.is-message-only { border-radius: 14px 14px 3px 14px; }
.bubble.is-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 138px; }
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 6; font-size: 10px; line-height: 13px; }
.bubble.is-very-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 156px; }
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 8; font-size: 9.5px; line-height: 12.5px; }
.bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); }
.bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); }
.bubble.is-success .bubble-status-icon { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); }
.bubble.is-error .bubble-status-icon { background: #ef4444; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(239, 68, 68, 0.34); }
.bubble.is-info .bubble-status-icon { background: #38bdf8; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(56, 189, 248, 0.34); }
.bubble.is-busy .bubble-status-icon::before { content: ""; position: absolute; inset: 0; width: 18px; height: 18px; background: radial-gradient(circle at 50% 50%, #fff 0 4px, transparent 4.5px); animation: status-pulse 820ms ease-in-out infinite; }
.bubble.is-waiting .bubble-status-icon::before { content: ""; position: absolute; left: 3px; top: 3px; box-sizing: border-box; width: 12px; height: 12px; border: 2px solid rgba(255, 255, 255, 0.96); border-top-color: rgba(255, 255, 255, 0.28); border-radius: 999px; }
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.96); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
@keyframes status-pulse { 0%, 100% { opacity: 0.52; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } }
`;
}
function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string {
const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState));
const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>)
.filter(([motion]) => motion !== "idle")
.map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state));
return [...reactionRules, ...motionRules].join("\n");
}
function createSpriteRule(selector: string, state: UniversalSpriteState): string {
const row = defaultPetSprite.states[state];
const iterations = "iterations" in row ? row.iterations : "infinite";
return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`;
}
function getReactionSpriteState(reaction: OpenPetsReaction | undefined): UniversalSpriteState {
return resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides);
}
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null, dismissToken?: string): string {
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
const status = !paused && badgeReaction ? getStatusBadge(badgeReaction) : null;
if (!text && !status) return "";
const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage);
const className = getBubbleClassName(text, isExplicitMessage, status?.className);
const header = status ? `<div class="bubble-header"><span class="bubble-status-icon" data-icon="${escapeHtml(status.icon)}" aria-hidden="true"></span><span class="bubble-status-label">${escapeHtml(status.label)}</span></div>` : "";
const divider = status && text ? `<div class="bubble-divider" aria-hidden="true"></div>` : "";
const body = text ? `<div class="bubble-body"><span class="bubble-text">${escapeHtml(text)}</span></div>` : "";
// Use provided dismissToken, fallback to display's dismissToken for transient messages
const token = dismissToken ?? display?.dismissToken;
const dismissAttr = token ? ` data-dismiss-token="${escapeHtml(token)}"` : "";
return `<div class="${className}" role="status" aria-live="polite"${dismissAttr}>${header}${divider}${body}</div>`;
}
function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon: string; readonly label: string } | null {
if (reaction === "thinking") return { className: "is-busy", icon: "", label: "Thinking" };
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: "Working" };
if (reaction === "editing") return { className: "is-busy", icon: "", label: "Editing" };
if (reaction === "testing") return { className: "is-busy", icon: "", label: "Testing" };
if (reaction === "waiting") return { className: "is-waiting", icon: "", label: "Waiting" };
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", icon: "✓", label: "Done" };
if (reaction === "error") return { className: "is-error", icon: "!", label: "Oops" };
if (reaction === "waving") return { className: "is-info", icon: "♪", label: "Hi" };
return null;
}
function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined): string {
const statusClass = statusClassName ? ` ${statusClassName}` : "";
if (!text) return `bubble is-status-only${statusClass}`;
if (!statusClassName) return `bubble is-message-only${isExplicitMessage ? getBubbleLengthClass(text) : ""}`;
const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
return `bubble is-message${statusClass}${lengthClass}`;
}
function getBubbleLengthClass(text: string): string {
return text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeCssUrl(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "");
}
function installMotionStatePublisher(window: BrowserWindow): void {
let lastX = window.getPosition()[0];
let lastSent: PetMotionState = "idle";
let idleTimer: NodeJS.Timeout | null = null;
const sendMotionState = (state: PetMotionState): void => {
if (window.isDestroyed() || lastSent === state) return;
lastSent = state;
window.webContents.send("openpets:pet-motion", state);
};
const scheduleIdle = (): void => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
idleTimer = null;
sendMotionState("idle");
}, 180);
};
const handleMove = (): void => {
if (window.isDestroyed()) return;
const [x] = window.getPosition();
const deltaX = x - lastX;
lastX = x;
if (Math.abs(deltaX) >= 3) {
sendMotionState(deltaX > 0 ? "run-right" : "run-left");
}
scheduleIdle();
};
window.on("move", handleMove);
window.on("moved", handleMove);
window.webContents.on("did-finish-load", () => {
lastSent = "idle";
window.webContents.send("openpets:pet-motion", "idle");
});
window.on("closed", () => {
if (idleTimer) clearTimeout(idleTimer);
});
}
function isAllowedPetDocumentUrl(url: string): boolean {
return url.startsWith("data:text/html") || url.startsWith("file://");
}
function allocateWindowLoadSequence(window: BrowserWindow): number {
const sequence = (windowLoadSequences.get(window) ?? 0) + 1;
windowLoadSequences.set(window, sequence);
return sequence;
}
async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string, sequence: number): Promise<void> {
const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet";
const previous = windowLoadChains.get(window) ?? Promise.resolve();
const next = previous.catch(() => {}).then(async () => {
if (window.isDestroyed()) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed" });
return;
}
if (windowLoadSequences.get(window) !== sequence) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, latestSequence: windowLoadSequences.get(window), reason: "superseded" });
return;
}
const dir = join(app.getPath("userData"), "rendered-pets");
await mkdir(dir, { recursive: true });
const filePath = join(dir, `${safeName}.html`);
await writeFile(filePath, html, "utf8");
if (window.isDestroyed()) {
debug("pet.window", "load skipped", { windowId: window.id, name: safeName, sequence, reason: "destroyed-after-write" });
return;
}
debug("pet.window", "load file begin", { windowId: window.id, name: safeName, sequence, filePath });
window.setIgnoreMouseEvents(false);
try {
await window.loadFile(filePath);
debug("pet.window", "load file complete", { windowId: window.id, name: safeName, sequence, url: window.webContents.getURL() });
} catch (error) {
if (!window.isDestroyed()) {
if (process.platform === "linux") window.setIgnoreMouseEvents(false);
else window.setIgnoreMouseEvents(true, { forward: true });
}
logError("pet.window", "load file rejected", error instanceof Error ? error : { windowId: window.id, name: safeName, sequence, error });
throw error;
}
});
windowLoadChains.set(window, next);
void next.catch(() => {}).finally(() => {
if (windowLoadChains.get(window) === next) windowLoadChains.delete(window);
});
return next;
}
function debounce(callback: () => void, delayMs: number): () => void {
let timeout: NodeJS.Timeout | undefined;
return () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(callback, delayMs);
};
}