Merge pull request #63995 from NousResearch/bb/salvage-63842-zoom-restore-sync

fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)
This commit is contained in:
brooklyn! 2026-07-13 17:43:42 -04:00 committed by GitHub
commit 2a25d53ee5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 63 additions and 8 deletions

View file

@ -4705,7 +4705,7 @@ function installPreviewShortcut(window) {
// 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,
applyZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_STORAGE_KEY,
@ -4718,11 +4718,9 @@ function setAndPersistZoomLevel(window, zoomLevel) {
return
}
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
// Keep any open settings UI in sync, including changes made via the
// keyboard shortcuts or the View menu.
window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) })
// Apply + notify in one funnel so the settings UI stays in sync, including
// changes made via the keyboard shortcuts or the View menu.
const next = applyZoomLevel(window.webContents, zoomLevel)
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@ -4744,8 +4742,9 @@ function restorePersistedZoomLevel(window) {
return
}
const level = clampZoomLevel(Number(stored))
window.webContents.setZoomLevel(level)
// Notify the renderer too — otherwise the Appearance UI Scale control
// can stay stuck at 100% even though the window zoom was restored.
applyZoomLevel(window.webContents, Number(stored))
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}

View file

@ -9,6 +9,7 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
@ -118,3 +119,42 @@ test('unknown window kinds default to chat (zoom enabled)', () => {
assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true })
assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true })
})
// The UI Scale settings control drifts out of sync after a restart when zoom
// is applied to the window but the renderer is never told: its $zoomPercent
// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and
// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom
// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a
// level always notifies — the regression can't come back by forgetting a send.
function fakeWebContents() {
const calls: Array<[string, ...unknown[]]> = []
return {
calls,
setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]),
send: (channel: string, payload: unknown) => calls.push(['send', channel, payload])
}
}
test('applyZoomLevel applies the level then notifies the renderer', () => {
const wc = fakeWebContents()
const applied = applyZoomLevel(wc, 3)
assert.equal(applied, 3)
assert.deepEqual(wc.calls, [
['setZoomLevel', 3],
['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }]
])
})
test('applyZoomLevel clamps garbage before applying and notifying', () => {
const wc = fakeWebContents()
const applied = applyZoomLevel(wc, 999)
const clamped = clampZoomLevel(999)
assert.equal(applied, clamped)
assert.deepEqual(wc.calls, [
['setZoomLevel', clamped],
['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }]
])
})

View file

@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) {
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
}
/**
* Apply a clamped zoom level to a webContents AND notify the renderer, in that
* order. Every path that changes zoom (user action, restore-on-load, lifecycle
* re-assert) funnels through here so the settings UI Scale control can never
* drift from the actually-applied level the bug where restore set the level
* but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%.
* Returns the clamped level so callers can persist it.
*/
export function applyZoomLevel(webContents, level) {
const clamped = clampZoomLevel(level)
webContents.setZoomLevel(clamped)
webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) })
return clamped
}
// 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']