diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b8c2d03da38..1771538038f 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4743,7 +4743,13 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' +import { + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_STORAGE_KEY, + zoomLevelToPercent +} from './zoom' function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) { @@ -6507,10 +6513,21 @@ async function startHermes() { // security posture: external links open in the OS browser, in-app navigation // stays confined to the dev server / packaged file URL, and the preview / // devtools / zoom / context-menu affordances behave identically everywhere. -function wireCommonWindowHandlers(win) { +// +// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the +// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own +// Alt+wheel scale, so inheriting the global UI zoom would render the mascot +// larger than its window and crop it. Chat windows keep zoom on. +function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) { installPreviewShortcut(win) installDevToolsShortcut(win) - installZoomShortcuts(win) + if (zoom) { + installZoomShortcuts(win) + // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on + // minimize/restore) and on first load (reloads / crash recovery). + installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) + win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win)) + } installContextMenu(win) win.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) @@ -6702,7 +6719,9 @@ function spawnPetOverlayWindow(bounds) { // Not supported everywhere — best effort. } - wireCommonWindowHandlers(win) + // Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it + // owns its window-fit + scale, and inheriting zoom would crop the sprite. + wireCommonWindowHandlers(win, { zoom: false }) win.once('ready-to-show', () => { if (!win.isDestroyed()) { @@ -6893,7 +6912,8 @@ function createWindow() { } mainWindow.webContents.once('did-finish-load', () => { - restorePersistedZoomLevel(mainWindow) + // Zoom restore is handled by wireCommonWindowHandlers (shared with session + // windows); no need to reapply it here. broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 0636c8c52e2..8be69e813a3 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -5,9 +5,19 @@ */ import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' import test from 'node:test' +import { fileURLToPath } from 'node:url' -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' +import { + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_REASSERT_WINDOW_EVENTS, + ZOOM_STORAGE_KEY, + zoomLevelToPercent +} from './zoom' test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') @@ -53,3 +63,63 @@ test('extreme percentages clamp to the level bounds', () => { assert.equal(percentToZoomLevel(1), -9) assert.equal(percentToZoomLevel(1_000_000), 9) }) + +test('installZoomReassertOnWindowEvents wires show and restore', () => { + const handlers = new Map() + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS]) + handlers.get('show')() + handlers.get('restore')() + assert.equal(calls, 2) +}) + +test('installZoomReassertOnWindowEvents skips destroyed windows', () => { + const handlers = new Map() + let destroyed = false + const win = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + destroyed = true + handlers.get('show')() + assert.equal(calls, 0) +}) + +// Source assertion (see windows-child-process.test.ts for the established +// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy +// Electron deps, so we assert the wiring contract against source rather than +// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI +// zoom while chat windows keep it — the whole reason this fix is scoped. +test('pet overlay opts out of global UI zoom; chat windows keep it', () => { + const electronDir = path.dirname(fileURLToPath(import.meta.url)) + const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') + + // The shared helper gates all zoom wiring behind an opt-out flag. + assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/) + + // The pet overlay window is the only caller that disables zoom. + assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/) + + // Zoom restore now flows through the shared helper, so createWindow must not + // reassert it directly (that would double-fire and drift from session windows). + const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'") + assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler') + const snippet = source.slice(finishLoad, finishLoad + 300) + assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/) +}) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index f3518f583d6..31182ea64d9 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -31,3 +31,22 @@ export function percentToZoomLevel(percent) { return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) } + +// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized +// and restored. Re-apply the persisted level on these lifecycle transitions. +export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore'] + +export function installZoomReassertOnWindowEvents(win, reassert) { + if (!win?.on) { + return + } + + for (const event of ZOOM_REASSERT_WINDOW_EVENTS) { + win.on(event, () => { + if (win.isDestroyed?.()) { + return + } + reassert() + }) + } +}