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; interface PetContentRender { readonly html: string; readonly bodyHtml: string; readonly reactionState: UniversalSpriteState; readonly cacheKey: string; } const petWindowRenderCache = new WeakMap(); const windowLoadChains = new WeakMap>(); const windowLoadSequences = new WeakMap(); const petMouseInteropRecovery = new WeakMap 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(); 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 { 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 { 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 { 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), ``); 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: ` OpenPets Default Pet ${bodyHtml} `, }; } async function tryCreateInstalledPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null, dismissToken?: string): Promise { 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 { 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), ``); const reactionState = getReactionSpriteState(display?.reaction); const stateRows = defaultPetSprite.states; return { cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}`, bodyHtml, reactionState, html: ` OpenPets Default Pet ${bodyHtml} `, }; } function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: string): string { return `
${bubble}
`; } 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 ? `
${escapeHtml(status.label)}
` : ""; const divider = status && text ? `` : ""; const body = text ? `
${escapeHtml(text)}
` : ""; // 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 `
${header}${divider}${body}
`; } 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 { 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); }; }