Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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))
Expand Down
72 changes: 71 additions & 1 deletion apps/desktop/electron/zoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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\)/)
})
19 changes: 19 additions & 0 deletions apps/desktop/electron/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
}
Loading