fix(electron): own app zoom and migrate legacy settings

Problem: Electron inherited the service-owned zoom value and hid app zoom on non-Windows platforms.\n\nRoot cause: the shell trusted geometry.zoomFactor and the renderer used legacy Wails bindings.\n\nFix: add an Electron-owned zoom store with safe legacy migration, trusted IPC, runtime updates, menu shortcuts, and cross-platform settings visibility. Keep browser tab zoom independent.\n\nVerification: Electron typecheck and 103 Electron tests pass; frontend typecheck and production build pass.
This commit is contained in:
SivanCola committed 2026-09-09 19:17:42 +08:00
1 parent c4add668b5
commit 0683e05ce9
16 files changed
+176 -12

No files matched your search

+16 -2
View File
@@ -28,6 +28,7 @@ import { ServiceSupervisor } from "./service.js";
import { claimShellInstance } from "./singleInstance.js";
import { TrayHost } from "./tray.js";
import { DEFAULT_GEOMETRY, MainWindow } from "./window.js";
import { AppZoomStore } from "./zoomStore.js";
const MAIN_WINDOW_PERMISSIONS = new Set(["clipboard-read", "clipboard-sanitized-write", "fullscreen", "notifications"]);
const TAKEOVER_KINDS = new Set<string>(["mousedown", "keydown", "wheel", "touchstart", "pointerdown"]);
@@ -71,6 +72,7 @@ function bootstrap(dataHome: string): void {
const distRoot = resolveDistRoot({ env: process.env, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
const devURL = (process.env.REASONIX_ELECTRON_DEV_URL ?? "").trim();
const appURL = devURL !== "" ? devURL : APP_INDEX_URL;
const zoomStore = new AppZoomStore(join(dataHome, "electron-app-zoom.json"), join(dataHome, "desktop-zoom.json"));
const icons = iconCandidates({ platform: process.platform, appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged });
const windowIcon = process.platform === "darwin" ? undefined : (firstExisting(icons.window) ?? undefined);
const serviceBinary = (process.env.REASONIX_DESKTOP_SERVICE ?? "").trim()
@@ -110,6 +112,7 @@ function bootstrap(dataHome: string): void {
else if (action === "restart") void service.restart().catch(() => undefined);
else lifecycle.approve();
},
zoomStore,
});
const downloads = new DownloadTracker({
@@ -244,9 +247,17 @@ function bootstrap(dataHome: string): void {
documents.clear();
}
},
onReady: (hello: HelloResult) => {
onReady: async (hello: HelloResult) => {
log.info(`desktop service ready: generation ${hello.runtimeGeneration}, pid ${hello.service.pid}`);
if (!mainWindow.browserWindow) mainWindow.create(hello.window);
if (!mainWindow.browserWindow) {
try {
await zoomStore.load();
mainWindow.create(hello.window);
} catch (error) {
log.warn(`app zoom initialization failed: ${errorText(error)}`);
mainWindow.create(DEFAULT_GEOMETRY);
}
}
// Reattach the surviving renderer after a service restart. Reloading
// would destroy unsent composer drafts; desktop:resync repairs reads.
if (!mainWindow.reattachApp()) void mainWindow.loadApp();
@@ -328,6 +339,9 @@ function bootstrap(dataHome: string): void {
toggleDevTools: () => mainWindow.toggleDevTools(),
showWindow: () => mainWindow.show("menu"),
quit: () => lifecycle.requestQuit(),
zoomIn: () => { void mainWindow.stepAppZoom(1); },
zoomOut: () => { void mainWindow.stepAppZoom(-1); },
resetZoom: () => { void mainWindow.resetAppZoom(); },
});
log.info(`shell starting: service ${serviceBinary}, ui ${appURL}, dist ${distRoot}, home ${dataHome}`);
return service.start().catch(() => undefined);
@@ -31,7 +31,8 @@ test("trusted toolbar takeover bypasses input suppression immediately, fences ac
registerRendererIpc({ ipcMain, contract: parseContract({ digest: "test", commands: ["ListTabs"] }),
window: { isTrustedSender: (sender, frame) => sender === trustedSender && frame === trustedFrame,
minimise() {}, toggleMaximise() {}, isMaximised: () => false, close() {},
bounds: () => ({ x: 0, y: 0, width: 1, height: 1, maximised: false }), setTheme() {}, setBackgroundColour() {} },
bounds: () => ({ x: 0, y: 0, width: 1, height: 1, maximised: false }), setTheme() {}, setBackgroundColour() {},
getAppZoom: async () => 1, setAppZoom: async () => 1, resetAppZoom: async () => 1 },
invoke: async () => undefined, serviceState: () => ({ phase: "ready", generation: "g" }),
clipboard: { writeText() {}, readText: () => "" }, openExternal: async () => {}, log: silentLog,
browser: { list: () => manager.list(), open: async (url, options) => { const opened = await manager.open(url, options); return manager.list().find((entry) => entry.id === opened.id)!; },
+3
View File
@@ -45,6 +45,9 @@ test("renderer invokes are gated by sender identity and the contract allowlist",
bounds: () => ({ x: 1, y: 2, width: 3, height: 4, maximised: false }),
setTheme() {},
setBackgroundColour() {},
getAppZoom: async () => 1,
setAppZoom: async () => 1,
resetAppZoom: async () => 1,
},
serviceState: () => ({ phase: "ready" as const, generation: "g-test" }),
invoke: async (method, args) => {
+6
View File
@@ -24,6 +24,9 @@ export interface RendererWindowApi {
bounds(): WindowBounds;
setTheme(theme: WindowTheme): void;
setBackgroundColour(r: number, g: number, b: number, a: number): void;
getAppZoom(): Promise<number>;
setAppZoom(factor: number): Promise<number>;
resetAppZoom(): Promise<number>;
}
// The user-driven browser panel: no grant is involved because the user is
@@ -126,6 +129,9 @@ export function registerRendererIpc(deps: RendererIpcDeps): void {
handle(IPC.windowGetBounds, () => deps.window.bounds());
handle(IPC.windowSetTheme, (theme) => deps.window.setTheme(theme === "light" || theme === "dark" ? theme : "system"));
handle(IPC.windowSetBackground, (r, g, b, a) => deps.window.setBackgroundColour(finite(r), finite(g), finite(b), finite(a, 255)));
handle(IPC.appZoomGet, () => deps.window.getAppZoom());
handle(IPC.appZoomSet, (factor) => deps.window.setAppZoom(finite(factor, Number.NaN)));
handle(IPC.appZoomReset, () => deps.window.resetAppZoom());
const browser = deps.browser;
if (!browser) return;
+8
View File
@@ -6,6 +6,9 @@ export interface MenuDeps {
toggleDevTools(): void;
showWindow(): void;
quit(): void;
zoomIn(): void;
zoomOut(): void;
resetZoom(): void;
}
export function applicationMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] {
@@ -22,6 +25,11 @@ export function applicationMenuTemplate(deps: MenuDeps): MenuItemConstructorOpti
],
},
{ role: "editMenu" },
{ label: "View", submenu: [
{ label: "Zoom In", accelerator: "CmdOrCtrl+=", click: () => deps.zoomIn() },
{ label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: () => deps.zoomOut() },
{ label: "Reset Zoom", accelerator: "CmdOrCtrl+0", click: () => deps.resetZoom() },
] },
{ role: "windowMenu" },
];
}
+15 -1
View File
@@ -5,6 +5,7 @@ import { shellActionFromURL, type ShellAction } from "./failurePage.js";
import type { HelloWindow } from "./handshake.js";
import { errorText, type Logger } from "./log.js";
import { APP_ORIGIN } from "./protocol.js";
import { AppZoomStore } from "./zoomStore.js";
export const DEFAULT_GEOMETRY: HelloWindow = { width: 1280, height: 820, minWidth: 760, minHeight: 480, frameless: false, zoomFactor: 1 };
@@ -19,6 +20,7 @@ export interface MainWindowDeps {
onCloseRequested(): Promise<boolean>;
onCloseAllowed(): void;
onShellAction(action: ShellAction): void;
zoomStore: AppZoomStore;
}
type Content = "none" | "app" | "failure";
@@ -70,7 +72,7 @@ export class MainWindow {
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
zoomFactor: geometry.zoomFactor,
zoomFactor: this.deps.zoomStore.current.appZoomFactor,
},
});
this.win = win;
@@ -109,6 +111,18 @@ export class MainWindow {
});
}
async getAppZoom(): Promise<number> { return (await this.deps.zoomStore.load()).appZoomFactor; }
async setAppZoom(factor: number): Promise<number> {
const state = await this.deps.zoomStore.set(factor);
this.browserWindow?.webContents.setZoomFactor(state.appZoomFactor);
return state.appZoomFactor;
}
async resetAppZoom(): Promise<number> { return this.setAppZoom(1); }
async stepAppZoom(direction: 1 | -1): Promise<number> {
const current = await this.getAppZoom();
return this.setAppZoom(current + direction * 0.05);
}
async loadApp(): Promise<void> {
const win = this.browserWindow;
if (!win) return;
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AppZoomStore, normalizeAppZoom } from "./zoomStore.js";
test("normalizes app zoom and defaults safely", () => {
assert.equal(normalizeAppZoom(Number.NaN), 1);
assert.equal(normalizeAppZoom(0.71), 0.7);
assert.equal(normalizeAppZoom(3), 2);
});
test("migrates only explicitly user-owned legacy zoom", async () => {
const root = await mkdtemp(join(tmpdir(), "reasonix-zoom-"));
const legacy = join(root, "desktop-zoom.json");
await writeFile(legacy, JSON.stringify({ zoomFactor: 0.7 }));
const store = new AppZoomStore(join(root, "electron-app-zoom.json"), legacy);
assert.equal((await store.load()).appZoomFactor, 1);
await writeFile(legacy, JSON.stringify({ zoomFactor: 0.7, source: "user" }));
const second = new AppZoomStore(join(root, "electron-app-zoom-2.json"), legacy);
assert.equal((await second.load()).appZoomFactor, 0.7);
});
test("persists user value and reset", async () => {
const root = await mkdtemp(join(tmpdir(), "reasonix-zoom-"));
const file = join(root, "nested", "zoom.json");
const store = new AppZoomStore(file);
await store.set(0.85);
assert.equal((await store.load()).appZoomFactor, 0.85);
await store.reset();
assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { version: 1, appZoomFactor: 1, source: "user" });
});
+59
View File
@@ -0,0 +1,59 @@
import { rename, readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
export const MIN_APP_ZOOM = 0.5;
export const MAX_APP_ZOOM = 2;
export const APP_ZOOM_STEP = 0.05;
export type AppZoomSource = "default" | "user" | "migrated";
export interface AppZoomState { version: 1; appZoomFactor: number; source: AppZoomSource; }
const DEFAULT: AppZoomState = { version: 1, appZoomFactor: 1, source: "default" };
export function normalizeAppZoom(value: number): number {
if (!Number.isFinite(value)) return 1;
const clamped = Math.min(MAX_APP_ZOOM, Math.max(MIN_APP_ZOOM, value));
return Number((MIN_APP_ZOOM + Math.round((clamped - MIN_APP_ZOOM) / APP_ZOOM_STEP) * APP_ZOOM_STEP).toFixed(2));
}
export class AppZoomStore {
private state: AppZoomState = DEFAULT;
constructor(private readonly file: string, private readonly legacyFile?: string) {}
async load(): Promise<AppZoomState> {
try {
const parsed = JSON.parse(await readFile(this.file, "utf8")) as Partial<AppZoomState>;
if (parsed.version === 1 && typeof parsed.appZoomFactor === "number" && Number.isFinite(parsed.appZoomFactor)
&& (parsed.source === "default" || parsed.source === "user" || parsed.source === "migrated")) {
this.state = { version: 1, appZoomFactor: normalizeAppZoom(parsed.appZoomFactor), source: parsed.source };
return this.state;
}
} catch { /* first launch or corrupt file */ }
this.state = await this.migrate();
await this.persist(this.state);
return this.state;
}
async set(factor: number, source: AppZoomSource = "user"): Promise<AppZoomState> {
if (!Number.isFinite(factor)) throw new Error("app zoom factor must be finite");
this.state = { version: 1, appZoomFactor: normalizeAppZoom(factor), source };
await this.persist(this.state);
return this.state;
}
async reset(): Promise<AppZoomState> { return this.set(1, "user"); }
get current(): AppZoomState { return this.state; }
private async migrate(): Promise<AppZoomState> {
if (!this.legacyFile) return DEFAULT;
try {
const parsed = JSON.parse(await readFile(this.legacyFile, "utf8")) as { zoomFactor?: unknown; source?: unknown };
// Legacy files had no trustworthy source marker; do not inherit an accidental 70% value.
if (parsed.source === "user" && typeof parsed.zoomFactor === "number" && Number.isFinite(parsed.zoomFactor)) {
return { version: 1, appZoomFactor: normalizeAppZoom(parsed.zoomFactor), source: "migrated" };
}
} catch { /* use default */ }
return DEFAULT;
}
private async persist(state: AppZoomState): Promise<void> {
await mkdir(dirname(this.file), { recursive: true });
const temp = join(dirname(this.file), `.${state.version}.zoom.tmp`);
await writeFile(temp, `${JSON.stringify(state)}\n`, "utf8");
await rename(temp, this.file);
}
}
+3
View File
@@ -200,6 +200,9 @@ contextBridge.exposeInMainWorld("reasonixDesktop", {
minimise: () => fire(IPC.windowMinimise),
toggleMaximise: () => fire(IPC.windowToggleMaximise),
close: () => fire(IPC.windowClose),
getAppZoom: () => call(IPC.appZoomGet),
setAppZoom: (factor: number) => call(IPC.appZoomSet, factor),
resetAppZoom: () => call(IPC.appZoomReset),
},
getPathForFile: (file: File) => webUtils.getPathForFile(file),
onServiceState,
+3
View File
@@ -14,6 +14,9 @@ export const IPC = {
windowGetBounds: "reasonix:native:window-get-bounds",
windowSetTheme: "reasonix:native:window-set-theme",
windowSetBackground: "reasonix:native:window-set-background",
appZoomGet: "reasonix:native:app-zoom-get",
appZoomSet: "reasonix:native:app-zoom-set",
appZoomReset: "reasonix:native:app-zoom-reset",
browserList: "reasonix:browser:list",
browserOpen: "reasonix:browser:open",
browserClose: "reasonix:browser:close",
@@ -78,7 +78,10 @@ export function installDesktopHostStub(commands: object, options: DesktopHostStu
isMaximised: () => Promise.resolve(false),
minimise: () => {},
toggleMaximise: () => {},
close: () => {},
close: () => {},
getAppZoom: async () => 1,
setAppZoom: async () => 1,
resetAppZoom: async () => 1,
},
getPathForFile: options.getPathForFile ?? (() => ""),
onServiceState: () => () => {},
@@ -245,11 +245,14 @@ export function SettingsPanel({
setConversationWidth(applyConversationWidth(s.conversationWidth));
}, [s?.conversationWidth, s?.desktopTheme, s?.desktopThemeStyle, s?.desktopTerminalTheme]);
useEffect(() => {
if (desktopPlatform !== "windows") return;
const host = desktopHost();
if (host.kind === "none" && desktopPlatform !== "windows") return;
let cancelled = false;
void (async () => {
try {
const persisted = await app.GetDesktopZoomFactor();
const persisted = host.kind === "electron"
? await host.native.getAppZoom()
: await app.GetDesktopZoomFactor();
if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
const snapped = snapZoom(persisted);
saveRestartZoom(snapped);
@@ -367,7 +370,9 @@ export function SettingsPanel({
setWarning(null);
setZoomPct(zoomToPercent(snapped));
try {
await app.SetDesktopZoomFactor(snapped);
const host = desktopHost();
if (host.kind === "electron") await host.native.setAppZoom(snapped);
else await app.SetDesktopZoomFactor(snapped);
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
} catch (e) {
if (seq !== zoomSaveSeq.current) return;
@@ -443,7 +448,7 @@ export function SettingsPanel({
terminalTheme={terminalTheme}
conversationWidth={conversationWidth}
textSize={textSize}
showDisplayZoom={desktopPlatform === "windows"}
showDisplayZoom={desktopHost().kind !== "none" || desktopPlatform === "windows"}
zoomPct={zoomPct}
fontFamily={fontFamily}
monoFontFamily={monoFontFamily}
+12
View File
@@ -39,6 +39,9 @@ export interface ReasonixDesktopHost {
minimise(): void;
toggleMaximise(): void;
close(): void;
getAppZoom(): Promise<number>;
setAppZoom(factor: number): Promise<number>;
resetAppZoom(): Promise<number>;
};
getPathForFile(file: File): string;
onServiceState(cb: (state: ServiceState) => void): () => void;
@@ -63,6 +66,9 @@ export interface DesktopHost {
setWindowTheme(theme: WindowTheme): void;
setWindowBackground(r: number, g: number, b: number, a: number): void;
getWindowBounds(): Promise<WindowBounds> | undefined;
getAppZoom(): Promise<number>;
setAppZoom(factor: number): Promise<number>;
resetAppZoom(): Promise<number>;
onFilesDropped(cb: (paths: string[]) => void): () => void;
getPathForFile?(file: File): string;
onServiceState(cb: (state: ServiceState) => void): () => void;
@@ -97,6 +103,9 @@ const serverHost: DesktopHost = {
getWindowBounds: () => undefined,
onFilesDropped: () => noop,
onServiceState: () => noop,
getAppZoom: async () => 1,
setAppZoom: async () => 1,
resetAppZoom: async () => 1,
},
};
@@ -150,6 +159,9 @@ const electronHostFrom = (host: ReasonixDesktopHost): DesktopHost => {
setWindowTheme: (theme) => host.native.window.setTheme(theme),
setWindowBackground: (r, g, b, a) => host.native.window.setBackgroundColour(r, g, b, a),
getWindowBounds: () => host.native.window.getBounds(),
getAppZoom: () => host.native.window.getAppZoom(),
setAppZoom: (factor) => host.native.window.setAppZoom(factor),
resetAppZoom: () => host.native.window.resetAppZoom(),
onFilesDropped: (cb) => {
installElectronDropHandlers();
dropListeners.add(cb);
+1 -1
View File
@@ -2770,7 +2770,7 @@ export const en = {
"settings.textSizeLarge": "Large",
"settings.textSizeXLarge": "Extra large",
"settings.textSizeXXLarge": "Huge",
"settings.displayZoom": "Display zoom (restart required)",
"settings.displayZoom": "Display zoom (applies immediately)",
"settings.displayZoomDecrease": "Decrease display zoom",
"settings.displayZoomIncrease": "Increase display zoom",
"settings.displayZoomReset": "Reset display zoom to 100%",
+1 -1
View File
@@ -1892,7 +1892,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.textSizeLarge": "大",
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "顯示縮放(需重啟生效)",
"settings.displayZoom": "顯示縮放(立即生效)",
"settings.displayZoomDecrease": "縮小顯示縮放",
"settings.displayZoomIncrease": "放大顯示縮放",
"settings.displayZoomReset": "恢復顯示縮放到 100%",
+1 -1
View File
@@ -2773,7 +2773,7 @@ export const zh: Record<DictKey, string> = {
"settings.textSizeLarge": "大",
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "显示缩放(需重启生效)",
"settings.displayZoom": "显示缩放(立即生效)",
"settings.displayZoomDecrease": "缩小显示缩放",
"settings.displayZoomIncrease": "放大显示缩放",
"settings.displayZoomReset": "恢复显示缩放到 100%",