diff --git a/apps/desktop/pet-preload.cjs b/apps/desktop/pet-preload.cjs index dd21c600..c2da0d8b 100644 --- a/apps/desktop/pet-preload.cjs +++ b/apps/desktop/pet-preload.cjs @@ -37,6 +37,23 @@ ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => { } }); +ipcRenderer.on("openpets:pet-content-state", (_event, state) => { + if (!state || typeof state.bodyHtml !== "string" || state.bodyHtml.length > 64 * 1024 || !allowedReactionStates.has(state.reactionState)) { + return; + } + + const apply = () => { + document.documentElement.dataset.reactionState = state.reactionState; + document.body.innerHTML = state.bodyHtml; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", apply, { once: true }); + } else { + apply(); + } +}); + const getInteractiveTarget = (event) => { const target = document.elementFromPoint(event.clientX, event.clientY); return target && target.closest(".pet-shell, .bubble"); diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 7687a045..1b22242c 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -138,7 +138,8 @@ assert.match(petWindowSource, /did-finish-load", rearmAfterLoad/, "pet windows m assert.match(petWindowSource, /did-fail-load", handleLoadFailure/, "pet windows must restore passthrough after failed content loads."); assert.match(petWindowSource, /window\.setIgnoreMouseEvents\(false\);[\s\S]*?await window\.loadFile/, "pet reloads must reset OS mouse passthrough before navigation."); assert.match(petWindowSource, /function allocateWindowLoadSequence/, "pet content reloads must allocate request sequence before async rendering."); -assert.match(petWindowSource, /loadPetHtmlFile\(window, html, "default", sequence\)/, "default pet reloads must preserve pre-render load sequence."); +assert.match(petWindowSource, /tryUpdateLoadedPetContent\(window, render, "default", sequence\)/, "default pet transient updates must avoid BrowserWindow reloads when the pet document is already loaded."); +assert.match(petPreloadSource, /openpets:pet-content-state[\s\S]*?document\.body\.innerHTML/, "pet preload must accept sanitized in-place content updates for transient bubbles and badges."); assert.match(petWindowSource, /windowLoadChains\.set\(window, next\)/, "pet content reloads must serialize loadFile calls per BrowserWindow."); assert.match(petWindowSource, /next\.catch\(\(\) => \{\}\)\.finally/, "pet content reload chain cleanup must not create unhandled rejections."); assert.match(petWindowSource, /destroyed-after-write/, "pet content reloads must re-check destroyed windows after writing HTML."); diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index a44601b1..445402f4 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -40,6 +40,13 @@ export type PetStatusBadgeReaction = Exclude; type PetMotionState = "idle" | "run-left" | "run-right"; type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review"; +interface PetContentRender { + readonly html: string; + readonly bodyHtml: string; + readonly reactionState: UniversalSpriteState; + readonly cacheKey: string; +} + const motionToSpriteState = { idle: "idle", "run-right": "running-right", @@ -60,6 +67,8 @@ const reactionToSpriteState = { celebrating: "jumping", } as const satisfies Record; +const petWindowRenderCache = new WeakMap(); + const defaultPetSprite = { fileName: "default-pet-spritesheet.webp", frameWidth: 192, @@ -389,8 +398,11 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void { export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): 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 html = await createDefaultPetHtml(paused, display, badge); - await loadPetHtmlFile(window, html, "default", sequence).catch((error: unknown) => { + const render = await createDefaultPetRender(paused, display, badge); + 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); }); @@ -405,8 +417,10 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin 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 html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue, badge); - await loadPetHtmlFile(window, html, `explicit-${pet.id}`, sequence); + const render = await createInstalledPetRender(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue, badge, `explicit:${pet.id}`); + 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); @@ -448,6 +462,16 @@ export function setPetReactionState(window: BrowserWindow, state: UniversalSprit 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); } @@ -457,19 +481,24 @@ export function readWindowPosition(window: BrowserWindow): Point { return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize); } -async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { - const installedPetHtml = await tryCreateInstalledPetHtml(paused, display, badge); - if (installedPetHtml) { - return installedPetHtml; +async function createDefaultPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { + const installedPetRender = await tryCreateInstalledPetRender(paused, display, badge); + if (installedPetRender) { + return installedPetRender; } const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString(); - const bubble = createBubbleMarkup(display, paused, badge); + const bodyHtml = createPetBodyMarkup("OpenPets default pet", createBubbleMarkup(display, paused, badge), ``); + const reactionState = getReactionSpriteState(display?.reaction); const stateRows = defaultPetSprite.states; const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue; - return ` - + return { + cacheKey: `default:builtin:${paused}:${scale}`, + bodyHtml, + reactionState, + html: ` + @@ -501,17 +530,13 @@ async function createDefaultPetHtml(paused: boolean, display: PetTransientDispla -
- ${bubble} -
- -
-
+ ${bodyHtml} - `; + `, + }; } -async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { +async function tryCreateInstalledPetRender(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise { const state = getAppStateSnapshot(); const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId); @@ -520,7 +545,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD } try { - return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge); + return await createInstalledPetRender(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge, `default:${selected.id}`); } catch (error) { console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error); try { @@ -532,7 +557,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD } } -async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null): Promise { +async function createInstalledPetRender(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null, cachePrefix: 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) { @@ -540,11 +565,16 @@ async function createInstalledPetHtml(petId: string, displayName: string, paused } const imageUrl = pathToFileURL(spritesheetPath).toString(); - const bubble = createBubbleMarkup(display, paused, badge); + const bodyHtml = createPetBodyMarkup(escapeHtml(displayName), createBubbleMarkup(display, paused, badge), ``); + const reactionState = getReactionSpriteState(display?.reaction); const stateRows = defaultPetSprite.states; - return ` - + return { + cacheKey: `${cachePrefix}:${paused}:${scale}:${spritesheet.mtimeMs}:${spritesheet.size}`, + bodyHtml, + reactionState, + html: ` + @@ -580,16 +610,19 @@ async function createInstalledPetHtml(petId: string, displayName: string, paused -
- ${bubble} -
- -
-
+ ${bodyHtml} - `; + `, + }; +} + +function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: string): string { + return `
+ ${bubble} +
+ ${spriteMarkup} +
+
`; } function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {