From ea458f9b7939d0e965f12b7ef1bfcac5fc5981d6 Mon Sep 17 00:00:00 2001 From: Andreas Armanious Date: Tue, 3 Nov 2026 10:53:36 +0100 Subject: [PATCH 1/2] fix(desktop): restore macOS zoom in on non-US layouts --- apps/desktop/src/window/DesktopWindow.test.ts | 44 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 13 ++++++ 2 files changed, 57 insertions(+) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 4f8949823e354..4e989658a7316 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -667,6 +667,50 @@ describe("DesktopWindow", () => { }), ); + it.effect("zooms in from macOS command-plus before renderer shortcuts consume it", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); + if (!beforeInput) + return yield* Effect.die("before-input-event listener was not registered"); + const preventDefault = vi.fn(); + const input = { + type: "keyDown", + key: "+", + code: "BracketRight", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + }; + beforeInput({ preventDefault }, input); + assert.equal(fakeWindow.window.webContents.getZoomLevel(), 0.5); + assert.equal(preventDefault.mock.calls.length, 1); + + beforeInput({ preventDefault }, { ...input, shift: true, code: "Equal" }); + assert.equal(fakeWindow.window.webContents.getZoomLevel(), 1); + for (const overrides of [ + { meta: false }, + { alt: true }, + { control: true }, + { type: "keyUp" }, + ]) { + beforeInput({ preventDefault }, { ...input, ...overrides }); + } + assert.equal(fakeWindow.window.webContents.getZoomLevel(), 1); + assert.equal(preventDefault.mock.calls.length, 2); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 5e02f4cfd8bbe..a159581d9c517 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -661,6 +661,19 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + // Match the layout character before renderer bindings or US-position menu accelerators. + if ( + environment.platform === "darwin" && + input.type === "keyDown" && + input.meta && + !input.control && + !input.alt && + input.key === "+" + ) { + event.preventDefault(); + runFork(zoomWindow(window, "in")); + return; + } // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; From 3474cba4caae44a3f14f7ada0816a5e3878737a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:44:40 +0000 Subject: [PATCH 2/2] fix(desktop): let renderer keybindings claim Cmd++ before app zoom The main-process handler zoomed the app on every macOS Cmd++, so it stole the key from the preview panel's mod++ zoom and from user keybindings. Zoom the app from a renderer keydown observer instead, which runs only when no keybinding consumed the key, through a new zoomAppIn IPC method that reuses DesktopWindow.zoomMain. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 20 +++++++ apps/desktop/src/preload.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 44 --------------- apps/desktop/src/window/DesktopWindow.ts | 13 ----- apps/web/src/appInit.tsx | 2 + .../lib/desktopZoomInFallback.browser.test.ts | 54 +++++++++++++++++++ apps/web/src/lib/desktopZoomInFallback.ts | 28 ++++++++++ packages/contracts/src/ipc.ts | 1 + 10 files changed, 109 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/lib/desktopZoomInFallback.browser.test.ts create mode 100644 apps/web/src/lib/desktopZoomInFallback.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..d81ad16b8be3f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,7 @@ import { openSystemSettings, checkSystemPermission, pasteAsText, + zoomAppIn, probeRemoteEditors, revealWindow, pickFolder, @@ -154,6 +155,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); + yield* ipc.handle(zoomAppIn); yield* ipc.handle(revealWindow); yield* ipc.handle(syncHtmlFullscreen); yield* ipc.handle(probeRemoteEditors); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..498935a226270 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -10,6 +10,7 @@ export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; +export const ZOOM_APP_IN_CHANNEL = "desktop:zoom-app-in"; export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e0eea5c74c9ed..d8849aa29fac9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -30,6 +30,7 @@ import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironm import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; @@ -433,6 +434,25 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); +export const zoomAppIn = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ZOOM_APP_IN_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.zoomAppIn")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + yield* (yield* DesktopWindow.DesktopWindow).zoomMain("in"); + }), +}); + export const revealWindow = DesktopIpc.makeIpcMethod({ channel: IpcChannels.REVEAL_WINDOW_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..c71c71ed3dbe4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -229,6 +229,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), + zoomAppIn: () => ipcRenderer.invoke(IpcChannels.ZOOM_APP_IN_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index f5cb0c4666e6b..91d034cbd772b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -697,50 +697,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("zooms in from macOS command-plus before renderer shortcuts consume it", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const beforeInput = fakeWindow.webContentsListeners.get("before-input-event"); - if (!beforeInput) - return yield* Effect.die("before-input-event listener was not registered"); - const preventDefault = vi.fn(); - const input = { - type: "keyDown", - key: "+", - code: "BracketRight", - meta: true, - control: false, - alt: false, - shift: false, - isAutoRepeat: false, - }; - beforeInput({ preventDefault }, input); - assert.equal(fakeWindow.window.webContents.getZoomLevel(), 0.5); - assert.equal(preventDefault.mock.calls.length, 1); - - beforeInput({ preventDefault }, { ...input, shift: true, code: "Equal" }); - assert.equal(fakeWindow.window.webContents.getZoomLevel(), 1); - for (const overrides of [ - { meta: false }, - { alt: true }, - { control: true }, - { type: "keyUp" }, - ]) { - beforeInput({ preventDefault }, { ...input, ...overrides }); - } - assert.equal(fakeWindow.window.webContents.getZoomLevel(), 1); - assert.equal(preventDefault.mock.calls.length, 2); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("ignores input that arrives after the window is destroyed", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 96f56530dfb24..73f5b41810dae 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -713,19 +713,6 @@ export const make = Effect.gen(function* () { !input.shift && input.key.toLowerCase() === "w", ); - // Match the layout character before renderer bindings or US-position menu accelerators. - if ( - environment.platform === "darwin" && - input.type === "keyDown" && - input.meta && - !input.control && - !input.alt && - input.key === "+" - ) { - event.preventDefault(); - runFork(zoomWindow(window, "in")); - return; - } // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3be9175c4f2e4..b6614ff9cd6a2 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -41,6 +41,7 @@ import { import { syncBrowserChromeTheme } from "./hooks/useTheme"; import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; import { installDesktopPasteAsText } from "./lib/desktopPasteAsText"; +import { installDesktopZoomInFallback } from "./lib/desktopZoomInFallback"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -178,6 +179,7 @@ export function startAppServices(): AppRouter { } installDesktopPasteAsText(window.desktopBridge, window); + installDesktopZoomInFallback(window.desktopBridge, navigator.platform); startClientAppearance(); startDocumentTitle(); diff --git a/apps/web/src/lib/desktopZoomInFallback.browser.test.ts b/apps/web/src/lib/desktopZoomInFallback.browser.test.ts new file mode 100644 index 0000000000000..4c621fa4086bb --- /dev/null +++ b/apps/web/src/lib/desktopZoomInFallback.browser.test.ts @@ -0,0 +1,54 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { registerCommands } from "../shortcuts/dispatcher"; +import { installDesktopZoomInFallback } from "./desktopZoomInFallback"; + +const previewZoomIn: ResolvedKeybindingsConfig = [ + { + command: "preview.zoomIn", + shortcut: { + key: "+", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, +]; + +// A German or Swiss layout types "+" on BracketRight without Shift. +function pressCommandPlus(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "+", + code: "BracketRight", + metaKey: true, + cancelable: true, + bubbles: true, + }); + window.dispatchEvent(event); + return event; +} + +describe("installDesktopZoomInFallback", () => { + it("zooms the app only when no keybinding consumed Cmd++", () => { + const zoomAppIn = vi.fn(async () => {}); + const previewZoom = vi.fn(); + using stack = new DisposableStack(); + stack.defer(installDesktopZoomInFallback({ zoomAppIn }, "MacIntel") ?? (() => {})); + + expect(pressCommandPlus().defaultPrevented).toBe(true); + expect(zoomAppIn).toHaveBeenCalledOnce(); + + stack.defer( + registerCommands( + { "preview.zoomIn": previewZoom }, + { keybindings: () => previewZoomIn, context: () => ({ previewFocus: true }) }, + ), + ); + pressCommandPlus(); + expect(previewZoom).toHaveBeenCalledOnce(); + expect(zoomAppIn).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/lib/desktopZoomInFallback.ts b/apps/web/src/lib/desktopZoomInFallback.ts new file mode 100644 index 0000000000000..99c377c6723ae --- /dev/null +++ b/apps/web/src/lib/desktopZoomInFallback.ts @@ -0,0 +1,28 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; + +import { registerCommands } from "../shortcuts/dispatcher"; + +/** + * macOS matches the Zoom In menu shortcut by US key position, so Cmd++ does nothing on layouts + * with "+" elsewhere. Zoom the app when no keybinding (such as preview zoom) consumed the key. + */ +export function installDesktopZoomInFallback( + bridge: Pick | undefined, + platform: string, +): (() => void) | undefined { + if (!bridge || !isMacPlatform(platform)) return undefined; + return registerCommands( + {}, + { + observe: (event, consumed) => { + if (consumed || !event.metaKey || event.ctrlKey || event.altKey || event.key !== "+") { + return; + } + // Keeps the US-layout menu accelerator from zooming a second time. + event.preventDefault(); + void bridge.zoomAppIn(); + }, + }, + ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..f398ba701e630 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1123,6 +1123,7 @@ export interface DesktopBridge { checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; pasteAsText: () => Promise; + zoomAppIn: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void; onQuitShortcut: (listener: (event: QuitShortcutHintEvent) => void) => () => void;