diff --git a/.changes/window-state-macos-overlay-size.md b/.changes/window-state-macos-overlay-size.md new file mode 100644 index 000000000..2040da49c --- /dev/null +++ b/.changes/window-state-macos-overlay-size.md @@ -0,0 +1,6 @@ +--- +window-state: patch +window-state-js: patch +--- + +Fixed windows using `titleBarStyle: Overlay` on macOS growing by the title bar height every time their size was restored. Also fixed a maximized window being restored off-screen when the position it had before being maximized is on a monitor that is no longer connected. diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index dcb990afd..a01cab7eb 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -86,7 +86,7 @@ skipped on desktop, and the rest run everywhere with the odd test gated. | `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. | | `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). | | `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). | -| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. | +| `window-state` | `filename`, save/restore, and that a re-created window keeps its saved size (also with an overlay title bar) and position over repeated restores (WM dependent). | Skipped — desktop-only plugin. | | `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | Android only; skipped on desktop. | | `geolocation` | `getCurrentPosition`, `watchPosition` (several at once) and `clearWatch` against a location simulated from the host, the granted permission state, and the rejections with location services off (Android). | | mobile-only plugins | `barcode-scanner`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. | @@ -172,7 +172,7 @@ passed there: both things it turns on belong to desktop-only plugins. | `E2E_SKIP_BUILD` | Skip the `tauri build` step and reuse the existing binary. | | `E2E_APP_PATH` | Absolute path to a prebuilt app/binary to test (also implies skip-build). | | `E2E_SKIP` | Comma-separated plugin names to skip, e.g. `E2E_SKIP=clipboard-manager,global-shortcut`. | -| `E2E_SKIP_WM` | Skip window-manager-dependent tests (the window-state size restore). | +| `E2E_SKIP_WM` | Skip window-manager-dependent tests (the window-state size and position restores). | | `E2E_SPEC_RETRIES` | Retry count for flaky spec files (default `0`). | | `E2E_CN_WEBDRIVER` | Use the CrabNebula Webdriver on Linux/Windows too (instead of the native driver). | | `E2E_NATIVE_DRIVER` | Path passed to `tauri-driver --native-driver` (e.g. a specific chromedriver). | diff --git a/packages/api-e2e/package.json b/packages/api-e2e/package.json index b40ef4205..7f50fd946 100644 --- a/packages/api-e2e/package.json +++ b/packages/api-e2e/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@crabnebula/tauri-driver": "^2.0.9", - "@crabnebula/test-runner-backend": "^0.3.0", + "@crabnebula/test-runner-backend": "^0.3.1", "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-autostart": "workspace:*", "@tauri-apps/plugin-barcode-scanner": "workspace:*", diff --git a/packages/api-e2e/test/specs/window-state.spec.ts b/packages/api-e2e/test/specs/window-state.spec.ts index f46d05576..91e5686b7 100644 --- a/packages/api-e2e/test/specs/window-state.spec.ts +++ b/packages/api-e2e/test/specs/window-state.spec.ts @@ -5,6 +5,146 @@ import { expect } from '@wdio/globals' import { tauri, eventually, describePlugin, itWm } from '../helpers/index.js' +interface WindowOptions { + width?: number + height?: number + x?: number + y?: number + titleBarStyle?: 'visible' | 'transparent' | 'overlay' +} + +interface Geometry { + inner: { width: number; height: number } + outer: { width: number; height: number } + position: { x: number; y: number } +} + +const createWindow = (label: string, options: WindowOptions) => + tauri( + (api, label, options) => + new Promise((resolve, reject) => { + const window = new api.webviewWindow.WebviewWindow(label, options) + window.once('tauri://created', () => resolve()).catch(reject) + window + .once('tauri://error', (event) => + reject( + new Error(`window creation failed: ${String(event.payload)}`) + ) + ) + .catch(reject) + setTimeout(() => reject(new Error('window creation timed out')), 8000) + }), + label, + options + ) + +const geometry = (label: string) => + tauri(async (api, label): Promise => { + const window = await api.webviewWindow.WebviewWindow.getByLabel(label) + if (!window) throw new Error(`window ${label} not found`) + const inner = await window.innerSize() + const outer = await window.outerSize() + const position = await window.outerPosition() + return { + inner: { width: inner.width, height: inner.height }, + outer: { width: outer.width, height: outer.height }, + position: { x: position.x, y: position.y } + } + }, label) + +const closeWindow = async (label: string) => { + await tauri(async (api, label) => { + const window = await api.webviewWindow.WebviewWindow.getByLabel(label) + await window?.close() + return null + }, label) + await eventually(async () => { + const labels = await tauri(async (api) => + (await api.webviewWindow.getAllWebviewWindows()).map((w) => w.label) + ) + if (labels.includes(label)) { + throw new Error('window is still present after close') + } + }) +} + +const saveState = (flags: 'SIZE' | 'POSITION') => + tauri( + (api, flags) => + api.windowState.saveWindowState( + flags === 'SIZE' + ? api.windowState.StateFlags.SIZE + : api.windowState.StateFlags.POSITION + ), + flags + ) + +const near = (actual: number, expected: number, tolerance: number) => + Math.abs(actual - expected) <= tolerance + +/** + * Creates the window `cycles` times. Each time it asks for a size and + * position different from the saved state, and the window must come back with + * the geometry of the first one, so any drift between saving and restoring + * (a title bar counted twice, a scale applied twice) accumulates and shows. + */ +async function expectStableRoundTrip( + label: string, + options: WindowOptions, + flags: 'SIZE' | 'POSITION', + cycles = 3 +) { + const scale = await tauri((api) => + api.window.getCurrentWindow().scaleFactor() + ) + const tolerance = Math.ceil(scale) * 2 + + await createWindow(label, options) + // let the window settle before reading what will be saved + const saved = await eventually(async () => { + const first = await geometry(label) + await new Promise((resolve) => setTimeout(resolve, 300)) + const second = await geometry(label) + if (JSON.stringify(first) !== JSON.stringify(second)) { + throw new Error('window geometry is still changing') + } + return second + }) + await saveState(flags) + await closeWindow(label) + + for (let cycle = 1; cycle <= cycles; cycle++) { + await createWindow(label, { + ...options, + width: (options.width ?? 500) + 200, + height: (options.height ?? 400) + 150, + x: (options.x ?? 100) + 120, + y: (options.y ?? 100) + 90 + }) + try { + await eventually(async () => { + const actual = await geometry(label) + const ok = + flags === 'SIZE' + ? near(actual.inner.width, saved.inner.width, tolerance) + && near(actual.inner.height, saved.inner.height, tolerance) + && near(actual.outer.width, saved.outer.width, tolerance) + && near(actual.outer.height, saved.outer.height, tolerance) + : near(actual.position.x, saved.position.x, tolerance) + && near(actual.position.y, saved.position.y, tolerance) + if (!ok) { + throw new Error( + `cycle ${cycle}: ${JSON.stringify(actual)} does not match the saved ${JSON.stringify(saved)}` + ) + } + }) + await saveState(flags) + } finally { + await closeWindow(label) + } + } +} + // The plugin is desktop-only: a mobile window is the whole screen and has no // state to persist, so the whole suite is skipped there. describePlugin('window-state', { desktopOnly: true }, () => { @@ -35,96 +175,31 @@ describePlugin('window-state', { desktopOnly: true }, () => { ) }) - itWm( - 'a new window is restored to the size it was last saved with', - async () => { - // The plugin applies the cached state of a label whenever a window with - // that label is created, which is what persists sizes across sessions. - const label = 'e2e-window-state' - const scale = await tauri((api) => - api.window.getCurrentWindow().scaleFactor() - ) + // The plugin applies the cached state of a label whenever a window with + // that label is created, which is what persists state across sessions. + itWm('a new window is restored to the size it was last saved with', () => + expectStableRoundTrip( + 'e2e-window-state-size', + { width: 500, height: 400 }, + 'SIZE' + ) + ) - const create = (width: number, height: number) => - tauri( - (api, label, width, height) => - new Promise((resolve, reject) => { - const window = new api.webviewWindow.WebviewWindow(label, { - width, - height - }) - window.once('tauri://created', () => resolve()).catch(reject) - window - .once('tauri://error', (event) => - reject( - new Error( - `window creation failed: ${String(event.payload)}` - ) - ) - ) - .catch(reject) - setTimeout( - () => reject(new Error('window creation timed out')), - 8000 - ) - }), - label, - width, - height - ) - const innerSize = () => - tauri(async (api, label) => { - const window = await api.webviewWindow.WebviewWindow.getByLabel(label) - if (!window) throw new Error(`window ${label} not found`) - const size = await window.innerSize() - return { width: size.width, height: size.height } - }, label) - const close = async () => { - await tauri(async (api, label) => { - const window = await api.webviewWindow.WebviewWindow.getByLabel(label) - await window?.close() - return null - }, label) - await eventually(async () => { - const labels = await tauri(async (api) => - (await api.webviewWindow.getAllWebviewWindows()).map((w) => w.label) - ) - if (labels.includes(label)) { - throw new Error('window is still present after close') - } - }) - } - const expectSize = (width: number, height: number) => - eventually(async () => { - const size = await innerSize() - const tolerance = Math.ceil(scale) * 8 - if ( - Math.abs(size.width - width) > tolerance - || Math.abs(size.height - height) > tolerance - ) { - throw new Error( - `size ${size.width}x${size.height} not near ${width}x${height}` - ) - } - }) + // An overlay title bar draws over the content, so the webview is as tall as + // the window. Restoring must not add or remove the title bar height. + itWm('an overlay title bar window keeps its size across restores', () => + expectStableRoundTrip( + 'e2e-window-state-overlay', + { width: 500, height: 400, titleBarStyle: 'overlay' }, + 'SIZE' + ) + ) - // create the window at one size and save that state - await create(500, 400) - await expectSize(500 * scale, 400 * scale) - const saved = await innerSize() - await tauri((api) => - api.windowState.saveWindowState(api.windowState.StateFlags.SIZE) - ) - await close() - - // a new window with the same label asks for another size, and gets the - // saved one back - await create(700, 600) - try { - await expectSize(saved.width, saved.height) - } finally { - await close() - } - } + itWm('a new window is restored to the position it was last saved at', () => + expectStableRoundTrip( + 'e2e-window-state-position', + { width: 500, height: 400, x: 150, y: 120 }, + 'POSITION' + ) ) }) diff --git a/plugins/window-state/src/lib.rs b/plugins/window-state/src/lib.rs index 8aeab6a44..67da5c7b1 100644 --- a/plugins/window-state/src/lib.rs +++ b/plugins/window-state/src/lib.rs @@ -204,24 +204,20 @@ impl WindowExt for Window { } if flags.contains(StateFlags::POSITION) { - let position = (state.x, state.y).into(); + // a maximized window's x and y are the monitor's corner, so it is + // placed where it was before it was maximized + let position = if state.maximized { + PhysicalPosition::new(state.prev_x, state.prev_y) + } else { + PhysicalPosition::new(state.x, state.y) + }; let size = (state.width, state.height).into(); // restore position to saved value if saved monitor exists // otherwise, let the OS decide where to place the window for m in self.available_monitors()? { if m.intersects(position, size) { - self.set_position(PhysicalPosition { - x: if state.maximized { - state.prev_x - } else { - state.x - }, - y: if state.maximized { - state.prev_y - } else { - state.y - }, - })?; + self.set_position(position)?; + break; } } } @@ -246,7 +242,7 @@ impl WindowExt for Window { let mut metadata = WindowState::default(); if flags.contains(StateFlags::SIZE) { - let size = self.inner_size()?; + let size = restorable_size(self)?; metadata.width = size.width; metadata.height = size.height; } @@ -285,6 +281,27 @@ impl WindowExt for Window { } } +/// The size to save for `window`, so that restoring it with `set_size` recreates the window. +/// +/// `set_size` sets the inner size, which on macOS is the window's content rect: the frame minus +/// the title bar. `inner_size` reports the webview's bounds instead, which include the title bar +/// when it is drawn over the content (`TitleBarStyle::Overlay`), so saving it would grow the +/// window by the title bar height on every restore. `inner_position` is the content rect's +/// origin, so its offset from `outer_position` is the title bar height. +fn restorable_size(window: &Window) -> tauri::Result> { + #[cfg(target_os = "macos")] + { + let outer = window.outer_size()?; + let title_bar = window.inner_position()?.y - window.outer_position()?.y; + Ok(PhysicalSize::new( + outer.width, + outer.height.saturating_sub(title_bar.max(0) as u32), + )) + } + #[cfg(not(target_os = "macos"))] + window.inner_size() +} + trait WindowExtInternal { fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()>; } @@ -320,7 +337,7 @@ impl WindowExtInternal for Window { } if flags.contains(StateFlags::SIZE) && !is_maximized && !is_minimized { - let size = self.inner_size()?; + let size = restorable_size(self)?; // It doesn't make sense to save a window with 0 height or width if size.width > 0 && size.height > 0 { state.width = size.width; @@ -517,6 +534,10 @@ impl Builder { if !window_clone.is_minimized().unwrap_or_default() && !is_maximized { let mut c = cache.lock().unwrap(); if let Some(state) = c.get_mut(&label) { + // the event carries the inner size, which is not what + // macOS restores (see `restorable_size`) + #[cfg(target_os = "macos")] + let size = &restorable_size(&window_clone).unwrap_or(*size); state.width = size.width; state.height = size.height; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4f8bf918..f217da966 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,8 +270,8 @@ importers: specifier: ^2.0.9 version: 2.0.9 '@crabnebula/test-runner-backend': - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.3.1 + version: 0.3.1 '@tauri-apps/api': specifier: ^2.12.0 version: 2.12.1 @@ -712,33 +712,33 @@ packages: engines: {node: '>= 10'} hasBin: true - '@crabnebula/test-runner-backend-darwin-arm64@0.3.0': - resolution: {integrity: sha512-XnZdSeew2ew/Eix+z1BwKt5PcrJSjL+VQ7dlrV1fvRj4WUTQtluzE1VhiVV0j2EIFJsRH2IExNj5OdCWxKSPiQ==} + '@crabnebula/test-runner-backend-darwin-arm64@0.3.1': + resolution: {integrity: sha512-5oZYngc7nGPiiWBnM48+nLVGt4/6oTNPnBt8uSz3AZDVo6bpQ/PdtBowOBaPhW8FG8wJCkCywuPKZkxNiQbQcw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@crabnebula/test-runner-backend-darwin-x64@0.3.0': - resolution: {integrity: sha512-dpogk8TxKd2CZb8BZ2Ni1mpbogD8KSoB8J92aw+9R24S8xBeTu8I+t4w9PVNIBvFaNAhEQelYhFffxH0FDs5+Q==} + '@crabnebula/test-runner-backend-darwin-x64@0.3.1': + resolution: {integrity: sha512-dAm10Qii1AgvhNzE4OaEHJ2KiIhbu1xqwtzkWEdvVNbBeoHoAk126o6WvluPkjMSXmys2Hzza+PsaXSuwzsUMQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@crabnebula/test-runner-backend-linux-x64-gnu@0.3.0': - resolution: {integrity: sha512-ZzDVKdY1ydv+lqfRYs6PAdmIc8AlfOciH3ntuUxyRJ8aSq0v8y+pow6b3/CRqmumkj9riVWsUriJr7Xw2YZFTg==} + '@crabnebula/test-runner-backend-linux-x64-gnu@0.3.1': + resolution: {integrity: sha512-zv5aG7JZVoZ024CzgmxUru6W7MxCfrE79WWDnPoo2/Xl8CO9fFaUSlUkGluUvNkd+0aufcMT0TGMCYhN/c1pEA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@crabnebula/test-runner-backend-win32-x64-msvc@0.3.0': - resolution: {integrity: sha512-xzE5bZx6QpqCkGFTpOYGxMfYidfsvYQgp3yQ1hXwuxeC5vsabOngRtE6X2DxQU6U8JzzNaooN0afG/khzoaihQ==} + '@crabnebula/test-runner-backend-win32-x64-msvc@0.3.1': + resolution: {integrity: sha512-Zlm3a7I+p+4DTVkspbUS3xsXePJ++7IMP5lI5ONGuYtPg7/qOYtUksjfT/LpCd4pqoCAqGr7Qr5icNWDaZMIag==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@crabnebula/test-runner-backend@0.3.0': - resolution: {integrity: sha512-72aldFCPV0Sflv8KGLeaNoUY4Vz8eRl7AvUsJXFwqJ7KKNDAfPKwovXjmZoqIWn6Icv01sEsd1b2w0O66Y3WEQ==} + '@crabnebula/test-runner-backend@0.3.1': + resolution: {integrity: sha512-gDYc+dj/K8jyNvJBH7WUjXrZi7y16qXEJXoW82Im71BbMpwqAknRm0HlmFmvuBK0Y+VCkSaD6VuTbPbfHsIvww==} engines: {node: '>= 10'} hasBin: true @@ -4821,24 +4821,24 @@ snapshots: '@crabnebula/tauri-driver-linux-x64-gnu': 2.0.9 '@crabnebula/tauri-driver-win32-x64-msvc': 2.0.9 - '@crabnebula/test-runner-backend-darwin-arm64@0.3.0': + '@crabnebula/test-runner-backend-darwin-arm64@0.3.1': optional: true - '@crabnebula/test-runner-backend-darwin-x64@0.3.0': + '@crabnebula/test-runner-backend-darwin-x64@0.3.1': optional: true - '@crabnebula/test-runner-backend-linux-x64-gnu@0.3.0': + '@crabnebula/test-runner-backend-linux-x64-gnu@0.3.1': optional: true - '@crabnebula/test-runner-backend-win32-x64-msvc@0.3.0': + '@crabnebula/test-runner-backend-win32-x64-msvc@0.3.1': optional: true - '@crabnebula/test-runner-backend@0.3.0': + '@crabnebula/test-runner-backend@0.3.1': optionalDependencies: - '@crabnebula/test-runner-backend-darwin-arm64': 0.3.0 - '@crabnebula/test-runner-backend-darwin-x64': 0.3.0 - '@crabnebula/test-runner-backend-linux-x64-gnu': 0.3.0 - '@crabnebula/test-runner-backend-win32-x64-msvc': 0.3.0 + '@crabnebula/test-runner-backend-darwin-arm64': 0.3.1 + '@crabnebula/test-runner-backend-darwin-x64': 0.3.1 + '@crabnebula/test-runner-backend-linux-x64-gnu': 0.3.1 + '@crabnebula/test-runner-backend-win32-x64-msvc': 0.3.1 '@dabh/diagnostics@2.0.9': dependencies: