890 lines
45 KiB
TypeScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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);
|
|
};
|
|
}
|