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