mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
commit
1318cd9b0d
3 changed files with 115 additions and 6 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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\)/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue