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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-18 15:29:35 +02:00
1 parent 9a3aa63490
commit ab239b5043
17 files changed
+849 -4

No files matched your search

+9
View File
@@ -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.
+1
View File
@@ -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**:
@@ -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<number> {
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<void> {
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<void> {
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<void> {
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);
}
});
@@ -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<string, () => 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', () => {
@@ -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));
@@ -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();
});
});
@@ -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;
}
}
@@ -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';
+126
View File
@@ -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, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
getZoomLevel: jest.Mock<number, []>;
isDestroyed: jest.Mock<boolean, []>;
};
};
@@ -102,6 +106,8 @@ function createMockMainWindow(): MockMainWindow {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
isDestroyed: jest.fn<boolean, []>().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);
+22 -3
View File
@@ -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);
});
}
}
@@ -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);
});
});
@@ -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);
});
@@ -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;
@@ -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<string, (details: NavigationDetails) => 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);
}
);
});
});
@@ -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<Electron.WebContents>();
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);
});
}
+54
View File
@@ -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
@@ -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