From 9042fc98e2f9d0ab6e825c825998c3067176b478 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Wed, 30 Sep 2026 18:06:03 +0200 Subject: [PATCH 1/3] feat(desktop): remember page zoom and add a sidebar zoom control on macOS and Linux --- .../capabilities/dashboard-zoom.json | 2 +- desktop/src-tauri/src/lib.rs | 10 +- desktop/src-tauri/src/window.rs | 6 +- .../src/content/docs/guides/desktop-app.md | 8 + gui/design-system/components.md | 1 + gui/src/App.tsx | 9 ++ gui/src/components/desktop-zoom-control.tsx | 32 ++++ gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/icons.tsx | 1 + gui/src/lib/desktop-zoom.ts | 121 ++++++++++++++ gui/src/styles.css | 30 ++++ gui/src/use-desktop-zoom.ts | 62 ++++++++ gui/tests/desktop-zoom-dom.test.tsx | 118 ++++++++++++++ gui/tests/desktop-zoom.test.ts | 149 ++++++++++++++++++ gui/tests/fr-localization.test.ts | 2 + structure/desktop-shell.md | 11 +- 25 files changed, 589 insertions(+), 13 deletions(-) create mode 100644 gui/src/components/desktop-zoom-control.tsx create mode 100644 gui/src/lib/desktop-zoom.ts create mode 100644 gui/src/use-desktop-zoom.ts create mode 100644 gui/tests/desktop-zoom-dom.test.tsx create mode 100644 gui/tests/desktop-zoom.test.ts diff --git a/desktop/src-tauri/capabilities/dashboard-zoom.json b/desktop/src-tauri/capabilities/dashboard-zoom.json index dc2b0734244..423f599e967 100644 --- a/desktop/src-tauri/capabilities/dashboard-zoom.json +++ b/desktop/src-tauri/capabilities/dashboard-zoom.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "dashboard-zoom", - "description": "Page zoom hotkeys for the main window, including the loopback dashboard", + "description": "Page zoom for the main window, set by the loopback dashboard's own zoom control and hotkeys", "windows": ["main"], "remote": { "urls": ["http://127.0.0.1:*"] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b8e6e9fe7d7..f8aa089373f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -318,10 +318,12 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) - // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on - // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted - // to the loopback dashboard by `capabilities/dashboard-zoom.json`. - .zoom_hotkeys_enabled(true) + // Windows keeps WebView2's native zoom. On macOS and Linux the dashboard owns + // page zoom (`gui/src/lib/desktop-zoom.ts`): Tauri's keydown polyfill keeps the + // level in a script variable that restarts at 1 on every page load, so it can + // neither remember the level nor stay in step with the sidebar control. The + // dashboard's one IPC call is granted by `capabilities/dashboard-zoom.json`. + .zoom_hotkeys_enabled(cfg!(target_os = "windows")) .on_navigation(window::navigation_allowed(app.handle().clone())) .on_new_window(window::open_new_windows_in_default_browser()) // A hidden window still loads pages: wry builds this one with WebView2 diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index b5a90c8cbf5..b7c1197242d 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -273,9 +273,9 @@ mod tests { } } - /// The zoom polyfill runs inside the loopback dashboard, which is a remote origin to Tauri. The - /// capability that lets it call `set_webview_zoom` is the only one reaching that origin, so it - /// stays pinned to this window, this origin and this one command. + /// The dashboard's zoom control runs inside the loopback dashboard, which is a remote origin to + /// Tauri. The capability that lets it call `set_webview_zoom` is the only one reaching that + /// origin, so it stays pinned to this window, this origin and this one command. #[test] fn the_dashboard_reaches_only_the_zoom_command() { let zoom: serde_json::Value = diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 64f06a5dd2c..4c24c70e6a5 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -73,6 +73,14 @@ and launcher installation or repair stays disabled while that ownership remains; Normal desktop updates replace the bundled CLI with the fixed startup probe. No local patch needs to be preserved across an update. +## Zoom + +On macOS and Linux, Cmd (macOS) or Ctrl (Linux) with `+`, `-` and `0`, or Ctrl with the mouse wheel, +zooms the window between 50% and 300% in 10% steps, and `0` returns to 100%. The sidebar shows the +same level with `-`, `+` and a reset button beside the theme and language rows. The level is +remembered and applied again the next time the app starts. Windows uses the browser engine's own +zoom, which is not remembered. + ## Keeping the proxy running The app keeps the proxy it started running. When that proxy restarts itself — after diff --git a/gui/design-system/components.md b/gui/design-system/components.md index 309bbaf5948..bbc15fe18d9 100644 --- a/gui/design-system/components.md +++ b/gui/design-system/components.md @@ -8,6 +8,7 @@ - `.nav-item`은 아이콘 17px, control text, 4px 세로 간격을 사용한다. - hover와 active는 같은 surface family를 쓰되 active는 semibold로 구분한다. - 메뉴마다 margin을 직접 추가하지 않고 `.sidebar nav`의 `gap`을 사용한다. +- 데스크톱 레이아웃에서 `.sidebar nav`는 스크롤 영역이고 footer(언어, 테마, 줌, 프록시)는 항상 보인다. 창이 낮거나 페이지 줌으로 CSS 뷰포트가 줄어도 footer 컨트롤에 닿을 수 있어야 한다. 데스크톱 셸의 줌 컨트롤(`.zoom-control`)은 테마 행과 같은 행 높이를 쓴다. ## Buttons diff --git a/gui/src/App.tsx b/gui/src/App.tsx index ef9f1a2b245..283df3afce0 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -29,7 +29,10 @@ import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; import { confirmAction } from "./action-dialogs"; import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell"; +import { zoomManagedOn } from "./lib/desktop-zoom"; import { useSidebarCollapse } from "./use-sidebar-collapse"; +import { useDesktopZoom } from "./use-desktop-zoom"; +import { DesktopZoomControl } from "./components/desktop-zoom-control"; import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar"; import { watchMacTitlebarMetrics, windowChromeHandlers } from "./lib/window-chrome"; @@ -216,6 +219,8 @@ export default function App() { const desktopShell = isDesktopShell(); const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse({ shortcut: desktopShell }); const desktopMac = desktopShell && hostOs() === "macos"; + const zoomManaged = desktopShell && zoomManagedOn(hostOs()); + const desktopZoom = useDesktopZoom({ managed: zoomManaged }); const appRef = useRef(null); useEffect(() => { if (desktopMac && appRef.current) return watchMacTitlebarMetrics(appRef.current); @@ -488,6 +493,10 @@ export default function App() { aria-label={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`} title={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`}> {t(THEME_TKEY[theme])} + {zoomManaged && ( + + )}
{t("dash.actions")}
diff --git a/gui/src/components/desktop-zoom-control.tsx b/gui/src/components/desktop-zoom-control.tsx new file mode 100644 index 00000000000..03998109417 --- /dev/null +++ b/gui/src/components/desktop-zoom-control.tsx @@ -0,0 +1,32 @@ +import { IconMinus, IconPlus } from "../icons"; +import { useT } from "../i18n/shared"; +import type { ZoomAction } from "../lib/desktop-zoom"; + +interface DesktopZoomControlProps { + percent: number; + canZoomIn: boolean; + canZoomOut: boolean; + onStep: (action: ZoomAction) => void; +} + +/** Sidebar row for the desktop window's page zoom; the same steps as Ctrl/Cmd + plus, minus and zero. */ +export function DesktopZoomControl({ percent, canZoomIn, canZoomOut, onStep }: DesktopZoomControlProps) { + const t = useT(); + return ( +
+ {t("zoom.label")} + + + +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11cbe22b5cf..02c6520003a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -218,6 +218,10 @@ export const de: Record = { "theme.light": "Hell", "theme.dark": "Dunkel", "theme.system": "System", + "zoom.label": "Zoom", + "zoom.out": "Verkleinern", + "zoom.in": "Vergrößern", + "zoom.reset": "Zoom auf 100 % zurücksetzen", "lang.label": "Sprache", "lang.nativeName": "Deutsch", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0731c6aabd6..34fece4ae48 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -156,6 +156,10 @@ export const en = { "theme.light": "Light", "theme.dark": "Dark", "theme.system": "System", + "zoom.label": "Zoom", + "zoom.out": "Zoom out", + "zoom.in": "Zoom in", + "zoom.reset": "Reset zoom to 100%", "lang.label": "Language", "lang.nativeName": "English", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 238ae04362f..49e23ca738d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -153,6 +153,10 @@ export const fr: Record = { "theme.light": "Clair", "theme.dark": "Sombre", "theme.system": "Système", + "zoom.label": "Zoom", + "zoom.out": "Zoom arrière", + "zoom.in": "Zoom avant", + "zoom.reset": "Réinitialiser le zoom à 100 %", "lang.label": "Langue", "lang.nativeName": "Français", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index bd7704fdd24..db72bd2a3de 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -223,6 +223,10 @@ export const ja: Record = { "theme.light": "ライト", "theme.dark": "ダーク", "theme.system": "システム", + "zoom.label": "表示倍率", + "zoom.out": "縮小", + "zoom.in": "拡大", + "zoom.reset": "倍率を100%に戻す", "lang.label": "言語", "lang.nativeName": "日本語", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 236a2b2d168..faca97d7eb9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -218,6 +218,10 @@ export const ko: Record = { "theme.light": "라이트", "theme.dark": "다크", "theme.system": "시스템", + "zoom.label": "확대/축소", + "zoom.out": "축소", + "zoom.in": "확대", + "zoom.reset": "확대/축소를 100%로 재설정", "lang.label": "언어", "lang.nativeName": "한국어", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b4042ab2cd6..029dcf8b6b6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -223,6 +223,10 @@ export const ru: Record = { "theme.light": "Светлая", "theme.dark": "Тёмная", "theme.system": "Системная", + "zoom.label": "Масштаб", + "zoom.out": "Уменьшить", + "zoom.in": "Увеличить", + "zoom.reset": "Сбросить масштаб до 100 %", "lang.label": "Язык", "lang.nativeName": "Русский", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b954a474aa5..65eac2271a5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -155,6 +155,10 @@ export const tr: Record = { "theme.light": "Açık", "theme.dark": "Koyu", "theme.system": "Sistem", + "zoom.label": "Yakınlaştırma", + "zoom.out": "Uzaklaştır", + "zoom.in": "Yakınlaştır", + "zoom.reset": "Yakınlaştırmayı %100'e sıfırla", "lang.label": "Dil", "lang.nativeName": "Türkçe", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0248bc02aed..d6f32e8c7d2 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -153,6 +153,10 @@ export const vi: Record = { "theme.light": "Sáng", "theme.dark": "Tối", "theme.system": "Hệ thống", + "zoom.label": "Thu phóng", + "zoom.out": "Thu nhỏ", + "zoom.in": "Phóng to", + "zoom.reset": "Đặt lại thu phóng về 100%", "lang.label": "Ngôn ngữ", "lang.nativeName": "Tiếng Việt", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6752777be49..129705749da 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -121,6 +121,10 @@ export const zhTW: Record = { "theme.light": "淺色", "theme.dark": "深色", "theme.system": "跟隨系統", + "zoom.label": "縮放", + "zoom.out": "縮小", + "zoom.in": "放大", + "zoom.reset": "重設縮放為 100%", "lang.label": "語言", "errorBoundary.title": "頁面載入失敗", "errorBoundary.message": "此部分在渲染時發生錯誤。請重新載入後再試。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 90d200bca96..55d6821f99c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -218,6 +218,10 @@ export const zh: Record = { "theme.light": "浅色", "theme.dark": "深色", "theme.system": "跟随系统", + "zoom.label": "缩放", + "zoom.out": "缩小", + "zoom.in": "放大", + "zoom.reset": "将缩放重置为 100%", "lang.label": "语言", "lang.nativeName": "中文", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 8aafcb220ac..c77c7597131 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -21,6 +21,7 @@ export const IconHardDrive = (p: P) => ( (); export const IconX = (p: P) => (); export const IconPlus = (p: P) => (); +export const IconMinus = (p: P) => (); export const IconRefresh = (p: P) => (); export const IconPause = (p: P) => (); export const IconPlay = (p: P) => (); diff --git a/gui/src/lib/desktop-zoom.ts b/gui/src/lib/desktop-zoom.ts new file mode 100644 index 00000000000..4c2063d9c39 --- /dev/null +++ b/gui/src/lib/desktop-zoom.ts @@ -0,0 +1,121 @@ +/** + * Page zoom for the desktop shell, owned by the dashboard. + * + * Tauri's own zoom-hotkey polyfill keeps the level in a script variable that starts at 1 on + * every page load: a restart forgot the level, and so did the hop from the bootstrap page to + * the dashboard, after which the first shortcut jumped from the real level to 120%. The + * dashboard therefore keeps the level itself, remembers it, and drives the webview through the + * one command `desktop/src-tauri/capabilities/dashboard-zoom.json` already grants to this + * origin. Outside the shell the global is absent and every call is a no-op. + */ +import type { HostOs } from "./desktop-shell"; + +export const ZOOM_STORAGE_KEY = "ocx-desktop-zoom"; +export const ZOOM_MIN = 0.5; +export const ZOOM_MAX = 3; +export const ZOOM_STEP = 0.1; +export const ZOOM_DEFAULT = 1; + +export type ZoomAction = "in" | "out" | "reset"; + +declare global { + interface Window { + __TAURI_INTERNALS__?: { + invoke?: (command: string, args?: Record) => Promise; + }; + } +} + +/** + * Whether the dashboard drives zoom on this host. Windows keeps WebView2's native zoom, which + * the shell leaves enabled there, so a second owner would only disagree with it. + */ +export function zoomManagedOn(os: HostOs): boolean { + return os === "macos" || os === "linux"; +} + +/** Whole percent steps keep repeated presses from drifting into 1.2000000000000002. */ +export function clampZoom(value: number): number { + if (!Number.isFinite(value)) return ZOOM_DEFAULT; + const bounded = Math.min(Math.max(value, ZOOM_MIN), ZOOM_MAX); + return Math.round(bounded * 100) / 100; +} + +export function stepZoom(current: number, action: ZoomAction): number { + if (action === "reset") return ZOOM_DEFAULT; + return clampZoom(current + (action === "in" ? ZOOM_STEP : -ZOOM_STEP)); +} + +export function zoomPercent(zoom: number): number { + return Math.round(zoom * 100); +} + +export function readSavedZoom( + storage: Pick | undefined = typeof localStorage === "undefined" ? undefined : localStorage, +): number { + try { + const raw = storage?.getItem(ZOOM_STORAGE_KEY); + if (raw === null || raw === undefined) return ZOOM_DEFAULT; + return clampZoom(Number(raw)); + } catch { + return ZOOM_DEFAULT; + } +} + +export function writeSavedZoom( + zoom: number, + storage: Pick | undefined = typeof localStorage === "undefined" ? undefined : localStorage, +): void { + try { + storage?.setItem(ZOOM_STORAGE_KEY, String(zoom)); + } catch { + // A storageless context still zooms for the session. + } +} + +/** + * The key combinations the shell always answered to: Cmd on macOS, Ctrl elsewhere, with plus, + * minus and zero. `=` is accepted because plus shares its key on most layouts. + */ +export function zoomKeyAction( + event: Pick, + os: HostOs, +): ZoomAction | null { + const modifier = os === "macos" ? event.metaKey : event.ctrlKey; + if (!modifier || event.altKey) return null; + if (event.key === "-" || event.key === "_") return "out"; + if (event.key === "=" || event.key === "+") return "in"; + if (event.key === "0") return "reset"; + return null; +} + +/** Distance a Ctrl + wheel gesture must add up to before it steps once. */ +export const WHEEL_STEP_PX = 50; + +/** + * Ctrl + wheel, as before: up zooms in. A mouse notch is one step, while a touchpad pinch + * arrives as many tiny deltas, so the distance accumulates instead of stepping on every event. + */ +export function accumulateWheel( + accumulated: number, + deltaY: number, +): { accumulated: number; action: ZoomAction | null } { + if (!Number.isFinite(deltaY) || deltaY === 0) return { accumulated, action: null }; + // A change of direction starts a new gesture rather than cancelling the last one. + const carried = Math.sign(accumulated) === Math.sign(deltaY) ? accumulated : 0; + const total = carried + deltaY; + if (Math.abs(total) < WHEEL_STEP_PX) return { accumulated: total, action: null }; + return { accumulated: 0, action: total < 0 ? "in" : "out" }; +} + +/** Asks the shell to set the webview zoom. Resolves false when there is no shell to ask. */ +export async function applyWebviewZoom(zoom: number): Promise { + const invoke = window.__TAURI_INTERNALS__?.invoke ?? window.__TAURI__?.core?.invoke; + if (!invoke) return false; + try { + await invoke("plugin:webview|set_webview_zoom", { value: zoom }); + return true; + } catch { + return false; + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index b4c9cd622fb..ee31def1ee3 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -292,6 +292,20 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } flex-direction: column; gap: 4px; } +/* Page zoom shrinks the CSS viewport, so a zoomed or short window is shorter than the menu plus + the footer. On the desktop layout the menu scrolls and the footer (language, theme, zoom, proxy) + stays reachable; the narrow layout keeps its drawer, which already scrolls as a whole. The + padding and the equal negative margin keep focus rings at the edges from being clipped by the + scroll box without moving anything. */ +@media (min-width: 761px) { + .sidebar nav { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px; + margin: -4px; + } +} .nav-item { display: flex; align-items: center; gap: 10px; @@ -401,6 +415,22 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .theme-toggle:hover { background: var(--accent-soft); color: var(--text); } .theme-toggle svg { width: 16px; height: 16px; flex-shrink: 0; } .theme-toggle .mode { text-transform: capitalize; } +/* Desktop shell only: page zoom beside the theme and language rows. Same row metrics as + .theme-toggle so the three controls read as one group. */ +.zoom-control { display: flex; align-items: center; gap: 4px; padding: 4px 10px; width: 100%; color: var(--muted); font-size: var(--text-control); } +.zoom-control__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zoom-control__btn, .zoom-control__value { + display: inline-flex; align-items: center; justify-content: center; + height: 26px; padding: 0; border: none; border-radius: var(--radius-sm); + background: none; color: inherit; font: inherit; cursor: pointer; + transition: background var(--motion-fast), color var(--motion-fast); +} +.zoom-control__btn { width: 26px; } +.zoom-control__btn svg { width: 14px; height: 14px; } +.zoom-control__value { min-width: 3.25rem; padding: 0 6px; font-variant-numeric: tabular-nums; } +.zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); } +.zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.zoom-control__btn:disabled { opacity: 0.5; cursor: default; } .stop-toggle { color: var(--red); } .stop-toggle:hover { background: var(--red-soft); color: var(--red); } .stop-toggle:disabled { opacity: 0.5; cursor: default; } diff --git a/gui/src/use-desktop-zoom.ts b/gui/src/use-desktop-zoom.ts new file mode 100644 index 00000000000..fde3dd0ad1e --- /dev/null +++ b/gui/src/use-desktop-zoom.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { hostOs } from "./lib/desktop-shell"; +import { + accumulateWheel, + applyWebviewZoom, + readSavedZoom, + stepZoom, + writeSavedZoom, + ZOOM_MAX, + ZOOM_MIN, + zoomKeyAction, + zoomPercent, + type ZoomAction, +} from "./lib/desktop-zoom"; + +/** + * Owns the desktop window's page zoom: the remembered level, the keyboard and Ctrl + wheel + * gestures, and the sidebar control. Inert outside the shell. The level is applied on mount as + * well as on every change, so a restart or a page navigation never leaves the webview at a + * level the dashboard does not know about. + */ +export function useDesktopZoom( + { managed }: { managed: boolean }, +): { zoom: number; percent: number; canZoomIn: boolean; canZoomOut: boolean; step: (action: ZoomAction) => void } { + const [zoom, setZoom] = useState(readSavedZoom); + const step = useCallback((action: ZoomAction) => setZoom((current) => stepZoom(current, action)), []); + const wheelDistance = useRef(0); + + // Updaters may run without a commit, so the side effects follow the render instead. + useEffect(() => { + if (!managed) return; + void applyWebviewZoom(zoom); + writeSavedZoom(zoom); + }, [managed, zoom]); + + useEffect(() => { + if (!managed) return; + const os = hostOs(); + const onKey = (event: KeyboardEvent) => { + const action = zoomKeyAction(event, os); + if (!action) return; + event.preventDefault(); + step(action); + }; + const onWheel = (event: WheelEvent) => { + if (!event.ctrlKey) return; + // Without this the webview would also apply its own pinch zoom under the dashboard's. + event.preventDefault(); + const next = accumulateWheel(wheelDistance.current, event.deltaY); + wheelDistance.current = next.accumulated; + if (next.action) step(next.action); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("wheel", onWheel, { passive: false }); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("wheel", onWheel); + }; + }, [managed, step]); + + return { zoom, percent: zoomPercent(zoom), canZoomIn: zoom < ZOOM_MAX, canZoomOut: zoom > ZOOM_MIN, step }; +} diff --git a/gui/tests/desktop-zoom-dom.test.tsx b/gui/tests/desktop-zoom-dom.test.tsx new file mode 100644 index 00000000000..89bcb3e26d2 --- /dev/null +++ b/gui/tests/desktop-zoom-dom.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { DesktopZoomControl } from "../src/components/desktop-zoom-control"; +import { LanguageProvider } from "../src/i18n/provider"; +import { ZOOM_MAX, ZOOM_STORAGE_KEY } from "../src/lib/desktop-zoom"; +import { useDesktopZoom } from "../src/use-desktop-zoom"; + +/** + * The remembered level actually reaches the webview: applied on mount (so a restart or a page + * navigation cannot leave the window at a level the dashboard does not know), moved by the + * shortcut, and written back. Rendered against a stand-in shell that records its commands. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const LINUX_SHELL = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) OpenCodexDesktop/2.75.0"; + +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let root: Root | null = null; +let calls: Array<{ command: string; args?: Record }> = []; + +function Probe({ managed }: { managed: boolean }) { + const zoom = useDesktopZoom({ managed }); + return ; +} + +function mount(node: React.ReactElement, saved?: string) { + win = new Window({ url: "http://127.0.0.1:10100/", settings: { navigator: { userAgent: LINUX_SHELL } } }); + previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; + calls = []; + if (saved !== undefined) win.localStorage.setItem(ZOOM_STORAGE_KEY, saved); + Reflect.set(win, "__TAURI_INTERNALS__", { + invoke: (command: string, args?: Record) => { calls.push({ command, args }); return Promise.resolve(); }, + }); + // Plain assignment fails in a whole-suite run: an earlier DOM test leaves `document` + // installed as a non-writable global, so only defineProperty works. `writable` stays on so + // this file never does the same to the tests that run after it (several of them assign + // `localStorage` and `navigator` directly). + Object.defineProperties(globalThis, { + document: { configurable: true, writable: true, value: win.document }, + window: { configurable: true, writable: true, value: win }, + navigator: { configurable: true, writable: true, value: win.navigator }, + localStorage: { configurable: true, writable: true, value: win.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, + }); + const host = win.document.createElement("div") as never as HTMLElement; + win.document.body.appendChild(host as never); + act(() => { root = createRoot(host); root.render({node}); }); + return host; +} + +function press(init: KeyboardEventInit) { + const event = new win.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + act(() => { win.dispatchEvent(event); }); + return event; +} + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + for (const k of globals) Object.defineProperty(globalThis, k, { configurable: true, writable: true, value: previous?.[k] }); +}); + +const zoomCalls = () => calls.filter(call => call.command === "plugin:webview|set_webview_zoom").map(call => call.args?.value); + +test("the remembered level is applied when the dashboard starts", () => { + const el = mount(, "1.3"); + expect(zoomCalls()).toEqual([1.3]); + expect(el.querySelector(".zoom-control__value")?.textContent).toBe("130%"); +}); + +test("a first start applies 100%, so the webview and the dashboard agree", () => { + mount(); + expect(zoomCalls()).toEqual([1]); +}); + +test("Ctrl plus steps up from the remembered level, applies it and saves it", () => { + const el = mount(, "1.3"); + const event = press({ key: "=", ctrlKey: true }); + expect(event.defaultPrevented).toBe(true); + expect(zoomCalls()).toEqual([1.3, 1.4]); + expect(win.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe("1.4"); + expect(el.querySelector(".zoom-control__value")?.textContent).toBe("140%"); +}); + +test("Ctrl zero returns to 100%", () => { + mount(, "1.7"); + press({ key: "0", ctrlKey: true }); + expect(zoomCalls().at(-1)).toBe(1); + expect(win.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe("1"); +}); + +test("the sidebar buttons step and reset the same level", () => { + const el = mount(, "1"); + const [out, , plus] = Array.from(el.querySelectorAll("button")) as never as HTMLElement[]; + act(() => { plus!.click(); }); + act(() => { plus!.click(); }); + act(() => { out!.click(); }); + expect(zoomCalls()).toEqual([1, 1.1, 1.2, 1.1]); + act(() => { (el.querySelector(".zoom-control__value") as never as HTMLElement).click(); }); + expect(zoomCalls().at(-1)).toBe(1); +}); + +test("the plus button disables at the ceiling", () => { + const el = mount(, String(ZOOM_MAX)); + const plus = el.querySelectorAll("button")[2] as never as HTMLButtonElement; + expect(plus.disabled).toBe(true); +}); + +test("outside the managed hosts nothing is applied or intercepted", () => { + mount(, "1.3"); + const event = press({ key: "=", ctrlKey: true }); + expect(event.defaultPrevented).toBe(false); + expect(zoomCalls()).toEqual([]); + expect(win.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe("1.3"); +}); diff --git a/gui/tests/desktop-zoom.test.ts b/gui/tests/desktop-zoom.test.ts new file mode 100644 index 00000000000..8b6d0655cce --- /dev/null +++ b/gui/tests/desktop-zoom.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { + accumulateWheel, + applyWebviewZoom, + clampZoom, + readSavedZoom, + stepZoom, + WHEEL_STEP_PX, + writeSavedZoom, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_STORAGE_KEY, + zoomKeyAction, + zoomManagedOn, + zoomPercent, +} from "../src/lib/desktop-zoom"; + +/** + * Desktop page zoom. Tauri's polyfill restarted at 100% on every page load and never saved + * the level; these pin the dashboard-owned replacement: the same keys and steps, a remembered + * level, and a level that can never leave the range or drift off whole percents. + */ +describe("zoom levels", () => { + test("steps by ten points and returns to 100% on reset", () => { + expect(stepZoom(1, "in")).toBe(1.1); + expect(stepZoom(1, "out")).toBe(0.9); + expect(stepZoom(1.7, "reset")).toBe(1); + }); + + test("repeated steps stay on whole percents", () => { + let zoom = 1; + for (let i = 0; i < 7; i += 1) zoom = stepZoom(zoom, "in"); + expect(zoom).toBe(1.7); + expect(zoomPercent(zoom)).toBe(170); + }); + + test("never leaves the supported range", () => { + let zoom = 1; + for (let i = 0; i < 100; i += 1) zoom = stepZoom(zoom, "in"); + expect(zoom).toBe(ZOOM_MAX); + for (let i = 0; i < 100; i += 1) zoom = stepZoom(zoom, "out"); + expect(zoom).toBe(ZOOM_MIN); + }); + + test("a non-finite level falls back to 100%", () => { + expect(clampZoom(Number.NaN)).toBe(1); + expect(clampZoom(Number.POSITIVE_INFINITY)).toBe(1); + }); +}); + +describe("remembered level", () => { + const memory = (initial?: string) => { + const values = new Map(initial === undefined ? [] : [[ZOOM_STORAGE_KEY, initial]]); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + }; + + test("round-trips through storage", () => { + const storage = memory(); + writeSavedZoom(1.3, storage); + expect(readSavedZoom(storage)).toBe(1.3); + }); + + test("an empty, garbled or out-of-range value reads as something safe", () => { + expect(readSavedZoom(memory())).toBe(1); + expect(readSavedZoom(memory("wide"))).toBe(1); + expect(readSavedZoom(memory("9"))).toBe(ZOOM_MAX); + expect(readSavedZoom(memory("0.01"))).toBe(ZOOM_MIN); + }); + + test("a storage that throws neither reads nor writes loudly", () => { + const broken = { + getItem: () => { throw new Error("denied"); }, + setItem: () => { throw new Error("denied"); }, + }; + expect(readSavedZoom(broken)).toBe(1); + expect(() => writeSavedZoom(1.2, broken)).not.toThrow(); + }); +}); + +describe("key bindings", () => { + const press = (key: string, mods: Partial> = {}) => + ({ key, ctrlKey: false, metaKey: false, altKey: false, ...mods }); + + test("Ctrl drives zoom on Linux and Cmd on macOS, never the other", () => { + expect(zoomKeyAction(press("=", { ctrlKey: true }), "linux")).toBe("in"); + expect(zoomKeyAction(press("+", { ctrlKey: true }), "linux")).toBe("in"); + expect(zoomKeyAction(press("-", { ctrlKey: true }), "linux")).toBe("out"); + expect(zoomKeyAction(press("0", { ctrlKey: true }), "linux")).toBe("reset"); + expect(zoomKeyAction(press("=", { metaKey: true }), "macos")).toBe("in"); + expect(zoomKeyAction(press("=", { metaKey: true }), "linux")).toBeNull(); + expect(zoomKeyAction(press("=", { ctrlKey: true }), "macos")).toBeNull(); + }); + + test("a bare key, another key, or an Alt chord is ignored", () => { + expect(zoomKeyAction(press("="), "linux")).toBeNull(); + expect(zoomKeyAction(press("b", { ctrlKey: true }), "linux")).toBeNull(); + expect(zoomKeyAction(press("=", { ctrlKey: true, altKey: true }), "linux")).toBeNull(); + }); + + test("only macOS and Linux are managed by the dashboard", () => { + expect(zoomManagedOn("linux")).toBe(true); + expect(zoomManagedOn("macos")).toBe(true); + expect(zoomManagedOn("windows")).toBe(false); + expect(zoomManagedOn("unknown")).toBe(false); + }); +}); + +describe("wheel gestures", () => { + test("one mouse notch is one step, up zooming in", () => { + expect(accumulateWheel(0, -100)).toEqual({ accumulated: 0, action: "in" }); + expect(accumulateWheel(0, 100)).toEqual({ accumulated: 0, action: "out" }); + }); + + test("small touchpad deltas add up before they step", () => { + let acc = 0; + let steps = 0; + for (let i = 0; i < 20; i += 1) { + const next = accumulateWheel(acc, -5); + acc = next.accumulated; + if (next.action) steps += 1; + } + expect(steps).toBe(Math.floor((20 * 5) / WHEEL_STEP_PX)); + }); + + test("reversing direction starts a new gesture", () => { + const first = accumulateWheel(0, -30); + expect(first.action).toBeNull(); + expect(accumulateWheel(first.accumulated, 30).accumulated).toBe(30); + }); + + test("a zero delta changes nothing", () => { + expect(accumulateWheel(-20, 0)).toEqual({ accumulated: -20, action: null }); + }); +}); + +describe("talking to the shell", () => { + test("without a shell nothing is applied and nothing throws", async () => { + const saved = Reflect.get(globalThis, "window"); + Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); + try { + expect(await applyWebviewZoom(1.2)).toBe(false); + } finally { + Object.defineProperty(globalThis, "window", { configurable: true, value: saved }); + } + }); +}); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 10376ac041e..c1946bc4b1c 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -32,6 +32,8 @@ const INTENTIONAL_ENGLISH = new Set([ // "auto" is the same word in French, and it labels a machine-derived alias source rather // than prose. Translating it would invent a difference the UI does not have. "models.aliasAuto", + // "Zoom" is the ordinary French word for page zoom too. + "zoom.label", "common.github", // Product names and ordinary French words whose correct spelling is identical to English. "remote.pairingCommandWindows", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 2369f2f25e7..97000013fab 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -28,11 +28,12 @@ none of the matching WKUIDelegate panel methods on macOS, so a platform dialog i drawing anything. `withGlobalTauri` is on so that page can invoke without a bundler. The bootstrap commands are granted to the local app origin only: `capabilities/default.json` declares no `remote` entry, and -Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. The main -window enables Tauri's zoom hotkeys (Cmd or Ctrl with + / - / 0); WebView2 handles them natively, but -on macOS and Linux Tauri injects a keydown polyfill that calls `set_webview_zoom` from whatever page -is loaded, including the loopback dashboard. `capabilities/dashboard-zoom.json` grants that single -command to the main window for `http://127.0.0.1:*`, and a test in `window.rs` pins its shape. +Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. WebView2 +handles Ctrl + / - / 0 natively, so the builder leaves Tauri's zoom hotkeys on for Windows only; on macOS +and Linux the dashboard owns zoom (`gui/src/lib/desktop-zoom.ts`): it handles the same keys and Ctrl + +wheel, keeps the level in `localStorage` and re-applies it at start, and calls `set_webview_zoom`. Tauri's +own polyfill cannot do that, since its level restarts at 1 on every page load. `capabilities/dashboard-zoom.json` +grants that single command to the main window for `http://127.0.0.1:*`, and a test in `window.rs` pins its shape. The main window carries an integrated title bar on macOS: the builder sets `TitleBarStyle::Overlay` with `hidden_title`, so the webview draws to the top of the window and From 63939944cb91c59204da7796fc14fe4d02443c6a Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Wed, 30 Sep 2026 20:11:55 +0200 Subject: [PATCH 2/3] fix(gui): move the sidebar zoom styles out of the size-capped stylesheet --- gui/src/main.tsx | 1 + gui/src/styles.css | 30 ----------------------------- gui/src/styles/sidebar-zoom.css | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 gui/src/styles/sidebar-zoom.css diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 49db60f99e5..332f0bb3869 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -19,6 +19,7 @@ import "./styles/lazycodex-role-models.css"; import "./styles/claude-desktop-picker.css"; import "./styles/anthropic-reset-grants.css"; import "./styles/star-onboarding.css"; +import "./styles/sidebar-zoom.css"; import "./styles/protocol-evidence.css"; import "./pages/tray.css"; diff --git a/gui/src/styles.css b/gui/src/styles.css index ee31def1ee3..b4c9cd622fb 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -292,20 +292,6 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } flex-direction: column; gap: 4px; } -/* Page zoom shrinks the CSS viewport, so a zoomed or short window is shorter than the menu plus - the footer. On the desktop layout the menu scrolls and the footer (language, theme, zoom, proxy) - stays reachable; the narrow layout keeps its drawer, which already scrolls as a whole. The - padding and the equal negative margin keep focus rings at the edges from being clipped by the - scroll box without moving anything. */ -@media (min-width: 761px) { - .sidebar nav { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 4px; - margin: -4px; - } -} .nav-item { display: flex; align-items: center; gap: 10px; @@ -415,22 +401,6 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .theme-toggle:hover { background: var(--accent-soft); color: var(--text); } .theme-toggle svg { width: 16px; height: 16px; flex-shrink: 0; } .theme-toggle .mode { text-transform: capitalize; } -/* Desktop shell only: page zoom beside the theme and language rows. Same row metrics as - .theme-toggle so the three controls read as one group. */ -.zoom-control { display: flex; align-items: center; gap: 4px; padding: 4px 10px; width: 100%; color: var(--muted); font-size: var(--text-control); } -.zoom-control__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.zoom-control__btn, .zoom-control__value { - display: inline-flex; align-items: center; justify-content: center; - height: 26px; padding: 0; border: none; border-radius: var(--radius-sm); - background: none; color: inherit; font: inherit; cursor: pointer; - transition: background var(--motion-fast), color var(--motion-fast); -} -.zoom-control__btn { width: 26px; } -.zoom-control__btn svg { width: 14px; height: 14px; } -.zoom-control__value { min-width: 3.25rem; padding: 0 6px; font-variant-numeric: tabular-nums; } -.zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); } -.zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } -.zoom-control__btn:disabled { opacity: 0.5; cursor: default; } .stop-toggle { color: var(--red); } .stop-toggle:hover { background: var(--red-soft); color: var(--red); } .stop-toggle:disabled { opacity: 0.5; cursor: default; } diff --git a/gui/src/styles/sidebar-zoom.css b/gui/src/styles/sidebar-zoom.css new file mode 100644 index 00000000000..ea6b0ee88c6 --- /dev/null +++ b/gui/src/styles/sidebar-zoom.css @@ -0,0 +1,34 @@ +/* ── Desktop shell: sidebar page-zoom control and a footer that stays reachable ── + Kept out of styles.css, which is under a file-size cap that only moves down. Loaded after it + (see main.tsx), so these rules follow the base .sidebar and .sidebar nav rules in the cascade. */ + +/* Same row metrics as .theme-toggle so the language, theme and zoom rows read as one group. */ +.zoom-control { display: flex; align-items: center; gap: 4px; padding: 4px 10px; width: 100%; color: var(--muted); font-size: var(--text-control); } +.zoom-control__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zoom-control__btn, .zoom-control__value { + display: inline-flex; align-items: center; justify-content: center; + height: 26px; padding: 0; border: none; border-radius: var(--radius-sm); + background: none; color: inherit; font: inherit; cursor: pointer; + transition: background var(--motion-fast), color var(--motion-fast); +} +.zoom-control__btn { width: 26px; } +.zoom-control__btn svg { width: 14px; height: 14px; } +.zoom-control__value { min-width: 3.25rem; padding: 0 6px; font-variant-numeric: tabular-nums; } +.zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); } +.zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.zoom-control__btn:disabled { opacity: 0.5; cursor: default; } + +/* Page zoom shrinks the CSS viewport, so a zoomed or short window is shorter than the menu plus + the footer. On the desktop layout the menu scrolls and the footer (language, theme, zoom, proxy) + stays reachable; the narrow layout keeps its drawer, which already scrolls as a whole. The + padding and the equal negative margin keep focus rings at the edges from being clipped by the + scroll box without moving anything. */ +@media (min-width: 761px) { + .sidebar nav { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px; + margin: -4px; + } +} From 3e62096773cae95a4e78af355f19ce9b186b6683 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Thu, 1 Oct 2026 14:19:58 +0200 Subject: [PATCH 3/3] fix(gui): take over desktop zoom in the capture phase so the shell and dashboard versions can differ --- .../capabilities/dashboard-zoom.json | 2 +- desktop/src-tauri/src/lib.rs | 10 +- desktop/src-tauri/src/window.rs | 6 +- .../src/content/docs/guides/desktop-app.md | 4 +- gui/src/lib/desktop-zoom.ts | 2 +- gui/src/use-desktop-zoom.ts | 27 +++++- gui/tests/desktop-zoom-dom.test.tsx | 94 ++++++++++++++++++- gui/tests/desktop-zoom.test.ts | 8 +- structure/desktop-shell.md | 17 ++-- 9 files changed, 145 insertions(+), 25 deletions(-) diff --git a/desktop/src-tauri/capabilities/dashboard-zoom.json b/desktop/src-tauri/capabilities/dashboard-zoom.json index 423f599e967..dc2b0734244 100644 --- a/desktop/src-tauri/capabilities/dashboard-zoom.json +++ b/desktop/src-tauri/capabilities/dashboard-zoom.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "dashboard-zoom", - "description": "Page zoom for the main window, set by the loopback dashboard's own zoom control and hotkeys", + "description": "Page zoom hotkeys for the main window, including the loopback dashboard", "windows": ["main"], "remote": { "urls": ["http://127.0.0.1:*"] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f8aa089373f..b8e6e9fe7d7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -318,12 +318,10 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) - // Windows keeps WebView2's native zoom. On macOS and Linux the dashboard owns - // page zoom (`gui/src/lib/desktop-zoom.ts`): Tauri's keydown polyfill keeps the - // level in a script variable that restarts at 1 on every page load, so it can - // neither remember the level nor stay in step with the sidebar control. The - // dashboard's one IPC call is granted by `capabilities/dashboard-zoom.json`. - .zoom_hotkeys_enabled(cfg!(target_os = "windows")) + // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on + // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted + // to the loopback dashboard by `capabilities/dashboard-zoom.json`. + .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) .on_new_window(window::open_new_windows_in_default_browser()) // A hidden window still loads pages: wry builds this one with WebView2 diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index b7c1197242d..b5a90c8cbf5 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -273,9 +273,9 @@ mod tests { } } - /// The dashboard's zoom control runs inside the loopback dashboard, which is a remote origin to - /// Tauri. The capability that lets it call `set_webview_zoom` is the only one reaching that - /// origin, so it stays pinned to this window, this origin and this one command. + /// The zoom polyfill runs inside the loopback dashboard, which is a remote origin to Tauri. The + /// capability that lets it call `set_webview_zoom` is the only one reaching that origin, so it + /// stays pinned to this window, this origin and this one command. #[test] fn the_dashboard_reaches_only_the_zoom_command() { let zoom: serde_json::Value = diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 4c24c70e6a5..81cdc75f946 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -78,7 +78,9 @@ patch needs to be preserved across an update. On macOS and Linux, Cmd (macOS) or Ctrl (Linux) with `+`, `-` and `0`, or Ctrl with the mouse wheel, zooms the window between 50% and 300% in 10% steps, and `0` returns to 100%. The sidebar shows the same level with `-`, `+` and a reset button beside the theme and language rows. The level is -remembered and applied again the next time the app starts. Windows uses the browser engine's own +remembered and applied again the next time the app starts. The dashboard comes from the proxy the +app is attached to, so an app attached to an older proxy keeps the earlier behaviour (20% steps, not +remembered, no sidebar control) until that proxy is updated. Windows uses the browser engine's own zoom, which is not remembered. ## Keeping the proxy running diff --git a/gui/src/lib/desktop-zoom.ts b/gui/src/lib/desktop-zoom.ts index 4c2063d9c39..25ce0d3a87f 100644 --- a/gui/src/lib/desktop-zoom.ts +++ b/gui/src/lib/desktop-zoom.ts @@ -82,7 +82,7 @@ export function zoomKeyAction( os: HostOs, ): ZoomAction | null { const modifier = os === "macos" ? event.metaKey : event.ctrlKey; - if (!modifier || event.altKey) return null; + if (!modifier) return null; if (event.key === "-" || event.key === "_") return "out"; if (event.key === "=" || event.key === "+") return "in"; if (event.key === "0") return "reset"; diff --git a/gui/src/use-desktop-zoom.ts b/gui/src/use-desktop-zoom.ts index fde3dd0ad1e..a23d9d8583e 100644 --- a/gui/src/use-desktop-zoom.ts +++ b/gui/src/use-desktop-zoom.ts @@ -18,6 +18,14 @@ import { * gestures, and the sidebar control. Inert outside the shell. The level is applied on mount as * well as on every change, so a restart or a page navigation never leaves the webview at a * level the dashboard does not know about. + * + * The shell may inject Tauri's own zoom polyfill, and the dashboard cannot know which shell + * version is hosting it: the runtime serves the dashboard while the installed app is the shell, + * and a declined takeover or a pending service restart leaves the two on different versions. The + * polyfill listens on `window` in the bubble phase, so these listeners run in the capture phase + * and stop the event. A dashboard that handles zoom is then the only writer under any shell, and + * a dashboard that does not (an older runtime) never gets here, so the polyfill keeps working + * there. Nothing has to be negotiated between the two. */ export function useDesktopZoom( { managed }: { managed: boolean }, @@ -40,21 +48,32 @@ export function useDesktopZoom( const action = zoomKeyAction(event, os); if (!action) return; event.preventDefault(); + event.stopImmediatePropagation(); step(action); }; const onWheel = (event: WheelEvent) => { if (!event.ctrlKey) return; // Without this the webview would also apply its own pinch zoom under the dashboard's. event.preventDefault(); + event.stopImmediatePropagation(); const next = accumulateWheel(wheelDistance.current, event.deltaY); wheelDistance.current = next.accumulated; if (next.action) step(next.action); }; - window.addEventListener("keydown", onKey); - window.addEventListener("wheel", onWheel, { passive: false }); + // The legacy event the polyfill listens to. It may fire beside `wheel`, so it is silenced here + // and never counted: one gesture is one step. + const silenceLegacyWheel = (event: Event) => { + if (!(event as WheelEvent).ctrlKey) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + window.addEventListener("keydown", onKey, { capture: true }); + window.addEventListener("wheel", onWheel, { capture: true, passive: false }); + window.addEventListener("mousewheel", silenceLegacyWheel, { capture: true, passive: false }); return () => { - window.removeEventListener("keydown", onKey); - window.removeEventListener("wheel", onWheel); + window.removeEventListener("keydown", onKey, { capture: true }); + window.removeEventListener("wheel", onWheel, { capture: true }); + window.removeEventListener("mousewheel", silenceLegacyWheel, { capture: true }); }; }, [managed, step]); diff --git a/gui/tests/desktop-zoom-dom.test.tsx b/gui/tests/desktop-zoom-dom.test.tsx index 89bcb3e26d2..32692e828bf 100644 --- a/gui/tests/desktop-zoom-dom.test.tsx +++ b/gui/tests/desktop-zoom-dom.test.tsx @@ -26,7 +26,7 @@ function Probe({ managed }: { managed: boolean }) { return ; } -function mount(node: React.ReactElement, saved?: string) { +function mount(node: React.ReactElement, saved?: string, beforeRender?: () => void) { win = new Window({ url: "http://127.0.0.1:10100/", settings: { navigator: { userAgent: LINUX_SHELL } } }); previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous; calls = []; @@ -45,6 +45,9 @@ function mount(node: React.ReactElement, saved?: string) { localStorage: { configurable: true, writable: true, value: win.localStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, }); + // The shell injects its polyfill when the document is created, so anything that stands in for it + // is registered here, before the dashboard has rendered and registered its own listeners. + beforeRender?.(); const host = win.document.createElement("div") as never as HTMLElement; win.document.body.appendChild(host as never); act(() => { root = createRoot(host); root.render({node}); }); @@ -57,6 +60,35 @@ function press(init: KeyboardEventInit) { return event; } +/** + * Tauri's zoom polyfill is injected into the page before the dashboard runs and listens on + * `window` in the bubble phase. Events from the page start at an element, so the real path is + * element, then window. Registration order matters: the polyfill's listeners exist first, so a + * dashboard listener that merely stops propagation in the bubble phase would run too late. Call + * this through `mount`'s `beforeRender` so the stand-in is registered the way the shell's is. + */ +function installPolyfillStandIn() { + const seen = { keydown: [] as string[], mousewheel: 0 }; + win.addEventListener("keydown", (event) => { seen.keydown.push((event as KeyboardEvent).key); }); + win.addEventListener("mousewheel", () => { seen.mousewheel += 1; }); + return seen; +} + +function pressOnPage(init: KeyboardEventInit) { + const event = new win.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + act(() => { win.document.body.dispatchEvent(event); }); + return event; +} + +function wheelOnPage(type: "wheel" | "mousewheel", init: WheelEventInit) { + const event = new win.WheelEvent(type, { bubbles: true, cancelable: true, ...init }); + // happy-dom's WheelEvent drops the modifier flags from its init (a browser keeps them), so the + // flag the handlers read is set on the instance. + Object.defineProperty(event, "ctrlKey", { value: init.ctrlKey === true }); + act(() => { win.document.body.dispatchEvent(event); }); + return event; +} + afterEach(() => { if (root) act(() => root!.unmount()); root = null; @@ -116,3 +148,63 @@ test("outside the managed hosts nothing is applied or intercepted", () => { expect(zoomCalls()).toEqual([]); expect(win.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe("1.3"); }); + +/** + * Version skew between the shell and the dashboard it is showing (PR review). The dashboard is + * served by the runtime and the shell is the installed app, so they are not always the same + * version: a declined takeover leaves the app attached to an older runtime, and an app update + * leaves the shell newer than a service that has not restarted. The polyfill is injected by the + * shell, so a dashboard that handles zoom has to be the only writer without the shell's help, + * and a dashboard that does not must leave the polyfill working. + */ +test("a newer dashboard is the only zoom writer even when the shell injects its polyfill", () => { + let polyfill!: ReturnType; + mount(, "1.3", () => { polyfill = installPolyfillStandIn(); }); + const event = pressOnPage({ key: "=", ctrlKey: true }); + expect(zoomCalls()).toEqual([1.3, 1.4]); + expect(polyfill.keydown).toEqual([]); + expect(event.defaultPrevented).toBe(true); +}); + +test("an older dashboard that does not manage zoom leaves the polyfill working", () => { + let polyfill!: ReturnType; + mount(, "1.3", () => { polyfill = installPolyfillStandIn(); }); + const event = pressOnPage({ key: "=", ctrlKey: true }); + expect(polyfill.keydown).toEqual(["="]); + expect(event.defaultPrevented).toBe(false); + expect(zoomCalls()).toEqual([]); +}); + +test("keys that are not zoom keys still reach the rest of the page", () => { + let polyfill!: ReturnType; + mount(, undefined, () => { polyfill = installPolyfillStandIn(); }); + pressOnPage({ key: "b", ctrlKey: true }); + pressOnPage({ key: "=" }); + expect(polyfill.keydown).toEqual(["b", "="]); +}); + +test("the polyfill's Alt variants cannot sneak a second write past the dashboard", () => { + let polyfill!: ReturnType; + mount(, "1", () => { polyfill = installPolyfillStandIn(); }); + pressOnPage({ key: "-", ctrlKey: true, altKey: true }); + expect(polyfill.keydown).toEqual([]); + expect(zoomCalls().at(-1)).toBe(0.9); +}); + +test("Ctrl + wheel is taken by the dashboard and the legacy mousewheel event is silenced too", () => { + let polyfill!: ReturnType; + mount(, "1", () => { polyfill = installPolyfillStandIn(); }); + wheelOnPage("wheel", { ctrlKey: true, deltaY: -120 }); + wheelOnPage("mousewheel", { ctrlKey: true, deltaY: -120 }); + expect(zoomCalls()).toEqual([1, 1.1]); + expect(polyfill.mousewheel).toBe(0); +}); + +test("a wheel turn without Ctrl scrolls the page and reaches every listener", () => { + let polyfill!: ReturnType; + mount(, "1", () => { polyfill = installPolyfillStandIn(); }); + const event = wheelOnPage("mousewheel", { deltaY: -120 }); + expect(polyfill.mousewheel).toBe(1); + expect(event.defaultPrevented).toBe(false); + expect(zoomCalls()).toEqual([1]); +}); diff --git a/gui/tests/desktop-zoom.test.ts b/gui/tests/desktop-zoom.test.ts index 8b6d0655cce..4f7c0148431 100644 --- a/gui/tests/desktop-zoom.test.ts +++ b/gui/tests/desktop-zoom.test.ts @@ -94,10 +94,14 @@ describe("key bindings", () => { expect(zoomKeyAction(press("=", { ctrlKey: true }), "macos")).toBeNull(); }); - test("a bare key, another key, or an Alt chord is ignored", () => { + test("a bare key or another key is ignored", () => { expect(zoomKeyAction(press("="), "linux")).toBeNull(); expect(zoomKeyAction(press("b", { ctrlKey: true }), "linux")).toBeNull(); - expect(zoomKeyAction(press("=", { ctrlKey: true, altKey: true }), "linux")).toBeNull(); + }); + + test("an Alt chord is handled like Tauri's polyfill handled it, so the dashboard stays the only writer", () => { + expect(zoomKeyAction(press("=", { ctrlKey: true, altKey: true }), "linux")).toBe("in"); + expect(zoomKeyAction(press("-", { ctrlKey: true, altKey: true }), "linux")).toBe("out"); }); test("only macOS and Linux are managed by the dashboard", () => { diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 97000013fab..b0fe1bd8dda 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -28,12 +28,17 @@ none of the matching WKUIDelegate panel methods on macOS, so a platform dialog i drawing anything. `withGlobalTauri` is on so that page can invoke without a bundler. The bootstrap commands are granted to the local app origin only: `capabilities/default.json` declares no `remote` entry, and -Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. WebView2 -handles Ctrl + / - / 0 natively, so the builder leaves Tauri's zoom hotkeys on for Windows only; on macOS -and Linux the dashboard owns zoom (`gui/src/lib/desktop-zoom.ts`): it handles the same keys and Ctrl + -wheel, keeps the level in `localStorage` and re-applies it at start, and calls `set_webview_zoom`. Tauri's -own polyfill cannot do that, since its level restarts at 1 on every page load. `capabilities/dashboard-zoom.json` -grants that single command to the main window for `http://127.0.0.1:*`, and a test in `window.rs` pins its shape. +Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. The main +window keeps Tauri's zoom hotkeys on (Cmd or Ctrl with + / - / 0): WebView2 handles them natively, and on +macOS and Linux Tauri injects a keydown polyfill that calls `set_webview_zoom` from whatever page is loaded. +A dashboard that handles zoom itself (`gui/src/lib/desktop-zoom.ts`) keeps the level in `localStorage`, +re-applies it at start and calls the same command, and its handlers run in the capture phase and stop the +event before the polyfill's bubble-phase listeners see it. Nothing is negotiated, because the shell cannot +know which dashboard it shows: the runtime serves the dashboard and the installed app is the shell, so a +declined takeover or a service that has not restarted puts them on different versions. An older dashboard +has no handler, so the polyfill keeps working; an older shell's polyfill is pre-empted by a newer +dashboard. `capabilities/dashboard-zoom.json` grants that single command to the main window for +`http://127.0.0.1:*`, and a test in `window.rs` pins its shape. The main window carries an integrated title bar on macOS: the builder sets `TitleBarStyle::Overlay` with `hidden_title`, so the webview draws to the top of the window and