Merge pull request #61925 from NousResearch/bb/salvage-61245-ui-zoom

fix(desktop): re-apply UI zoom on show/restore, scoped to chat windows (supersedes #61245)
This commit is contained in:
brooklyn! 2026-07-10 02:14:56 -05:00 committed by GitHub
commit 1318cd9b0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 115 additions and 6 deletions

View file

@ -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))

View file

@ -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\)/)
})

View file

@ -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()
})
}
}