From ab239b5043b0148d119e37ed26f45b419d14d234 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:29:35 +0200 Subject: [PATCH] fix(shell): persist the app zoom level as frame-bound temporary zoom (#1109) (#1617) * fix(shell): persist and restore the app zoom level (#1109) The app-wide zoom (Cmd/Ctrl and +/-) was never saved, so it reset to the default on every restart. Save the webContents zoom level next to the window bounds on window close and before quit, and reapply it once the renderer finishes loading. webContents zoom is per-host, so the restored level then holds across in-app section navigation (SPA route changes never reload). * fix(shell): restore the zoom level as frame-bound temporary zoom (#1109) Under file:// Chromium keys zoom by the full URL, and the packaged renderer routes with pushState, so a level applied through webContents.setZoomLevel belongs to index.html only: the first resize after a section change snapped the renderer back to the default, and the close handler read the current route's entry (usually 0) over the user's choice. The preload now applies the persisted level with webFrame.setZoomLevel, a temporary zoom bound to the frame that survives in-page navigation and resizes; the main process hands it over through the synchronous WINDOW:GET_ZOOM_LEVEL IPC and writes the live level back on close, before-quit and before every cross-document navigation, since a reload drops the temporary level. Co-Authored-By: Claude Fable 5.1 * fix(shell): apply the restored zoom level at DOMContentLoaded (#1109) A webFrame.setZoomLevel at preload start left a hidden window without a first frame on Linux and Windows: ready-to-show never fired, the window never showed, and the renderer got no animation frames, so the startup splash removed in a requestAnimationFrame stayed. macOS was unaffected and CDP-driven tests force frames, which is why only the packaged legacy-migration E2E asserting the splash is gone caught it. Applying once the document is parsed is harmless and still lands before the first Angular paint; ownership of the level now follows the preload's WINDOW:ZOOM_LEVEL_APPLIED acknowledgement instead of the request. The release note no longer advertises Ctrl shortcuts Windows and Linux do not have. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Justin Willhite <5132924+thejdubb02@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .changes/shell-zoom-level-persistence.md | 9 + CLAUDE.md | 1 + .../src/window-zoom-level.e2e.ts | 114 +++++++++++++ .../src/app/api/main.preload.spec.ts | 37 ++++ .../src/app/api/main.preload.ts | 24 ++- .../src/app/api/preload-zoom-level.spec.ts | 72 ++++++++ .../src/app/api/preload-zoom-level.ts | 63 +++++++ .../src/app/app-window-state.spec.ts | 1 + apps/electron-backend/src/app/app.spec.ts | 126 ++++++++++++++ apps/electron-backend/src/app/app.ts | 25 ++- .../src/app/events/window.events.spec.ts | 58 +++++++ .../src/app/events/window.events.ts | 21 +++ .../src/app/services/store.service.ts | 3 + .../app/services/window-zoom-level.spec.ts | 160 ++++++++++++++++++ .../src/app/services/window-zoom-level.ts | 80 +++++++++ docs/architecture/workspace-shell.md | 54 ++++++ .../shared/interfaces/src/lib/ipc-commands.ts | 5 + 17 files changed, 849 insertions(+), 4 deletions(-) create mode 100644 .changes/shell-zoom-level-persistence.md create mode 100644 apps/electron-backend-e2e/src/window-zoom-level.e2e.ts create mode 100644 apps/electron-backend/src/app/api/preload-zoom-level.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-zoom-level.ts create mode 100644 apps/electron-backend/src/app/services/window-zoom-level.spec.ts create mode 100644 apps/electron-backend/src/app/services/window-zoom-level.ts diff --git a/.changes/shell-zoom-level-persistence.md b/.changes/shell-zoom-level-persistence.md new file mode 100644 index 000000000..b85a3451c --- /dev/null +++ b/.changes/shell-zoom-level-persistence.md @@ -0,0 +1,9 @@ +--- +type: fix +area: shell +issues: [1109] +--- + +The app now remembers your zoom level (Cmd and +/- on macOS): it holds when +you switch between sections or resize the window, and comes back after a +restart, so a larger font size no longer resets to the default. diff --git a/CLAUDE.md b/CLAUDE.md index 9ba37dbf2..0168e6922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -769,6 +769,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication - Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen +- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). Contract: `docs/architecture/workspace-shell.md`, "Zoom level" - Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: diff --git a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts new file mode 100644 index 000000000..e8a27c64e --- /dev/null +++ b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts @@ -0,0 +1,114 @@ +import { join } from 'path'; +import { + closeElectronApp, + expect, + launchElectronApp, + LaunchedElectronApp, + openSources, + restartElectronApp, + test, + workspaceRoot, +} from './electron-test-fixtures'; + +/** + * Chromium's zoom factor as the renderer actually renders it: the window's + * content width in device-independent pixels over the CSS pixels the page + * sees. Independent of `getZoomLevel()`, which under file:// reports the + * per-URL entry and can disagree with what is on screen. + */ +async function renderedZoomFactor(app: LaunchedElectronApp): Promise { + const contentWidth = await app.electronApp.evaluate(({ BrowserWindow }) => { + const [win] = BrowserWindow.getAllWindows(); + return win.getContentSize()[0]; + }); + const innerWidth = await app.mainWindow.evaluate(() => window.innerWidth); + return contentWidth / innerWidth; +} + +/** What the macOS menu roles (Cmd +/−) do: `webContents.zoomLevel += 1`. */ +async function zoomInFromMenu(app: LaunchedElectronApp): Promise { + await app.electronApp.evaluate(({ BrowserWindow }) => { + const [win] = BrowserWindow.getAllWindows(); + win.webContents.setZoomLevel(win.webContents.getZoomLevel() + 1); + }); +} + +/** + * A cross-document navigation of the renderer (what a reload is for zoom: + * Chromium drops the temporary level and the new document's preload must + * restore it). Loads the packaged index the way startup does — a plain + * `page.reload()` on a routed `file://` URL has no file behind it. + */ +async function reloadRenderer(app: LaunchedElectronApp): Promise { + await app.electronApp.evaluate(({ BrowserWindow }, indexPath) => { + const [win] = BrowserWindow.getAllWindows(); + return win.loadFile(indexPath); + }, join(workspaceRoot, 'dist/apps/web/index.html')); + await app.mainWindow.waitForSelector('app-root'); +} + +async function resizeWindowBy( + app: LaunchedElectronApp, + delta: number +): Promise { + await app.electronApp.evaluate(({ BrowserWindow }, step) => { + const [win] = BrowserWindow.getAllWindows(); + const [width, height] = win.getSize(); + win.setSize(width + step, height + step); + }, delta); +} + +// Zoom level 1 is a 1.2 factor. The packaged renderer runs under file:// with +// path routing, where Chromium keys zoom by full URL: without frame-bound +// (temporary) zoom the level "holds" only until the next resize after a +// section change, and a restart brings back the default (issue #1109). +const ZOOMED_FACTOR = 1.2; + +test('@electron @window keeps the zoom level across sections, resizes and a restart', async ({ + dataDir, +}) => { + let app = await launchElectronApp(dataDir); + + try { + expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1); + + await zoomInFromMenu(app); + await expect + .poll(() => renderedZoomFactor(app)) + .toBeCloseTo(ZOOMED_FACTOR, 1); + + // A route change (new file:// URL) followed by the visual-properties + // sync a resize forces: per-URL zoom snaps back to 1 here. + await openSources(app.mainWindow); + await resizeWindowBy(app, 24); + await app.mainWindow.waitForTimeout(500); + expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1); + + // A reload rebuilds the document; the level must be saved before it + // and re-applied by the new document's preload. + await reloadRenderer(app); + await expect + .poll(() => renderedZoomFactor(app)) + .toBeCloseTo(ZOOMED_FACTOR, 1); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await expect + .poll(() => renderedZoomFactor(app)) + .toBeCloseTo(ZOOMED_FACTOR, 1); + expect( + await app.electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.getZoomLevel() + ) + ).toBe(1); + + await openSources(app.mainWindow); + await resizeWindowBy(app, -24); + await app.mainWindow.waitForTimeout(500); + expect(await renderedZoomFactor(app)).toBeCloseTo(ZOOMED_FACTOR, 1); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 9f29f65d3..992b32a75 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -31,11 +31,14 @@ type MockIpcRenderer = { on: jest.Mock; off: jest.Mock; send: jest.Mock; + sendSync: jest.Mock; }; let mockExposedApi: ExposedElectronApi | null; let mockIpcRenderer: MockIpcRenderer; let mockGetPathForFile: jest.Mock; +let mockWebFrame: { getZoomLevel: jest.Mock; setZoomLevel: jest.Mock }; +let mockDocumentListeners: Map void>; function getExposedApi(): ExposedElectronApi { if (!mockExposedApi) { @@ -64,6 +67,19 @@ describe('main preload DB IPC contract', () => { on: jest.fn(), off: jest.fn(), send: jest.fn(), + sendSync: jest.fn().mockReturnValue(1.5), + }; + mockWebFrame = { + getZoomLevel: jest.fn().mockReturnValue(0), + setZoomLevel: jest.fn(), + }; + // The preload defers webFrame.setZoomLevel to DOMContentLoaded; the + // jest environment is node, so stand in for the parsing document. + mockDocumentListeners = new Map(); + (globalThis as { document?: unknown }).document = { + readyState: 'loading', + addEventListener: (event: string, listener: () => void) => + mockDocumentListeners.set(event, listener), }; jest.doMock('electron', () => ({ @@ -75,6 +91,7 @@ describe('main preload DB IPC contract', () => { ), }, ipcRenderer: mockIpcRenderer, + webFrame: mockWebFrame, webUtils: { getPathForFile: mockGetPathForFile, }, @@ -85,6 +102,26 @@ describe('main preload DB IPC contract', () => { afterEach(() => { jest.dontMock('electron'); + delete (globalThis as { document?: unknown }).document; + }); + + it('requests the persisted zoom level synchronously and applies it at DOMContentLoaded', () => { + // Through webFrame (temporary zoom) so the app's pushState routing + // under file:// cannot reset it, and only once the document is parsed: + // an earlier webFrame.setZoomLevel leaves a hidden Linux/Windows + // window without ready-to-show (issue #1109). + expect(mockIpcRenderer.sendSync).toHaveBeenCalledWith( + 'WINDOW:GET_ZOOM_LEVEL' + ); + expect(mockWebFrame.setZoomLevel).not.toHaveBeenCalled(); + expect(mockExposedApi).not.toBeNull(); + + mockDocumentListeners.get('DOMContentLoaded')?.(); + + expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5); + expect(mockIpcRenderer.send).toHaveBeenCalledWith( + 'WINDOW:ZOOM_LEVEL_APPLIED' + ); }); it('covers every worker-backed DB operation exposed by the preload bridge', () => { diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index ea4ad442e..6427003b5 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,5 +1,5 @@ import type { SourceProbeContext } from '@iptvnator/shared/interfaces'; -import { contextBridge, ipcRenderer, webUtils } from 'electron'; +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'; import { APP_UPDATE_CHECK, APP_UPDATE_DOWNLOAD, @@ -10,6 +10,8 @@ import { ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, ANNOUNCE_PLAYLIST_OPEN_LISTENER, OPEN_FILE, + WINDOW_GET_ZOOM_LEVEL, + WINDOW_ZOOM_LEVEL_APPLIED, } from '@iptvnator/shared/interfaces/ipc-commands'; import { attachEmbeddedMpvFrameView, @@ -19,6 +21,7 @@ import { createPreloadPerformanceCapture, toPreloadPerformanceTargetMethod, } from './preload-performance-capture'; +import { applyPersistedZoomLevel } from './preload-zoom-level'; import { createXtreamPreloadPerformanceCapture, isXtreamPreloadPerformanceCaptureEnabled, @@ -1151,4 +1154,23 @@ const electronApi: ElectronBridgeApi = { }, }; +// Restore the app zoom level (issue #1109). Must be webFrame (temporary, +// frame-bound zoom), not a main-process setZoomLevel: under file:// Chromium +// keys zoom by full URL, and the app's pushState routing would reset it on +// the next resize. Applied at DOMContentLoaded, never earlier — see +// preload-zoom-level.ts for the Linux/Windows ready-to-show trap. +applyPersistedZoomLevel({ + requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL), + getZoomLevel: () => webFrame.getZoomLevel(), + setZoomLevel: (level) => webFrame.setZoomLevel(level), + whenDocumentParsed: (apply) => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', apply, { once: true }); + } else { + apply(); + } + }, + notifyApplied: () => ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED), +}); + contextBridge.exposeInMainWorld('electron', wrapElectronApi(electronApi)); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts new file mode 100644 index 000000000..3ec787df7 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts @@ -0,0 +1,72 @@ +import { applyPersistedZoomLevel } from './preload-zoom-level'; + +function createPorts(saved: unknown, current = 0) { + const parsed: Array<() => void> = []; + return { + requestPersistedZoomLevel: jest.fn(() => saved), + getZoomLevel: jest.fn(() => current), + setZoomLevel: jest.fn(), + whenDocumentParsed: jest.fn((apply: () => void) => { + parsed.push(apply); + }), + notifyApplied: jest.fn(), + /** Fire DOMContentLoaded. */ + parse(): void { + for (const apply of parsed.splice(0)) apply(); + }, + }; +} + +describe('applyPersistedZoomLevel', () => { + it('requests the level at once but applies it only once the document is parsed', () => { + const ports = createPorts(-1.5); + + expect(applyPersistedZoomLevel(ports)).toBe(-1.5); + expect(ports.requestPersistedZoomLevel).toHaveBeenCalledTimes(1); + // Before DOMContentLoaded webFrame.setZoomLevel would leave a hidden + // Linux/Windows window without ready-to-show. + expect(ports.setZoomLevel).not.toHaveBeenCalled(); + expect(ports.notifyApplied).not.toHaveBeenCalled(); + + ports.parse(); + + expect(ports.setZoomLevel).toHaveBeenCalledWith(-1.5); + expect(ports.notifyApplied).toHaveBeenCalledTimes(1); + expect(ports.getZoomLevel).not.toHaveBeenCalled(); + }); + + it.each([null, undefined, Number.NaN, '2'])( + 're-applies the current level as a temporary zoom when %p is stored', + (saved) => { + const ports = createPorts(saved, 0.5); + + expect(applyPersistedZoomLevel(ports)).toBe(0.5); + ports.parse(); + + expect(ports.setZoomLevel).toHaveBeenCalledWith(0.5); + expect(ports.notifyApplied).toHaveBeenCalledTimes(1); + } + ); + + it('never throws out of the preload when the request fails', () => { + const ports = createPorts(1); + ports.requestPersistedZoomLevel.mockImplementation(() => { + throw new Error('no main process'); + }); + + expect(applyPersistedZoomLevel(ports)).toBeNull(); + expect(ports.whenDocumentParsed).not.toHaveBeenCalled(); + }); + + it('does not acknowledge a level that webFrame refused', () => { + const ports = createPorts(1); + ports.setZoomLevel.mockImplementation(() => { + throw new Error('frame gone'); + }); + + applyPersistedZoomLevel(ports); + expect(() => ports.parse()).not.toThrow(); + + expect(ports.notifyApplied).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.ts b/apps/electron-backend/src/app/api/preload-zoom-level.ts new file mode 100644 index 000000000..f81511312 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-zoom-level.ts @@ -0,0 +1,63 @@ +/** + * Preload half of the app zoom level persistence (issue #1109). + * + * The level is requested synchronously at preload start and applied through + * `webFrame.setZoomLevel`, which installs a temporary, frame-bound zoom level + * instead of Chromium's per-URL entry — the only form that survives the + * app's `pushState` routing under `file://` (see + * `services/window-zoom-level.ts` for the mechanism). When nothing is stored + * it re-applies the current level for the same reason: entering temporary + * mode makes the very first zoom shortcut URL-independent too, and keeps + * whatever per-URL level Chromium restored on its own for this document. + * + * The apply itself is deferred to `DOMContentLoaded`. Calling + * `webFrame.setZoomLevel` earlier — at preload start, or from a `setTimeout` + * — leaves a hidden window without a first frame on Linux and Windows: + * `ready-to-show` never fires, `show()` never runs, and the renderer gets no + * animation frames (the splash that `main.ts` removes in a + * `requestAnimationFrame` stays forever). macOS is unaffected, which is why + * only the packaged Linux/Windows E2E caught it. After the parser finishes + * the call is harmless, and it still lands before the first Angular paint. + */ + +export interface PreloadZoomLevelPorts { + /** `ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL)` — the stored level or null. */ + requestPersistedZoomLevel(): unknown; + /** `webFrame.getZoomLevel()` */ + getZoomLevel(): number; + /** `webFrame.setZoomLevel(level)` */ + setZoomLevel(level: number): void; + /** Runs `apply` once the document is parsed (DOMContentLoaded), or at once if it already is. */ + whenDocumentParsed(apply: () => void): void; + /** `ipcRenderer.send(WINDOW_ZOOM_LEVEL_APPLIED)` — hands the main process ownership of the level. */ + notifyApplied(): void; +} + +/** + * Returns the level that will be applied, or `null` when the request failed. + * A failure here must never break the bridge: the preload continues and the + * window merely keeps Chromium's default zoom behaviour for this load. + */ +export function applyPersistedZoomLevel( + ports: PreloadZoomLevelPorts +): number | null { + try { + const saved = ports.requestPersistedZoomLevel(); + const level = + typeof saved === 'number' && Number.isFinite(saved) + ? saved + : ports.getZoomLevel(); + + ports.whenDocumentParsed(() => { + try { + ports.setZoomLevel(level); + ports.notifyApplied(); + } catch { + // Same contract as below: never take the bridge down. + } + }); + return level; + } catch { + return null; + } +} diff --git a/apps/electron-backend/src/app/app-window-state.spec.ts b/apps/electron-backend/src/app/app-window-state.spec.ts index 598931315..39f747c6f 100644 --- a/apps/electron-backend/src/app/app-window-state.spec.ts +++ b/apps/electron-backend/src/app/app-window-state.spec.ts @@ -42,6 +42,7 @@ jest.mock('./services/store.service', () => ({ set: jest.fn(), }, WINDOW_BOUNDS: 'windowBounds', + ZOOM_LEVEL: 'zoomLevel', })); import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces'; diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index f7a94bdb6..37cb9033e 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -37,6 +37,7 @@ jest.mock('./services/store.service', () => ({ }, STARTUP_WINDOW_MODE: 'startupWindowMode', WINDOW_BOUNDS: 'windowBounds', + ZOOM_LEVEL: 'zoomLevel', })); jest.mock('./services/embedded-mpv-frame-copy-platform.util', () => ({ @@ -58,6 +59,7 @@ import { app as electronApp, BrowserWindow, screen } from 'electron'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { store } from './services/store.service'; +import { markZoomLevelApplied } from './services/window-zoom-level'; import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions'; type MockMainWindow = { @@ -80,6 +82,8 @@ type MockMainWindow = { on: jest.Mock void]>; openDevTools: jest.Mock; setWindowOpenHandler: jest.Mock; + getZoomLevel: jest.Mock; + isDestroyed: jest.Mock; }; }; @@ -102,6 +106,8 @@ function createMockMainWindow(): MockMainWindow { on: jest.fn void]>(), openDevTools: jest.fn(), setWindowOpenHandler: jest.fn(), + getZoomLevel: jest.fn().mockReturnValue(0), + isDestroyed: jest.fn().mockReturnValue(false), }, }; } @@ -487,6 +493,126 @@ describe('Electron app security helpers', () => { }); }); + describe('zoom level persistence', () => { + const bounds = { x: 1, y: 2, width: 3, height: 4 }; + + function createWindowViaOnReady(): MockMainWindow { + const mainWindow = createMockMainWindow(); + mainWindow.getNormalBounds.mockReturnValue(bounds); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + getAppInternals().onReady(); + return mainWindow; + } + + function fireHandlers( + calls: Array<[string, (...args: unknown[]) => void]>, + eventName: string, + ...args: unknown[] + ): void { + const handlers = calls + .filter(([name]) => name === eventName) + .map(([, handler]) => handler); + + expect(handlers).toHaveLength(1); + handlers[0](...args); + } + + function fireWindowEvent(win: MockMainWindow, eventName: string): void { + fireHandlers(win.on.mock.calls, eventName); + } + + /** The preload asked for the level: its getZoomLevel() is now the app's. */ + function preloadAppliedZoom(win: MockMainWindow, level: number): void { + win.webContents.getZoomLevel.mockReturnValue(level); + markZoomLevelApplied( + win.webContents as unknown as Electron.WebContents + ); + } + + it('persists the zoom level next to the bounds when a window whose preload owns the level closes', () => { + const mainWindow = createWindowViaOnReady(); + preloadAppliedZoom(mainWindow, 1.5); + + fireWindowEvent(mainWindow, 'close'); + + expect(store.set).toHaveBeenCalledWith('zoomLevel', 1.5); + expect(store.set).toHaveBeenCalledWith('windowBounds', bounds); + }); + + it('saves only the bounds when the window closes before its preload applied the level', () => { + const mainWindow = createWindowViaOnReady(); + // Before the preload handshake getZoomLevel() is Chromium's + // per-URL default; saving it would clobber the persisted level. + mainWindow.webContents.getZoomLevel.mockReturnValue(0); + + fireWindowEvent(mainWindow, 'close'); + + expect(store.set).not.toHaveBeenCalledWith( + 'zoomLevel', + expect.anything() + ); + expect(store.set).toHaveBeenCalledWith('windowBounds', bounds); + }); + + it('does not read zoom from a destroyed webContents on close', () => { + const mainWindow = createWindowViaOnReady(); + preloadAppliedZoom(mainWindow, 1.5); + mainWindow.webContents.isDestroyed.mockReturnValue(true); + + fireWindowEvent(mainWindow, 'close'); + + expect(mainWindow.webContents.getZoomLevel).not.toHaveBeenCalled(); + expect(store.set).not.toHaveBeenCalledWith( + 'zoomLevel', + expect.anything() + ); + expect(store.set).toHaveBeenCalledWith('windowBounds', bounds); + }); + + it('persists the zoom level and bounds on before-quit', () => { + const mainWindow = createMockMainWindow(); + mainWindow.getNormalBounds.mockReturnValue(bounds); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + preloadAppliedZoom(mainWindow, 2); + + fireHandlers( + (electronApp.on as jest.Mock).mock.calls, + 'before-quit' + ); + + expect(store.set).toHaveBeenCalledWith('zoomLevel', 2); + expect(store.set).toHaveBeenCalledWith('windowBounds', bounds); + }); + + it('saves the zoom level right before a reload, which drops the temporary level', () => { + const mainWindow = createWindowViaOnReady(); + preloadAppliedZoom(mainWindow, 3); + + fireHandlers( + mainWindow.webContents.on.mock.calls, + 'did-start-navigation', + { isMainFrame: true, isSameDocument: false } + ); + + expect(store.set).toHaveBeenCalledWith('zoomLevel', 3); + }); + + it('leaves in-app (same-document) routing alone', () => { + const mainWindow = createWindowViaOnReady(); + preloadAppliedZoom(mainWindow, 3); + + fireHandlers( + mainWindow.webContents.on.mock.calls, + 'did-start-navigation', + { isMainFrame: true, isSameDocument: true } + ); + + expect(store.set).not.toHaveBeenCalled(); + }); + }); + it('creates the main window immediately when Electron is already ready', () => { const mainWindow = createMockMainWindow(); (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 93b5a3169..7d1ef0c1f 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -18,6 +18,10 @@ import { WINDOW_BOUNDS, } from './services/store.service'; import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util'; +import { + attachZoomLevelPersistence, + persistZoomLevel, +} from './services/window-zoom-level'; import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util'; import { FULLSCREEN_LAUNCH_SWITCH, @@ -450,6 +454,18 @@ export default class App { }); } + /** + * Persist the state restored on the next launch: window bounds and the + * zoom level. Runs from both the window 'close' and app 'before-quit' + * handlers, since either can be the last to run before the process ends. + * The zoom half is a no-op until the window's preload took ownership of + * the level (`services/window-zoom-level.ts`). + */ + private static persistWindowState(win: Electron.BrowserWindow): void { + store.set(WINDOW_BOUNDS, win.getNormalBounds()); + persistZoomLevel(win); + } + private static initMainWindow() { const workAreaSize = screen.getPrimaryDisplay().workAreaSize; const width = Math.min(1280, workAreaSize.width || 1280); @@ -539,6 +555,10 @@ export default class App { App.handleRendererNavigation ); + // The preload restores the zoom level on every document load; the + // main process only saves it back before a reload drops it. + attachZoomLevelPersistence(App.mainWindow); + // Emitted when the window is closed. App.mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows @@ -551,7 +571,7 @@ export default class App { App.mainWindow.on('close', () => { if (App.mainWindow) { - store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds()); + App.persistWindowState(App.mainWindow); } }); @@ -653,8 +673,7 @@ export default class App { } App.application.on('activate', App.onActivate); // App is activated App.application.on('before-quit', () => { - if (App.mainWindow) - store.set(WINDOW_BOUNDS, App.mainWindow.getNormalBounds()); + if (App.mainWindow) App.persistWindowState(App.mainWindow); }); } } diff --git a/apps/electron-backend/src/app/events/window.events.spec.ts b/apps/electron-backend/src/app/events/window.events.spec.ts index a144ebb11..be97baa9c 100644 --- a/apps/electron-backend/src/app/events/window.events.spec.ts +++ b/apps/electron-backend/src/app/events/window.events.spec.ts @@ -5,6 +5,14 @@ const mockHandlers = new Map< (event: unknown, ...args: unknown[]) => unknown >(); const mockFromWebContents = jest.fn(); +const mockReadPersistedZoomLevel = jest.fn(); +const mockMarkZoomLevelApplied = jest.fn(); + +jest.mock('../services/window-zoom-level', () => ({ + readPersistedZoomLevel: () => mockReadPersistedZoomLevel(), + markZoomLevelApplied: (contents: unknown) => + mockMarkZoomLevelApplied(contents), +})); jest.mock('electron', () => ({ ipcMain: { @@ -16,6 +24,14 @@ jest.mock('electron', () => ({ mockHandlers.set(channel, handler); } ), + on: jest.fn( + ( + channel: string, + handler: (event: unknown, ...args: unknown[]) => unknown + ) => { + mockHandlers.set(channel, handler); + } + ), }, BrowserWindow: { fromWebContents: (...args: unknown[]) => mockFromWebContents(...args), @@ -90,9 +106,11 @@ describe('WindowEvents', () => { expect([...mockHandlers.keys()].sort()).toEqual([ 'WINDOW:CLOSE', 'WINDOW:GET_STATE', + 'WINDOW:GET_ZOOM_LEVEL', 'WINDOW:MINIMIZE', 'WINDOW:TOGGLE_FULLSCREEN', 'WINDOW:TOGGLE_MAXIMIZE', + 'WINDOW:ZOOM_LEVEL_APPLIED', ]); }); @@ -442,3 +460,43 @@ describe('WindowEvents', () => { expect(win.minimize).not.toHaveBeenCalled(); }); }); + +describe('zoom level IPC', () => { + beforeEach(() => { + mockReadPersistedZoomLevel.mockReset(); + mockMarkZoomLevelApplied.mockReset(); + }); + + it('answers the preload synchronously without taking ownership yet', () => { + mockReadPersistedZoomLevel.mockReturnValue(1.5); + const event: { sender: unknown; returnValue?: unknown } = { + sender: {}, + }; + + mockHandlers.get('WINDOW:GET_ZOOM_LEVEL')?.(event); + + expect(event.returnValue).toBe(1.5); + // Ownership waits for the applied acknowledgement: until then the + // sender's getZoomLevel() is still Chromium's per-URL default. + expect(mockMarkZoomLevelApplied).not.toHaveBeenCalled(); + }); + + it('answers null when nothing usable is stored', () => { + mockReadPersistedZoomLevel.mockReturnValue(null); + const event: { sender: unknown; returnValue?: unknown } = { + sender: {}, + }; + + mockHandlers.get('WINDOW:GET_ZOOM_LEVEL')?.(event); + + expect(event.returnValue).toBeNull(); + }); + + it('marks the sender as owning the level once the preload applied it', () => { + const sender = { id: 7 }; + + mockHandlers.get('WINDOW:ZOOM_LEVEL_APPLIED')?.({ sender }); + + expect(mockMarkZoomLevelApplied).toHaveBeenCalledWith(sender); + }); +}); diff --git a/apps/electron-backend/src/app/events/window.events.ts b/apps/electron-backend/src/app/events/window.events.ts index 5ce0d79c4..b55ce47c6 100644 --- a/apps/electron-backend/src/app/events/window.events.ts +++ b/apps/electron-backend/src/app/events/window.events.ts @@ -9,11 +9,17 @@ import { BrowserWindow, ipcMain } from 'electron'; import { WINDOW_CLOSE, WINDOW_GET_STATE, + WINDOW_GET_ZOOM_LEVEL, WINDOW_MINIMIZE, + WINDOW_ZOOM_LEVEL_APPLIED, WINDOW_TOGGLE_FULLSCREEN, WINDOW_TOGGLE_MAXIMIZE, } from '@iptvnator/shared/interfaces'; import { toggleFullScreen } from '../services/native-fullscreen-transitions'; +import { + markZoomLevelApplied, + readPersistedZoomLevel, +} from '../services/window-zoom-level'; interface WindowState { isMaximized: boolean; @@ -98,3 +104,18 @@ ipcMain.handle(WINDOW_CLOSE, (event) => { ipcMain.handle(WINDOW_GET_STATE, (event): WindowState => { return getWindowState(getSenderWindow(event)); }); + +// Answered synchronously (`returnValue`) so the preload has the level before +// the document is parsed and can apply it at DOMContentLoaded, ahead of the +// first Angular paint. +ipcMain.on(WINDOW_GET_ZOOM_LEVEL, (event) => { + event.returnValue = readPersistedZoomLevel(); +}); + +// The preload applied the level with webFrame.setZoomLevel: from here on the +// sender's getZoomLevel() is the app's value and may be persisted. Ownership +// waits for this acknowledgement rather than the request above, so a close +// between the two cannot save Chromium's per-URL default over the user's level. +ipcMain.on(WINDOW_ZOOM_LEVEL_APPLIED, (event) => { + markZoomLevelApplied(event.sender); +}); diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index 0362b3637..9b1203ed3 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -6,6 +6,8 @@ import type { } from '@iptvnator/shared/interfaces'; export const WINDOW_BOUNDS = 'WINDOW_BOUNDS'; +/** App-wide zoom, as the raw Electron zoom level (0 is default). */ +export const ZOOM_LEVEL = 'ZOOM_LEVEL'; export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH'; export const MPV_PLAYER_ARGUMENTS = 'MPV_PLAYER_ARGUMENTS'; export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH'; @@ -64,6 +66,7 @@ export const EMBEDDED_MPV_AUTO_RECONNECT = 'EMBEDDED_MPV_AUTO_RECONNECT'; export type StoreType = { [WINDOW_BOUNDS]: Electron.Rectangle; + [ZOOM_LEVEL]: number; [MPV_PLAYER_PATH]: string; [MPV_PLAYER_ARGUMENTS]: string; [VLC_PLAYER_PATH]: string; diff --git a/apps/electron-backend/src/app/services/window-zoom-level.spec.ts b/apps/electron-backend/src/app/services/window-zoom-level.spec.ts new file mode 100644 index 000000000..1147eb7ae --- /dev/null +++ b/apps/electron-backend/src/app/services/window-zoom-level.spec.ts @@ -0,0 +1,160 @@ +jest.mock('./store.service', () => ({ + store: { + get: jest.fn(), + set: jest.fn(), + }, + ZOOM_LEVEL: 'zoomLevel', +})); + +import { store } from './store.service'; +import { + attachZoomLevelPersistence, + markZoomLevelApplied, + persistZoomLevel, + readPersistedZoomLevel, +} from './window-zoom-level'; + +type NavigationDetails = { isMainFrame: boolean; isSameDocument: boolean }; + +function createWindow(level = 0) { + const listeners = new Map void>(); + const webContents = { + isDestroyed: jest.fn(() => false), + getZoomLevel: jest.fn(() => level), + on: jest.fn( + (event: string, listener: (details: NavigationDetails) => void) => { + listeners.set(event, listener); + } + ), + }; + const win = { webContents } as unknown as Electron.BrowserWindow; + + return { + win, + webContents, + navigate(details: NavigationDetails): void { + const listener = listeners.get('did-start-navigation'); + expect(listener).toBeDefined(); + listener?.(details); + }, + }; +} + +describe('window zoom level persistence', () => { + beforeEach(() => { + jest.clearAllMocks(); + (store.get as jest.Mock).mockReturnValue(undefined); + }); + + describe('readPersistedZoomLevel', () => { + it('returns a finite stored level', () => { + (store.get as jest.Mock).mockReturnValue(-1.5); + + expect(readPersistedZoomLevel()).toBe(-1.5); + expect(store.get).toHaveBeenCalledWith('zoomLevel'); + }); + + it.each([undefined, null, Number.NaN, Number.POSITIVE_INFINITY, '2'])( + 'returns null for the unusable stored value %p', + (value) => { + (store.get as jest.Mock).mockReturnValue(value); + + expect(readPersistedZoomLevel()).toBeNull(); + } + ); + }); + + describe('persistZoomLevel', () => { + it('does nothing before the preload took ownership of the level', () => { + const { win, webContents } = createWindow(2); + + persistZoomLevel(win); + + expect(webContents.getZoomLevel).not.toHaveBeenCalled(); + expect(store.set).not.toHaveBeenCalled(); + }); + + it('saves the live level once the preload applied it', () => { + const { win, webContents } = createWindow(1.5); + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + + persistZoomLevel(win); + + expect(store.set).toHaveBeenCalledWith('zoomLevel', 1.5); + }); + + it('never reads a destroyed webContents', () => { + const { win, webContents } = createWindow(1.5); + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + webContents.isDestroyed.mockReturnValue(true); + + persistZoomLevel(win); + + expect(webContents.getZoomLevel).not.toHaveBeenCalled(); + expect(store.set).not.toHaveBeenCalled(); + }); + + it('skips a non-finite live level', () => { + const { win, webContents } = createWindow(Number.NaN); + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + + persistZoomLevel(win); + + expect(store.set).not.toHaveBeenCalled(); + }); + }); + + describe('attachZoomLevelPersistence', () => { + it('saves the level before a cross-document navigation and releases ownership until the next preload answers', () => { + const { win, webContents, navigate } = createWindow(3); + attachZoomLevelPersistence(win); + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + + navigate({ isMainFrame: true, isSameDocument: false }); + expect(store.set).toHaveBeenCalledWith('zoomLevel', 3); + + // The reloading document has not applied anything yet: its + // getZoomLevel() is Chromium's per-URL default and must not be saved. + (store.set as jest.Mock).mockClear(); + webContents.getZoomLevel.mockReturnValue(0); + persistZoomLevel(win); + expect(store.set).not.toHaveBeenCalled(); + + // The new document's preload handshake re-enables saving. + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + webContents.getZoomLevel.mockReturnValue(3); + persistZoomLevel(win); + expect(store.set).toHaveBeenCalledWith('zoomLevel', 3); + }); + + it.each([ + { isMainFrame: true, isSameDocument: true }, + { isMainFrame: false, isSameDocument: false }, + ])( + 'ignores the navigation %p (in-app routing and subframes keep the level)', + (details) => { + const { win, webContents, navigate } = createWindow(3); + attachZoomLevelPersistence(win); + markZoomLevelApplied( + webContents as unknown as Electron.WebContents + ); + + navigate(details); + + expect(store.set).not.toHaveBeenCalled(); + persistZoomLevel(win); + expect(store.set).toHaveBeenCalledWith('zoomLevel', 3); + } + ); + }); +}); diff --git a/apps/electron-backend/src/app/services/window-zoom-level.ts b/apps/electron-backend/src/app/services/window-zoom-level.ts new file mode 100644 index 000000000..4ee7c75fa --- /dev/null +++ b/apps/electron-backend/src/app/services/window-zoom-level.ts @@ -0,0 +1,80 @@ +/** + * App-wide zoom level persistence (issue #1109). + * + * The packaged renderer runs under `file://` with path routing, and Chromium + * keys per-host zoom by the FULL URL when a URL has no host — so every + * `pushState` to another section owns a separate zoom entry, and the next + * visual-properties sync (a window resize, a display change) snaps the + * renderer back to that entry's default. `webContents.setZoomLevel` from the + * main process writes exactly those per-URL entries and cannot fix this. + * + * The preload therefore applies the persisted level with + * `webFrame.setZoomLevel`, which installs a TEMPORARY zoom level bound to the + * frame instead of the URL: it survives in-page navigation and resizes, the + * menu-role zoom shortcuts increment it, and `getZoomLevel()` reports it + * regardless of the current route. Chromium never persists temporary zoom, + * so this module owns the electron-conf side: it hands the preload the saved + * level and writes the live level back on close, quit, and every + * cross-document navigation (a reload clears the temporary level, and the + * new document's preload reads whatever was saved last). + */ + +import { store, ZOOM_LEVEL } from './store.service'; + +/** + * WebContents whose preload has applied the app-owned zoom level, i.e. whose + * `getZoomLevel()` now reflects the user's choice rather than Chromium's + * per-URL default. Saving before that point would overwrite the persisted + * level with the pre-load value. + */ +const zoomOwningContents = new WeakSet(); + +export function readPersistedZoomLevel(): number | null { + const level = store.get(ZOOM_LEVEL); + return typeof level === 'number' && Number.isFinite(level) ? level : null; +} + +/** Records that `contents` applied the app-owned level (preload handshake). */ +export function markZoomLevelApplied(contents: Electron.WebContents): void { + zoomOwningContents.add(contents); +} + +/** + * Write the live zoom level of `win` to electron-conf. A no-op until the + * window's preload took ownership of the level, and never touches a + * destroyed webContents, whose `getZoomLevel()` would throw. + */ +export function persistZoomLevel(win: Electron.BrowserWindow): void { + const contents = win.webContents; + + if (!zoomOwningContents.has(contents) || contents.isDestroyed()) { + return; + } + + const level = contents.getZoomLevel(); + + if (Number.isFinite(level)) { + store.set(ZOOM_LEVEL, level); + } +} + +/** + * Save the level right before a cross-document navigation (reload, or a + * navigation the app itself never issues). `did-start-navigation` still + * observes the temporary level; by `did-finish-load` Chromium has dropped it, + * and the new document's preload has already asked for the stored value. + * Ownership is released with it: until that preload answers, the window's + * `getZoomLevel()` is the per-URL default again and must not be saved. + */ +export function attachZoomLevelPersistence(win: Electron.BrowserWindow): void { + const contents = win.webContents; + + contents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) { + return; + } + + persistZoomLevel(win); + zoomOwningContents.delete(contents); + }); +} diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index fd7315666..731e5cb6f 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -407,6 +407,60 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455): `window.events.spec.ts`, and the startup-window-mode cases in `settings.e2e.ts`. +Zoom level (Cmd/Ctrl and +/−, issue #1109): + +1. The packaged renderer runs under `file://` with path routing. Chromium + keys per-host zoom by the FULL URL when a URL has no host, so every + `pushState` to another section owns a separate zoom entry: after a route + change `webContents.getZoomLevel()` already reports that entry (usually + 0) and the next visual-properties sync — a window resize, a display + change — snaps the renderer back to it. Chromium persists those per-URL + entries in `Preferences` on its own, which is why the level used to + "appear briefly" on `index.html` at startup and then reset. Dev mode + (`http://localhost:4200`) is per-host and never shows this, so only a + packaged or `ELECTRON_IS_DEV=0` run can verify zoom behaviour. +2. Restore therefore happens in the preload, not the main process: + `applyPersistedZoomLevel` (`api/preload-zoom-level.ts`) asks for the + stored level over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC at preload + start and applies it with `webFrame.setZoomLevel`, which installs a + TEMPORARY, frame-bound zoom level. It survives in-page navigation and + resizes, the macOS menu roles (`zoomIn`/`zoomOut`/`resetZoom`) increment + it, and `getZoomLevel()` reports it regardless of the route. + `webContents.setZoomLevel` from the main process would write the per-URL + entry and re-create the bug. When nothing is stored the preload re-applies + the current level for the same reason: entering temporary mode makes the + first zoom shortcut URL-independent too. A failed request is swallowed + and only costs this load its restore. + The apply is deferred to `DOMContentLoaded` — never at preload start and + never from a `setTimeout`: on Linux and Windows a `webFrame.setZoomLevel` + that early leaves the hidden window without a first frame, `ready-to-show` + never fires, `show()` never runs, and the renderer gets no animation + frames (the splash `main.ts` removes in a `requestAnimationFrame` stays). + macOS is unaffected and CDP-driven tests force frames, so only the + packaged Linux/Windows E2E asserting the splash is gone caught it + (`legacy-playlist-migration.e2e.ts`, defer-epg). After the parser + finishes the call is harmless and still lands before the first Angular + paint. The preload then sends `WINDOW:ZOOM_LEVEL_APPLIED`. +3. Chromium never persists temporary zoom, so `services/window-zoom-level.ts` + owns the electron-conf key `ZOOM_LEVEL`: the applied acknowledgement (not + the request — between the two the sender's `getZoomLevel()` is still the + per-URL default) marks the sender + as owning the level, `persistZoomLevel` writes it back from the window + `close` and app `before-quit` handlers (the bounds-only saves of before, + folded into `persistWindowState`), and `attachZoomLevelPersistence` also + writes it on every main-frame cross-document `did-start-navigation` — + a reload drops the temporary level, and by `did-finish-load` the new + document's preload has already read whatever was stored. That + navigation also releases ownership until the next preload answers, so a + close mid-reload cannot save the per-URL default over the user's level. +4. Windows/Linux have no zoom shortcuts today: the window calls + `setMenu(null)` and nothing handles `zoom-changed`, so only macOS' default + application menu zooms. Persistence is platform-neutral and would pick + up any future shortcut that goes through `webContents.setZoomLevel`. + Regression coverage lives in `window-zoom-level.e2e.ts`, which measures + the rendered factor (content width ÷ `window.innerWidth`) across a + section change, a resize, a reload and a restart. + Layout integration: 1. `document.body` gets a `frameless-platform` class (set in diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index a724f77b0..e4ba2a0bb 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -134,6 +134,11 @@ export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN'; export const WINDOW_CLOSE = 'WINDOW:CLOSE'; export const WINDOW_GET_STATE = 'WINDOW:GET_STATE'; export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED'; +// Synchronous preload request for the persisted app zoom level (answered +// with `event.returnValue`), and the preload's acknowledgement once it has +// applied that level with webFrame.setZoomLevel at DOMContentLoaded +export const WINDOW_GET_ZOOM_LEVEL = 'WINDOW:GET_ZOOM_LEVEL'; +export const WINDOW_ZOOM_LEVEL_APPLIED = 'WINDOW:ZOOM_LEVEL_APPLIED'; // Close guard: while active, closing/quitting the app is intercepted in the // main process and handed to the renderer for a save/discard/stay decision