From 007a61124bd5760a806a4716c045bad083d67376 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 19:12:55 -0700 Subject: [PATCH] fix(desktop): preserve main window geometry across toolbar mode Keep transient floating-window bounds out of the persisted main-window state, repair legacy undersized startup geometry, and restore the standard client minimum after leaving toolbar mode. --- src/apps/cli/src/peer_host/deny.rs | 1 + src/apps/desktop/src/api/peer_host_invoke.rs | 1 + .../src/api/remote_workspace_policy.rs | 4 + src/apps/desktop/src/api/system_api.rs | 37 +++- src/apps/desktop/src/lib.rs | 139 +++++++++++++- src/apps/desktop/src/theme.rs | 6 +- src/apps/desktop/src/tray.rs | 1 + .../startupPerformanceContract.test.ts | 30 ++- .../toolbar-mode/ToolbarModeContext.ts | 2 + .../toolbar-mode/ToolbarModeProvider.tsx | 179 +++++++++++------- .../api/adapters/peer-device-adapter.test.ts | 4 + .../api/adapters/peer-device-adapter.ts | 1 + .../api/service-api/SystemAPI.ts | 14 ++ 13 files changed, 341 insertions(+), 78 deletions(-) diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 80af64a157..743ce4d1af 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -15,6 +15,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "initialize_tray_after_startup", "startup_window_control", "toggle_main_window_fullscreen", + "set_main_window_transient_geometry", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 11d41d5175..e7a6865dfb 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -37,6 +37,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "initialize_tray_after_startup", "startup_window_control", "toggle_main_window_fullscreen", + "set_main_window_transient_geometry", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 94d0b21777..d53b2676e5 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1516,6 +1516,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::RemoteUnsupported, ), ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), + ( + "set_main_window_transient_geometry", + RemoteWorkspacePolicy::LocalOnly, + ), ( "set_prevent_sleep_enabled", RemoteWorkspacePolicy::LocalOnly, diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 2f2d157d63..84eaa9d0d4 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -133,7 +133,10 @@ async fn probe_endpoint_throughput(client: &reqwest::Client, url: &str) -> u64 { let started = std::time::Instant::now(); let request = client .get(url) - .header(reqwest::header::RANGE, format!("bytes=0-{}", PROBE_BYTES - 1)) + .header( + reqwest::header::RANGE, + format!("bytes=0-{}", PROBE_BYTES - 1), + ) .send(); let Ok(Ok(response)) = tokio::time::timeout(PROBE_WINDOW, request).await else { return 0; @@ -168,7 +171,10 @@ async fn ranked_updater(app: &AppHandle) -> Result builder, Err(error) => { - log::warn!("Updater endpoint ranking rejected, using bundled order: {}", error); + log::warn!( + "Updater endpoint ranking rejected, using bundled order: {}", + error + ); app.updater_builder() } }; @@ -590,12 +596,33 @@ fn read_main_window_fullscreen_response( // ─── Window / Tray behavior commands ───────────────────────────────────────── +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetMainWindowTransientGeometryRequest { + pub transient: bool, +} + +/// Mark whether the shared main window currently uses toolbar-mode geometry. +/// +/// Entering captures the latest normal bounds before the frontend resizes the +/// native window. Leaving persists the restored normal bounds. While transient +/// geometry is active, all process-exit save paths retain the captured normal +/// state instead of the floating-window state. +#[tauri::command] +pub async fn set_main_window_transient_geometry( + app: tauri::AppHandle, + request: SetMainWindowTransientGeometryRequest, +) -> Result<(), String> { + crate::set_main_window_transient_geometry(&app, request.transient) +} + /// Immediately exit the application (used by the "ask" dialog when the user /// chooses to quit rather than minimize to tray). #[tauri::command] pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("Quit requested via quit_app command"); crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); + crate::save_main_window_state(&app); crate::perform_process_exit_cleanup(); app.exit(0); Ok(()) @@ -667,6 +694,7 @@ pub async fn startup_window_control( if behavior == "quit" { log::info!("Quit requested from startup window control"); crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); + crate::save_main_window_state(&app); crate::perform_process_exit_cleanup(); app.exit(0); } else { @@ -843,7 +871,10 @@ mod tests { "unexpected updater arch segment: {arch}" ); #[cfg(target_os = "macos")] - assert!(key.starts_with("darwin-"), "macOS must map to darwin, got {key}"); + assert!( + key.starts_with("darwin-"), + "macOS must map to darwin, got {key}" + ); } use super::*; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index efec57e041..b717de8779 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -48,7 +48,7 @@ use std::sync::{ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tauri::Manager; -use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt}; // Re-export API pub use api::*; @@ -113,6 +113,15 @@ static MAIN_WINDOW_CLOSE_PENDING_ON_MACOS: AtomicBool = AtomicBool::new(false); const MAIN_WINDOW_CLOSE_REQUESTED_EVENT: &str = "bitfun_main_window_close_requested"; const BROWSER_WEBVIEW_PAGE_LOAD_EVENT: &str = "browser-webview-page-load"; const CRON_DESKTOP_START_FALLBACK_DELAY: Duration = Duration::from_secs(120); +pub(crate) const MAIN_WINDOW_DEFAULT_WIDTH: f64 = 1200.0; +pub(crate) const MAIN_WINDOW_DEFAULT_HEIGHT: f64 = 800.0; +pub(crate) const MAIN_WINDOW_MIN_WIDTH: f64 = 800.0; +pub(crate) const MAIN_WINDOW_MIN_HEIGHT: f64 = 600.0; + +// Toolbar mode temporarily morphs the main window into a compact floating +// surface. Its geometry must never replace the normal main-window geometry +// restored on the next process start. +static MAIN_WINDOW_USES_TRANSIENT_GEOMETRY: AtomicBool = AtomicBool::new(false); #[cfg(target_os = "macos")] const MAIN_WINDOW_CLOSE_FALLBACK_HIDE_MS: u64 = 2_500; @@ -277,12 +286,129 @@ fn main_window_state_flags() -> StateFlags { StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN } +fn persist_main_window_state(app: &tauri::AppHandle) -> Result<(), String> { + app.save_window_state(main_window_state_flags()) + .map_err(|error| error.to_string()) +} + pub(crate) fn save_main_window_state(app: &tauri::AppHandle) { - if let Err(error) = app.save_window_state(main_window_state_flags()) { + if MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.load(Ordering::SeqCst) { + log::debug!("Skipped saving transient main window geometry"); + return; + } + + if let Err(error) = persist_main_window_state(app) { log::warn!("Failed to save main window state: {}", error); } } +pub(crate) fn set_main_window_transient_geometry( + app: &tauri::AppHandle, + transient: bool, +) -> Result<(), String> { + if transient { + if MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.load(Ordering::SeqCst) { + return Ok(()); + } + + // Capture the latest normal bounds before toolbar mode starts resizing + // the shared native window. + persist_main_window_state(app).map_err(|error| { + format!( + "Failed to save main window state before transient geometry: {}", + error + ) + })?; + MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.store(true, Ordering::SeqCst); + return Ok(()); + } + + MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.store(false, Ordering::SeqCst); + persist_main_window_state(app).map_err(|error| { + format!( + "Failed to save restored main window state after transient geometry: {}", + error + ) + }) +} + +fn has_standard_main_window_size(width: f64, height: f64) -> bool { + width >= MAIN_WINDOW_MIN_WIDTH && height >= MAIN_WINDOW_MIN_HEIGHT +} + +pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { + if let Err(error) = window.restore_state(main_window_state_flags()) { + log::warn!("Failed to restore main window state: {}", error); + } + + let is_maximized = window.is_maximized().unwrap_or(false); + let is_fullscreen = window.is_fullscreen().unwrap_or(false); + if !is_maximized && !is_fullscreen { + match (window.inner_size(), window.scale_factor()) { + (Ok(size), Ok(scale_factor)) => { + let logical_size = size.to_logical::(scale_factor); + if !has_standard_main_window_size(logical_size.width, logical_size.height) { + log::info!( + "Resetting undersized main window state: width={}, height={}", + logical_size.width, + logical_size.height + ); + + let resize_result = window.set_size(tauri::LogicalSize::new( + MAIN_WINDOW_DEFAULT_WIDTH, + MAIN_WINDOW_DEFAULT_HEIGHT, + )); + let center_result = window.center(); + let resize_succeeded = match resize_result { + Ok(()) => true, + Err(error) => { + log::warn!("Failed to reset main window size: {}", error); + false + } + }; + if let Err(error) = center_result { + log::warn!("Failed to center reset main window: {}", error); + } + if resize_succeeded { + if let Err(error) = persist_main_window_state(window.app_handle()) { + log::warn!("Failed to persist repaired main window state: {}", error); + } + } + } + } + (Err(error), _) => { + log::warn!("Failed to read restored main window size: {}", error); + } + (_, Err(error)) => { + log::warn!("Failed to read main window scale factor: {}", error); + } + } + } + + if let Err(error) = window.set_min_size(Some(tauri::LogicalSize::new( + MAIN_WINDOW_MIN_WIDTH, + MAIN_WINDOW_MIN_HEIGHT, + ))) { + log::warn!("Failed to set main window minimum size: {}", error); + } +} + +#[cfg(test)] +mod main_window_geometry_tests { + use super::has_standard_main_window_size; + + #[test] + fn floating_toolbar_sizes_are_not_valid_main_window_sizes() { + assert!(!has_standard_main_window_size(440.0, 680.0)); + assert!(!has_standard_main_window_size(700.0, 140.0)); + } + + #[test] + fn default_client_size_is_a_valid_main_window_size() { + assert!(has_standard_main_window_size(1200.0, 800.0)); + } +} + #[tauri::command] async fn webdriver_bridge_result(request: WebdriverBridgeResultRequest) -> Result<(), String> { log::debug!("webdriver_bridge_result command invoked"); @@ -510,7 +636,12 @@ pub async fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin( tauri_plugin_window_state::Builder::default() - .with_state_flags(main_window_state_flags()) + // Restore explicitly after the main window is built, and save + // explicitly at normal-geometry boundaries. Empty automatic + // flags keep toolbar-mode resize/move events out of the + // plugin cache and prevent its exit hook from overwriting the + // last normal main-window geometry. + .with_state_flags(StateFlags::empty()) .with_filter(|label| label == "main") .build(), ) @@ -1406,6 +1537,7 @@ pub async fn run() { api::system_api::minimize_to_tray, api::system_api::initialize_tray_after_startup, api::system_api::startup_window_control, + api::system_api::set_main_window_transient_geometry, api::system_api::toggle_main_window_fullscreen, sleep_prevention::get_prevent_sleep_enabled, sleep_prevention::set_prevent_sleep_enabled, @@ -1603,6 +1735,7 @@ pub async fn run() { app.run(|_app_handle, event| match event { tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { crash_diagnostics::mark_clean_shutdown("tauri_run_exit"); + save_main_window_state(_app_handle); perform_process_exit_cleanup(); } #[cfg(target_os = "macos")] diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index da0235cac4..099219af9e 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -476,7 +476,10 @@ pub fn create_main_window( #[allow(unused_mut)] let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url) .title("BitFun") - .inner_size(1200.0, 800.0) + .inner_size( + crate::MAIN_WINDOW_DEFAULT_WIDTH, + crate::MAIN_WINDOW_DEFAULT_HEIGHT, + ) .center() .resizable(true) .fullscreen(false) @@ -529,6 +532,7 @@ pub fn create_main_window( let build_started_at = Instant::now(); match builder.build() { Ok(window) => { + crate::restore_main_window_state(&window); startup_trace.record_elapsed_step("native_window", "webview_build", build_started_at); debug!( "Main window creation step completed: step=build url_kind={} duration_ms={} total_duration_ms={}", diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index d9eb777eb0..831223b1f0 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -220,6 +220,7 @@ pub fn setup_tray( } else if id == "quit" { log::info!("Quit requested from tray menu"); crate::crash_diagnostics::mark_clean_shutdown("tray_quit"); + crate::save_main_window_state(app); crate::perform_process_exit_cleanup(); app.exit(0); } else if id == "toggle_desktop_pet" { diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index f9e13b1081..5fc1518e53 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -168,21 +168,47 @@ describe('startup performance contract', () => { ); }); - it('centers the first main window and persists geometry before close handling', () => { + it('restores only normal main-window geometry and repairs legacy floating sizes', () => { const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); const desktopLibSource = readSource('../../../../apps/desktop/src/lib.rs'); + const toolbarModeProviderSource = readSource( + '../../flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx' + ); const windowEventStart = desktopLibSource.indexOf('.on_window_event({'); const invokeHandlerStart = desktopLibSource.indexOf('.invoke_handler(', windowEventStart); const windowEventSource = desktopLibSource.slice(windowEventStart, invokeHandlerStart); - expect(desktopThemeSource).toContain('.inner_size(1200.0, 800.0)\n .center()'); + expect(desktopThemeSource).toContain('crate::MAIN_WINDOW_DEFAULT_WIDTH'); + expect(desktopThemeSource).toContain('crate::restore_main_window_state(&window)'); expect(desktopThemeSource).not.toContain('windows_maximize_show_wait_action'); expect(desktopLibSource).toContain('tauri_plugin_window_state::Builder::default()'); + expect(desktopLibSource).toContain('.with_state_flags(StateFlags::empty())'); expect(desktopLibSource).toContain('.with_filter(|label| label == "main")'); + expect(desktopLibSource).toContain('Resetting undersized main window state'); + expect(desktopLibSource).toContain('MAIN_WINDOW_USES_TRANSIENT_GEOMETRY'); + expect(toolbarModeProviderSource).not.toContain( + "import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'" + ); + expect(toolbarModeProviderSource).toContain( + "await import('@/infrastructure/api/service-api/SystemAPI')" + ); + expect(toolbarModeProviderSource).toContain('win.innerSize()'); + expect(toolbarModeProviderSource).not.toContain('win.outerSize(),'); expect(windowEventStart).toBeGreaterThan(-1); expect(invokeHandlerStart).toBeGreaterThan(windowEventStart); expect(windowEventSource).toContain('matches!(event, tauri::WindowEvent::CloseRequested { .. })'); expect(windowEventSource).toContain('save_main_window_state(window.app_handle())'); + expect(toolbarModeProviderSource).toContain( + 'setMainWindowTransientGeometry(true)' + ); + expect(toolbarModeProviderSource).toContain( + 'setMainWindowTransientGeometry(false)' + ); + expect( + toolbarModeProviderSource.indexOf('setMainWindowTransientGeometry(true)') + ).toBeLessThan( + toolbarModeProviderSource.indexOf('win.setSize(new PhysicalSize(geometry.width') + ); }); it('keeps system tray creation out of the synchronous Tauri setup path', () => { diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts index 79fb47125d..149e55ea72 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeContext.ts @@ -62,6 +62,8 @@ export const TOOLBAR_COMPACT_MIN = { width: 400, height: 100 }; /** Matches the floating chat bubble panel ($panel-width/$panel-height). */ export const TOOLBAR_EXPANDED_SIZE = { width: 440, height: 680 }; export const TOOLBAR_EXPANDED_MIN = { width: 400, height: 500 }; +export const MAIN_WINDOW_DEFAULT_SIZE = { width: 1200, height: 800 }; +export const MAIN_WINDOW_MIN_SIZE = { width: 800, height: 600 }; export const ToolbarModeContext = createContext(undefined); diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx index 7ace4843c3..42069e6f69 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx @@ -1,8 +1,10 @@ import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; -import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'; +import { LogicalSize, PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'; import { createLogger } from '@/shared/utils/logger'; import { + MAIN_WINDOW_DEFAULT_SIZE, + MAIN_WINDOW_MIN_SIZE, TOOLBAR_COMPACT_MIN, TOOLBAR_COMPACT_SIZE, TOOLBAR_EXPANDED_MIN, @@ -20,6 +22,78 @@ interface ToolbarModeProviderProps { children: ReactNode; } +type MainWindow = ReturnType; + +const setMainWindowTransientGeometry = async (transient: boolean): Promise => { + const { systemAPI } = await import('@/infrastructure/api/service-api/SystemAPI'); + await systemAPI.setMainWindowTransientGeometry(transient); +}; + +const restoreMainWindowFromToolbarMode = async ( + win: MainWindow, + saved: SavedWindowState | null, + isMacOS: boolean, +): Promise => { + // Remove the toolbar constraint before restoring the normal bounds. The + // standard client minimum is re-applied once restoration completes. + await win.setMinSize(null); + + if (isMacOS) { + try { + await win.setTitleBarStyle('overlay'); + } catch (error) { + log.debug('Failed to restore macOS overlay title bar (early, ignored)', error); + } + } else { + try { + await win.setDecorations(saved?.isDecorated ?? false); + } catch (error) { + log.debug('Failed to restore window decorations (ignored)', error); + } + } + + await Promise.all([ + win.setResizable(true), + win.setSkipTaskbar(false), + ]); + + if (saved) { + await win.setSize(new PhysicalSize(saved.width, saved.height)); + await win.setPosition(new PhysicalPosition(saved.x, saved.y)); + + if (saved.isMaximized) { + await win.maximize(); + } + } else { + await win.setSize(new LogicalSize( + MAIN_WINDOW_DEFAULT_SIZE.width, + MAIN_WINDOW_DEFAULT_SIZE.height, + )); + await win.center(); + } + + await win.setMinSize(new LogicalSize( + MAIN_WINDOW_MIN_SIZE.width, + MAIN_WINDOW_MIN_SIZE.height, + )); + + if (isMacOS) { + try { + await win.setTitleBarStyle('overlay'); + await new Promise((resolve) => setTimeout(resolve, 60)); + await win.setTitleBarStyle('overlay'); + } catch (error) { + log.debug('Failed to re-apply macOS overlay title bar (ignored)', error); + } + } + + // Keep the native side in transient mode until all normal geometry has been + // restored. Turning this off persists only the final standard-client state. + await win.setAlwaysOnTop(false); + await setMainWindowTransientGeometry(false); + await win.setFocus(); +}; + export const ToolbarModeProvider: React.FC = ({ children }) => { const [isToolbarMode, setIsToolbarMode] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -39,20 +113,22 @@ export const ToolbarModeProvider: React.FC = ({ childr const savedWindowStateRef = useRef(null); const enableToolbarMode = useCallback(async () => { + const win = getCurrentWindow(); + const isMacOS = + typeof window !== 'undefined' && + '__TAURI__' in window && + typeof navigator !== 'undefined' && + typeof navigator.platform === 'string' && + navigator.platform.toUpperCase().includes('MAC'); + try { window.dispatchEvent(new CustomEvent('toolbar-mode-activating')); - const win = getCurrentWindow(); - const isMacOS = - typeof window !== 'undefined' && - '__TAURI__' in window && - typeof navigator !== 'undefined' && - typeof navigator.platform === 'string' && - navigator.platform.toUpperCase().includes('MAC'); - const [position, size, isMaximized, isDecorated] = await Promise.all([ win.outerPosition(), - win.outerSize(), + // setSize restores the inner size, so capture the matching metric. + // Using outerSize here grows decorated windows on every mode round-trip. + win.innerSize(), win.isMaximized(), (async () => { try { @@ -76,6 +152,8 @@ export const ToolbarModeProvider: React.FC = ({ childr await import('./ToolbarMode'); + // Persist the current normal bounds before any compact-window mutation. + await setMainWindowTransientGeometry(true); setIsToolbarMode(true); setIsExpanded(true); @@ -89,8 +167,10 @@ export const ToolbarModeProvider: React.FC = ({ childr targetSize: TOOLBAR_EXPANDED_SIZE, minSize: TOOLBAR_EXPANDED_MIN, }); + await win.setMinSize(new PhysicalSize(geometry.minWidth, geometry.minHeight)); + await win.setAlwaysOnTop(true); + const toolbarWindowOps: Array> = [ - win.setAlwaysOnTop(true), win.setSize(new PhysicalSize(geometry.width, geometry.height)), win.setPosition(new PhysicalPosition(geometry.x, geometry.y)), win.setResizable(true), @@ -105,74 +185,35 @@ export const ToolbarModeProvider: React.FC = ({ childr } } await Promise.all(toolbarWindowOps); - - await win.setMinSize(new PhysicalSize(geometry.minWidth, geometry.minHeight)); } catch (error) { log.error('Failed to enable toolbar mode', error); setIsToolbarMode(false); + setIsExpanded(false); + try { + await restoreMainWindowFromToolbarMode(win, savedWindowStateRef.current, isMacOS); + savedWindowStateRef.current = null; + } catch (restoreError) { + // The native transient flag intentionally remains active if rollback + // cannot finish, so a partial floating geometry is never persisted. + log.error('Failed to restore main window after toolbar mode activation error', restoreError); + } } }, []); const disableToolbarMode = useCallback(async () => { + const win = getCurrentWindow(); + const isMacOS = + typeof window !== 'undefined' && + '__TAURI__' in window && + typeof navigator !== 'undefined' && + typeof navigator.platform === 'string' && + navigator.platform.toUpperCase().includes('MAC'); + try { setIsToolbarMode(false); setIsExpanded(false); - - const win = getCurrentWindow(); - const isMacOS = - typeof window !== 'undefined' && - '__TAURI__' in window && - typeof navigator !== 'undefined' && - typeof navigator.platform === 'string' && - navigator.platform.toUpperCase().includes('MAC'); - const saved = savedWindowStateRef.current; - - await win.setMinSize(null); - - if (isMacOS) { - try { - await win.setTitleBarStyle('overlay'); - } catch (error) { - log.debug('Failed to restore macOS overlay title bar (early, ignored)', error); - } - } else { - try { - const targetDecorations = saved?.isDecorated ?? false; - await win.setDecorations(targetDecorations); - } catch (error) { - log.debug('Failed to restore window decorations (ignored)', error); - } - } - - await Promise.all([ - win.setAlwaysOnTop(false), - win.setResizable(true), - win.setSkipTaskbar(false), - ]); - - if (saved) { - await win.setSize(new PhysicalSize(saved.width, saved.height)); - await win.setPosition(new PhysicalPosition(saved.x, saved.y)); - - if (saved.isMaximized) { - await win.maximize(); - } - } else { - await win.setSize(new PhysicalSize(1200, 800)); - await win.center(); - } - - if (isMacOS) { - try { - await win.setTitleBarStyle('overlay'); - await new Promise((resolve) => setTimeout(resolve, 60)); - await win.setTitleBarStyle('overlay'); - } catch (error) { - log.debug('Failed to re-apply macOS overlay title bar (ignored)', error); - } - } - - await win.setFocus(); + await restoreMainWindowFromToolbarMode(win, savedWindowStateRef.current, isMacOS); + savedWindowStateRef.current = null; } catch (error) { log.error('Failed to disable toolbar mode', error); } diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 8b73f06b21..8f3f9e5746 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -23,6 +23,10 @@ describe('isPeerLocalOnlyCommand', () => { expect(isPeerLocalOnlyCommand('get_prevent_sleep_enabled')).toBe(true); expect(isPeerLocalOnlyCommand('set_prevent_sleep_enabled')).toBe(true); }); + + it('keeps native main-window geometry control on the controller computer', () => { + expect(isPeerLocalOnlyCommand('set_main_window_transient_geometry')).toBe(true); + }); }); describe('peerInvokePriorityFor', () => { diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 308022cd08..cd9b628c49 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -20,6 +20,7 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'initialize_tray_after_startup', 'startup_window_control', 'toggle_main_window_fullscreen', + 'set_main_window_transient_geometry', 'get_prevent_sleep_enabled', 'set_prevent_sleep_enabled', 'restart_app', diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 9e3b818782..aa6cce7d99 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -270,6 +270,20 @@ export class SystemAPI { } } + /** + * Desktop only: protect normal main-window geometry while toolbar mode + * temporarily resizes the shared native window. + */ + async setMainWindowTransientGeometry(transient: boolean): Promise { + try { + await api.invoke('set_main_window_transient_geometry', { + request: { transient } + }); + } catch (error) { + throw createTauriCommandError('set_main_window_transient_geometry', error, { transient }); + } + } + /** * Desktop only: toggle OS-window fullscreen for the main window. *