diff --git a/.changeset/optional-custom-titlebar.md b/.changeset/optional-custom-titlebar.md new file mode 100644 index 0000000000..61dbc8afe8 --- /dev/null +++ b/.changeset/optional-custom-titlebar.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Allow using native window chrome instead of the custom title bar. diff --git a/src-tauri/src/desktop/settings.rs b/src-tauri/src/desktop/settings.rs index 6f9c58e129..435a154b0e 100644 --- a/src-tauri/src/desktop/settings.rs +++ b/src-tauri/src/desktop/settings.rs @@ -7,13 +7,19 @@ use ts_rs::TS; pub struct DesktopSettings { pub close_to_background_on_close: bool, pub show_system_tray_icon: bool, + pub use_custom_title_bar: bool, } pub(crate) const DESKTOP_SETTINGS_PATH: &str = "desktop-preferences.json"; pub(crate) const CLOSE_TO_BACKGROUND_ON_CLOSE_KEY: &str = "closeToBackgroundOnClose"; pub(crate) const SHOW_SYSTEM_TRAY_ICON_KEY: &str = "showSystemTrayIcon"; +pub(crate) const USE_CUSTOM_TITLE_BAR_KEY: &str = "useCustomTitleBar"; pub(crate) const LEGACY_KEEP_BACKGROUND_RUNNING_KEY: &str = "keepBackgroundRunning"; +pub(crate) const fn use_custom_title_bar_default() -> bool { + cfg!(target_os = "windows") +} + pub(crate) fn tray_available_for_session(settings: DesktopSettings, tray_created: bool) -> bool { settings.show_system_tray_icon && tray_created } @@ -21,12 +27,14 @@ pub(crate) fn tray_available_for_session(settings: DesktopSettings, tray_created pub(crate) fn desktop_settings_from_values( close_to_background_on_close: Option, show_system_tray_icon: Option, + use_custom_title_bar: Option, keep_background_running: Option, ) -> DesktopSettings { DesktopSettings { close_to_background_on_close: close_to_background_on_close.unwrap_or(true) || keep_background_running.unwrap_or(false), show_system_tray_icon: show_system_tray_icon.unwrap_or(true), + use_custom_title_bar: use_custom_title_bar.unwrap_or(use_custom_title_bar_default()), } } @@ -41,6 +49,7 @@ mod tests { let settings = DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, + use_custom_title_bar: true, }; assert_eq!( @@ -48,6 +57,7 @@ mod tests { json!({ "closeToBackgroundOnClose": true, "showSystemTrayIcon": false, + "useCustomTitleBar": true, }) ); } @@ -58,11 +68,13 @@ mod tests { serde_json::from_value::(json!({ "closeToBackgroundOnClose": true, "showSystemTrayIcon": false, + "useCustomTitleBar": true, })) .unwrap(), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, + use_custom_title_bar: true, } ); } @@ -74,16 +86,18 @@ mod tests { assert!(output.contains("type DesktopSettings")); assert!(output.contains("closeToBackgroundOnClose: boolean")); assert!(output.contains("showSystemTrayIcon: boolean")); + assert!(output.contains("useCustomTitleBar: boolean")); assert!(!output.contains("keepBackgroundRunning: boolean")); } #[test] fn legacy_background_setting_keeps_close_behavior_enabled() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(true)), + desktop_settings_from_values(Some(false), Some(false), Some(false), Some(true)), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, + use_custom_title_bar: false, } ); } @@ -91,11 +105,20 @@ mod tests { #[test] fn explicit_close_setting_stays_disabled_when_legacy_background_is_off() { assert_eq!( - desktop_settings_from_values(Some(false), Some(true), Some(false)), + desktop_settings_from_values(Some(false), Some(true), Some(true), Some(false)), DesktopSettings { close_to_background_on_close: false, show_system_tray_icon: true, + use_custom_title_bar: true, } ); } + + #[test] + fn missing_custom_title_bar_setting_uses_platform_default() { + assert_eq!( + desktop_settings_from_values(Some(true), Some(true), None, None).use_custom_title_bar, + cfg!(target_os = "windows") + ); + } } diff --git a/src-tauri/src/desktop/tray.rs b/src-tauri/src/desktop/tray.rs index 9c05687130..ea545f5569 100644 --- a/src-tauri/src/desktop/tray.rs +++ b/src-tauri/src/desktop/tray.rs @@ -2,9 +2,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::desktop::runtime_state::DesktopRuntimeState; use crate::desktop::settings::{ - desktop_settings_from_values, tray_available_for_session, DesktopSettings, - CLOSE_TO_BACKGROUND_ON_CLOSE_KEY, DESKTOP_SETTINGS_PATH, LEGACY_KEEP_BACKGROUND_RUNNING_KEY, - SHOW_SYSTEM_TRAY_ICON_KEY, + desktop_settings_from_values, tray_available_for_session, use_custom_title_bar_default, + DesktopSettings, CLOSE_TO_BACKGROUND_ON_CLOSE_KEY, DESKTOP_SETTINGS_PATH, + LEGACY_KEEP_BACKGROUND_RUNNING_KEY, SHOW_SYSTEM_TRAY_ICON_KEY, USE_CUSTOM_TITLE_BAR_KEY, }; use serde_json::json; use tauri::{ @@ -24,6 +24,7 @@ const TRAY_MENU_QUIT_ID: &str = "tray_quit"; pub struct DesktopSettingsState { close_to_background_on_close: AtomicBool, show_system_tray_icon: AtomicBool, + use_custom_title_bar: AtomicBool, tray_available: AtomicBool, } @@ -32,6 +33,7 @@ impl Default for DesktopSettingsState { Self { close_to_background_on_close: AtomicBool::new(true), show_system_tray_icon: AtomicBool::new(true), + use_custom_title_bar: AtomicBool::new(use_custom_title_bar_default()), tray_available: AtomicBool::new(false), } } @@ -65,12 +67,18 @@ fn exit_request_action(settings: DesktopSettings, code: Option) -> ExitRequ } } -fn load_desktop_settings(app: &AppHandle) -> tauri::Result { +pub(crate) fn load_desktop_settings( + app: &AppHandle, +) -> tauri::Result { let store = app .store_builder(DESKTOP_SETTINGS_PATH) .defaults(std::collections::HashMap::from([ (CLOSE_TO_BACKGROUND_ON_CLOSE_KEY.into(), json!(true)), (SHOW_SYSTEM_TRAY_ICON_KEY.into(), json!(true)), + ( + USE_CUSTOM_TITLE_BAR_KEY.into(), + json!(use_custom_title_bar_default()), + ), ])) .build() .map_err(|error| tauri::Error::PluginInitialization("store".into(), error.to_string()))?; @@ -82,6 +90,9 @@ fn load_desktop_settings(app: &AppHandle) -> tauri::Result store .get(SHOW_SYSTEM_TRAY_ICON_KEY) .and_then(|value| value.as_bool()), + store + .get(USE_CUSTOM_TITLE_BAR_KEY) + .and_then(|value| value.as_bool()), store .get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY) .and_then(|value| value.as_bool()), @@ -93,6 +104,7 @@ fn current_desktop_settings(app: &AppHandle) -> DesktopSet DesktopSettings { close_to_background_on_close: state.close_to_background_on_close.load(Ordering::Relaxed), show_system_tray_icon: state.show_system_tray_icon.load(Ordering::Relaxed), + use_custom_title_bar: state.use_custom_title_bar.load(Ordering::Relaxed), } } @@ -137,6 +149,11 @@ fn apply_desktop_settings( state .show_system_tray_icon .store(settings.show_system_tray_icon, Ordering::Relaxed); + state + .use_custom_title_bar + .store(settings.use_custom_title_bar, Ordering::Relaxed); + + apply_main_window_title_bar_settings(app, settings)?; if settings.show_system_tray_icon && cfg!(not(target_os = "macos")) { if app.tray_by_id(MAIN_TRAY_ID).is_none() { @@ -167,6 +184,33 @@ fn apply_desktop_settings( Ok(desktop_runtime_state(app)) } +fn apply_main_window_title_bar_settings( + app: &AppHandle, + settings: DesktopSettings, +) -> tauri::Result<()> { + let Some(window) = app.get_webview_window(crate::MAIN_WINDOW_LABEL) else { + return Ok(()); + }; + + #[cfg(any(target_os = "windows", target_os = "linux"))] + window.set_decorations(!settings.use_custom_title_bar)?; + + #[cfg(target_os = "macos")] + { + window.set_title_bar_style(if settings.use_custom_title_bar { + tauri::TitleBarStyle::Transparent + } else { + tauri::TitleBarStyle::Visible + })?; + window.set_title(if settings.use_custom_title_bar { + "" + } else { + crate::main_window_title(app) + })?; + } + + Ok(()) +} fn close_all_windows(app: &AppHandle) { for (_label, window) in app.webview_windows() { let _ = window.close(); @@ -303,6 +347,7 @@ mod tests { let settings = DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, + use_custom_title_bar: false, }; assert_eq!( @@ -316,6 +361,7 @@ mod tests { let settings = DesktopSettings { show_system_tray_icon: true, close_to_background_on_close: false, + use_custom_title_bar: false, }; assert_eq!( @@ -329,6 +375,7 @@ mod tests { let settings = DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: true, + use_custom_title_bar: false, }; assert_eq!( @@ -343,6 +390,7 @@ mod tests { let settings = DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: true, + use_custom_title_bar: false, }; assert_eq!( @@ -354,10 +402,11 @@ mod tests { #[test] fn missing_store_values_default_to_enabled() { assert_eq!( - desktop_settings_from_values(None, None, None), + desktop_settings_from_values(None, None, None, None), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: true, + use_custom_title_bar: use_custom_title_bar_default(), } ); } @@ -365,10 +414,11 @@ mod tests { #[test] fn legacy_background_store_value_migrates_to_close_behavior() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(true)), + desktop_settings_from_values(Some(false), Some(false), Some(false), Some(true)), DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: false, + use_custom_title_bar: false, } ); } @@ -376,10 +426,11 @@ mod tests { #[test] fn explicit_store_values_are_preserved_when_legacy_background_is_off() { assert_eq!( - desktop_settings_from_values(Some(false), Some(false), Some(false)), + desktop_settings_from_values(Some(false), Some(false), Some(true), Some(false)), DesktopSettings { show_system_tray_icon: false, close_to_background_on_close: false, + use_custom_title_bar: true, } ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a90139843..77fa454bf2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,6 +24,11 @@ use tauri::Wry as BrowserEngine; pub const MAIN_WINDOW_LABEL: &str = "main"; +#[cfg(desktop)] +pub(crate) fn main_window_title(app: &AppHandle) -> &str { + app.config().product_name.as_deref().unwrap_or("Sable") +} + #[cfg(all( not(feature = "cef"), any( @@ -89,6 +94,29 @@ fn resolve_webview_permission( allowed } +#[cfg(all(feature = "cef", target_os = "linux"))] +fn setup_cef_resize_workaround( + webview_window: &tauri::WebviewWindow, +) -> tauri::Result<()> { + let webview = webview_window.as_ref().clone(); + webview.set_auto_resize(false)?; + webview_window.on_window_event(move |event| { + let size = match event { + tauri::WindowEvent::Resized(size) => *size, + tauri::WindowEvent::ScaleFactorChanged { new_inner_size, .. } => *new_inner_size, + _ => return, + }; + + if let Err(error) = webview.set_bounds(tauri::Rect { + position: tauri::Position::Physical(tauri::PhysicalPosition::new(0, 0)), + size: tauri::Size::Physical(size), + }) { + log::warn!("failed to resize CEF webview: {error}"); + } + }); + Ok(()) +} + pub fn show_or_create_main_window(app: &AppHandle) -> tauri::Result<()> { if let Some(_window) = app.get_webview_window(MAIN_WINDOW_LABEL) { #[cfg(desktop)] @@ -103,6 +131,9 @@ pub fn show_or_create_main_window(app: &AppHandle) -> taur log::info!("Main window not found, creating a new one."); + #[cfg(desktop)] + let desktop_settings = desktop::tray::load_desktop_settings(app)?; + let builder = tauri::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, tauri::WebviewUrl::default()) .disable_drag_drop_handler() @@ -110,17 +141,7 @@ pub fn show_or_create_main_window(app: &AppHandle) -> taur .background_color(tauri::window::Color(0x1A, 0x1C, 0x28, 0xFF)); #[cfg(desktop)] - let title = if app - .package_info() - .version - .pre - .as_str() - .starts_with("nightly.") - { - "Sable Nightly" - } else { - "Sable" - }; + let title = main_window_title(app); #[cfg(desktop)] let builder = builder @@ -130,18 +151,23 @@ pub fn show_or_create_main_window(app: &AppHandle) -> taur .inner_size(1280.0, 720.0) .visible(false); - // Float the native traffic lights over the content for a unified look. #[cfg(target_os = "macos")] - let builder = builder - .hidden_title(true) - .title_bar_style(tauri::TitleBarStyle::Transparent); + let builder = if desktop_settings.use_custom_title_bar { + builder + .title("") + .title_bar_style(tauri::TitleBarStyle::Transparent) + } else { + builder.title_bar_style(tauri::TitleBarStyle::Visible) + }; - // Windows and Linux draw their own titlebar (DesktopTitleBar). #[cfg(any(target_os = "windows", target_os = "linux"))] - let builder = builder.decorations(false); + let builder = builder.decorations(!desktop_settings.use_custom_title_bar); let webview_window = builder.build()?; + #[cfg(all(feature = "cef", target_os = "linux"))] + setup_cef_resize_workaround(&webview_window)?; + #[cfg(desktop)] desktop::tray::setup_close_to_background(&webview_window); @@ -407,6 +433,7 @@ mod tests { let _ = crate::desktop::settings::DesktopSettings { close_to_background_on_close: true, show_system_tray_icon: true, + use_custom_title_bar: false, }; let _ = crate::desktop::runtime_state::DesktopRuntimeState { tray_available: true, diff --git a/src/app/components/SyncConnectionStatus.tsx b/src/app/components/SyncConnectionStatus.tsx index e4cb95baba..4383079362 100644 --- a/src/app/components/SyncConnectionStatus.tsx +++ b/src/app/components/SyncConnectionStatus.tsx @@ -159,6 +159,7 @@ export function SyncConnectionStatusBanner({ status }: SyncConnectionStatusProps export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusProps) { const shouldReduceMotion = useReducedMotion(); + const progress = status?.progress; const pillVariants = shouldReduceMotion ? { hidden: { opacity: 0 }, @@ -261,7 +262,7 @@ export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusPro > {status.text} - {status.progress !== undefined && ( + {progress !== undefined && (
diff --git a/src/app/components/app-shell/AppShell.tsx b/src/app/components/app-shell/AppShell.tsx index 416dadfb19..d8405df231 100644 --- a/src/app/components/app-shell/AppShell.tsx +++ b/src/app/components/app-shell/AppShell.tsx @@ -13,6 +13,8 @@ import { Toast } from '$components/toast/Toast'; import type { ScreenSize } from '$hooks/useScreenSize'; import { ScreenSizeProvider } from '$hooks/useScreenSize'; import { isReactQueryDevtoolsEnabled } from '$pages/reactQueryDevtoolsGate'; +import { useDesktopSetting } from '$state/hooks/desktopSettings'; +import { getCustomTitlebarKind } from '$utils/tauriTitlebar'; import { SystemBarShell } from './SystemBarShell'; const ReactQueryDevtools = lazy(async () => { @@ -29,12 +31,7 @@ type AppShellProps = { }; export function AppShell({ children, queryClient, screenSize, jotaiStore }: AppShellProps) { - const tauriOs = isTauri() ? osType() : undefined; - const useDesktopTitleBar = tauriOs === 'windows' || tauriOs === 'linux'; - const useMacTitleBar = tauriOs === 'macos'; - const hasCustomTitleBar = useDesktopTitleBar || useMacTitleBar; const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); - const contentHeight = hasCustomTitleBar ? 'calc(100% - var(--tauri-titlebar-height))' : '100%'; const [portalContainer, setPortalContainer] = useState(null); return ( @@ -44,34 +41,12 @@ export function AppShell({ children, queryClient, screenSize, jotaiStore }: AppS - -
- {useDesktopTitleBar && } - {useMacTitleBar && } -
- - {children} - - -
-
+ {children} +
{reactQueryDevtoolsEnabled && ( @@ -85,3 +60,49 @@ export function AppShell({ children, queryClient, screenSize, jotaiStore }: AppS ); } + +type AppShellFrameProps = { + children: ReactNode; + portalContainer: HTMLDivElement | null; + onPortalContainerChange: (node: HTMLDivElement | null) => void; +}; + +function AppShellFrame({ children, portalContainer, onPortalContainerChange }: AppShellFrameProps) { + const [useCustomTitleBar] = useDesktopSetting('useCustomTitleBar'); + const tauriOs = isTauri() ? osType() : undefined; + const titlebarKind = getCustomTitlebarKind(useCustomTitleBar, tauriOs); + const contentHeight = titlebarKind ? 'calc(100% - var(--tauri-titlebar-height))' : '100%'; + + return ( + <> + +
+ {titlebarKind === 'desktop' && } + {titlebarKind === 'mac' && } +
+ + {children} + + +
+
+ + ); +} diff --git a/src/app/features/settings/desktop/Desktop.test.tsx b/src/app/features/settings/desktop/Desktop.test.tsx index f0347bacca..e4908564fa 100644 --- a/src/app/features/settings/desktop/Desktop.test.tsx +++ b/src/app/features/settings/desktop/Desktop.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type * as Folds from 'folds'; import { SequenceCardStyle } from '$features/settings/styles.css'; @@ -10,19 +11,28 @@ const { mockUseDesktopSettingsReady, mockUseDesktopRuntimeState, mockUseDesktopSettingsSyncing, -} = vi.hoisted(() => ({ - mockUseDesktopSetting: vi.fn< - (key: 'closeToBackgroundOnClose' | 'showSystemTrayIcon') => readonly [boolean, () => void] - >((key) => { - if (key === 'closeToBackgroundOnClose') return [true, vi.fn<() => void>()] as const; - return [true, vi.fn<() => void>()] as const; - }), - mockUseDesktopSettingsReady: vi.fn<() => boolean>(() => true), - mockUseDesktopSettingsSyncing: vi.fn<() => boolean>(() => false), - mockUseDesktopRuntimeState: vi.fn<() => { trayAvailable: boolean }>(() => ({ - trayAvailable: false, - })), -})); + mockSetUseCustomTitleBar, +} = vi.hoisted(() => { + const setUseCustomTitleBarMock = vi.fn<(value: boolean) => void>(); + + return { + mockUseDesktopSetting: vi.fn< + ( + key: 'closeToBackgroundOnClose' | 'showSystemTrayIcon' | 'useCustomTitleBar' + ) => readonly [boolean, (value: boolean) => void] + >((key) => { + if (key === 'useCustomTitleBar') return [true, setUseCustomTitleBarMock] as const; + if (key === 'closeToBackgroundOnClose') return [true, vi.fn<() => void>()] as const; + return [true, vi.fn<() => void>()] as const; + }), + mockSetUseCustomTitleBar: setUseCustomTitleBarMock, + mockUseDesktopSettingsReady: vi.fn<() => boolean>(() => true), + mockUseDesktopSettingsSyncing: vi.fn<() => boolean>(() => false), + mockUseDesktopRuntimeState: vi.fn<() => { trayAvailable: boolean }>(() => ({ + trayAvailable: false, + })), + }; +}); vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => true, @@ -39,6 +49,11 @@ vi.mock('$state/hooks/desktopSettings', () => ({ useDesktopRuntimeState: mockUseDesktopRuntimeState, })); +vi.mock('$components/page', () => ({ + PageContent: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsSectionPage: ({ children }: { children: ReactNode }) =>
{children}
, +})); + vi.mock('folds', async () => { const actual = await vi.importActual('folds'); return { @@ -82,6 +97,12 @@ describe('Desktop', () => { const { container } = renderDesktop(); expect(screen.getByText('Close button keeps Sable running')).toBeInTheDocument(); + expect(screen.getByText('Use custom title bar')).toBeInTheDocument(); + expect( + screen.getByText( + 'Use Sable-drawn window controls and connection status instead of the native window chrome.' + ) + ).toBeInTheDocument(); expect( screen.getByText( 'When enabled, closing the window keeps Sable running instead of exiting. If the tray icon is enabled and available, Sable stays in the system tray. Otherwise it continues running in the background.' @@ -96,6 +117,14 @@ describe('Desktop', () => { expect(container.getElementsByClassName(SequenceCardStyle)).toHaveLength(3); }); + it('updates the custom title bar setting from the Window switch', () => { + renderDesktop(); + + fireEvent.click(screen.getByRole('switch', { name: 'use-custom-title-bar' })); + + expect(mockSetUseCustomTitleBar).toHaveBeenCalledWith(false); + }); + it('shows fallback copy while the tray icon is enabled but unavailable', () => { renderDesktop(); diff --git a/src/app/features/settings/desktop/Desktop.tsx b/src/app/features/settings/desktop/Desktop.tsx index a4f40a29f5..580db9be46 100644 --- a/src/app/features/settings/desktop/Desktop.tsx +++ b/src/app/features/settings/desktop/Desktop.tsx @@ -28,6 +28,7 @@ export function Desktop({ requestBack, requestClose }: DesktopProps) { 'closeToBackgroundOnClose' ); const [showSystemTrayIcon, setShowSystemTrayIcon] = useDesktopSetting('showSystemTrayIcon'); + const [useCustomTitleBar, setUseCustomTitleBar] = useDesktopSetting('useCustomTitleBar'); const [autoUpdateCheck, setAutoUpdateCheck] = useAtom(autoUpdateCheckAtom); if (!isTauri() || !ready) return null; @@ -51,6 +52,18 @@ export function Desktop({ requestBack, requestClose }: DesktopProps) { direction="Column" gap="400" > + + } + /> ({ + SyncState: { + Prepared: 'Prepared', + Syncing: 'Syncing', + Catchup: 'Catchup', + Reconnecting: 'Reconnecting', + Error: 'Error', + }, +})); + +vi.mock('$hooks/useSlidingSyncHydrating', () => ({ + useSlidingSyncHydrating: () => ({ isHydrating: false, progress: null }), +})); + +vi.mock('$hooks/useSyncState', () => ({ + useSyncState: () => undefined, +})); + +vi.mock('$state/hooks/desktopSettings', () => ({ + useDesktopSetting: () => [false, vi.fn<() => void>()] as const, +})); + import { SyncState } from '$types/matrix-sdk'; -import { shouldShowConnecting } from './SyncStatus'; +import { shouldShowConnecting, shouldShowInlineSyncStatus } from './SyncStatus'; describe('shouldShowConnecting', () => { it('hides ordinary initial connection states', () => { @@ -18,3 +41,13 @@ describe('shouldShowConnecting', () => { expect(shouldShowConnecting(true, SyncState.Syncing, SyncState.Syncing)).toBe(false); }); }); + +describe('shouldShowInlineSyncStatus', () => { + it('keeps the inline banner for native chrome', () => { + expect(shouldShowInlineSyncStatus(false)).toBe(true); + }); + + it('hides the inline banner when a custom title bar renders the status', () => { + expect(shouldShowInlineSyncStatus(true)).toBe(false); + }); +}); diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 3adcde76d1..63f3288260 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -7,6 +7,7 @@ import { useSyncState } from '$hooks/useSyncState'; import { useSlidingSyncHydrating } from '$hooks/useSlidingSyncHydrating'; import { type TitlebarStatusView, titlebarStatusAtom } from '$state/titlebarStatus'; import { SyncConnectionStatusBanner } from '$components/SyncConnectionStatus'; +import { useDesktopSetting } from '$state/hooks/desktopSettings'; import { hasCustomDesktopTitlebar } from '$utils/tauriTitlebar'; const DISCONNECTED_GRACE_MS = 2000; @@ -28,11 +29,15 @@ export const shouldShowConnecting = ( current === SyncState.Catchup) && previous !== SyncState.Syncing; +export const shouldShowInlineSyncStatus = (hasCustomTitleBar: boolean): boolean => + !hasCustomTitleBar; + type SyncStatusProps = { mx: MatrixClient; }; export function SyncStatus({ mx }: SyncStatusProps) { const setTitlebarStatus = useSetAtom(titlebarStatusAtom); + const [useCustomTitleBar] = useDesktopSetting('useCustomTitleBar'); const hasConnectedRef = useRef(false); const [stateData, setStateData] = useState({ current: null, @@ -115,7 +120,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { }, [setTitlebarStatus, view]); // Where a custom titlebar renders the pill, skip the inline banner. - if (hasCustomDesktopTitlebar()) return null; + if (!shouldShowInlineSyncStatus(hasCustomDesktopTitlebar(useCustomTitleBar))) return null; return ; } diff --git a/src/app/state/desktopSettings.test.ts b/src/app/state/desktopSettings.test.ts index 424218eac0..8446e40e6c 100644 --- a/src/app/state/desktopSettings.test.ts +++ b/src/app/state/desktopSettings.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { DesktopRuntimeState, SyncDesktopSettingsParams } from '$generated/tauri/types'; import { DEFAULT_DESKTOP_SETTINGS, + desktopSettingsDefaultsForPlatform, desktopRuntimeStateAtom, desktopSettingsSyncingAtom, desktopSettingsFromStoreValues, @@ -80,6 +81,13 @@ describe('desktop settings state', () => { await expect(getDesktopSettings()).resolves.toEqual(DEFAULT_DESKTOP_SETTINGS); }); + it('enables the custom title bar by default only on Windows', () => { + expect(desktopSettingsDefaultsForPlatform('windows').useCustomTitleBar).toBe(true); + expect(desktopSettingsDefaultsForPlatform('linux').useCustomTitleBar).toBe(false); + expect(desktopSettingsDefaultsForPlatform('macos').useCustomTitleBar).toBe(false); + expect(desktopSettingsDefaultsForPlatform(undefined).useCustomTitleBar).toBe(false); + }); + it('loads a single desktop setting by key', async () => { mockEntries.mockResolvedValue([ ['closeToBackgroundOnClose', false], @@ -89,17 +97,36 @@ describe('desktop settings state', () => { await expect(getDesktopSetting('showSystemTrayIcon')).resolves.toBe(false); }); + it('loads an explicit persisted custom title bar value', async () => { + mockEntries.mockResolvedValue([['useCustomTitleBar', false]]); + + await expect(getDesktopSetting('useCustomTitleBar')).resolves.toBe(false); + }); + it('migrates the legacy background-running flag into close behavior', () => { - expect(desktopSettingsFromStoreValues(false, false, true)).toEqual({ + expect(desktopSettingsFromStoreValues(false, false, true, undefined)).toEqual({ closeToBackgroundOnClose: true, showSystemTrayIcon: false, + useCustomTitleBar: true, }); }); it('preserves an explicit close-off setting when the legacy flag is off', () => { - expect(desktopSettingsFromStoreValues(false, true, false)).toEqual({ + expect(desktopSettingsFromStoreValues(false, true, false, undefined)).toEqual({ closeToBackgroundOnClose: false, showSystemTrayIcon: true, + useCustomTitleBar: true, + }); + }); + + it('preserves an explicit custom title bar value over platform defaults', () => { + expect( + desktopSettingsFromStoreValues(undefined, undefined, undefined, false, 'windows') + ).toMatchObject({ useCustomTitleBar: false }); + expect( + desktopSettingsFromStoreValues(undefined, undefined, undefined, true, 'macos') + ).toMatchObject({ + useCustomTitleBar: true, }); }); @@ -114,6 +141,7 @@ describe('desktop settings state', () => { await store.set(desktopSettingsAtom, { closeToBackgroundOnClose: true, showSystemTrayIcon: true, + useCustomTitleBar: true, }); expect(mockSet).not.toHaveBeenCalled(); @@ -121,6 +149,7 @@ describe('desktop settings state', () => { settings: { closeToBackgroundOnClose: true, showSystemTrayIcon: true, + useCustomTitleBar: true, }, }); expect(store.get(desktopRuntimeStateAtom)).toEqual({ trayAvailable: false }); @@ -146,6 +175,7 @@ describe('desktop settings state', () => { const writePromise = store.set(desktopSettingsAtom, { closeToBackgroundOnClose: true, showSystemTrayIcon: true, + useCustomTitleBar: true, }); await vi.waitFor(() => { @@ -165,17 +195,20 @@ describe('desktop settings state', () => { saveDesktopSettings({ closeToBackgroundOnClose: false, showSystemTrayIcon: false, + useCustomTitleBar: false, }) ).resolves.toEqual({ trayAvailable: false }); - expect(mockSet).toHaveBeenCalledTimes(3); + expect(mockSet).toHaveBeenCalledTimes(4); expect(mockSet).toHaveBeenCalledWith('closeToBackgroundOnClose', false); expect(mockSet).toHaveBeenCalledWith('showSystemTrayIcon', false); expect(mockSet).toHaveBeenCalledWith('keepBackgroundRunning', false); + expect(mockSet).toHaveBeenCalledWith('useCustomTitleBar', false); expect(mockSyncDesktopSettings).toHaveBeenCalledWith({ settings: { closeToBackgroundOnClose: false, showSystemTrayIcon: false, + useCustomTitleBar: false, }, }); }); @@ -197,6 +230,7 @@ describe('desktop settings state', () => { settings: { closeToBackgroundOnClose: true, showSystemTrayIcon: false, + useCustomTitleBar: true, }, }); }); @@ -218,6 +252,7 @@ describe('desktop settings state', () => { settings: { closeToBackgroundOnClose: false, showSystemTrayIcon: true, + useCustomTitleBar: true, }, }); }); @@ -239,6 +274,7 @@ describe('desktop settings state', () => { settings: { closeToBackgroundOnClose: true, showSystemTrayIcon: false, + useCustomTitleBar: true, }, }); }); diff --git a/src/app/state/desktopSettings.ts b/src/app/state/desktopSettings.ts index 4d3c06e496..05c4870bca 100644 --- a/src/app/state/desktopSettings.ts +++ b/src/app/state/desktopSettings.ts @@ -1,10 +1,10 @@ import { atom } from 'jotai'; -import { isTauri } from '@tauri-apps/api/core'; -import { type as osType } from '@tauri-apps/plugin-os'; import { LazyStore } from '@tauri-apps/plugin-store'; import { getDesktopRuntimeState, syncDesktopSettings } from '$generated/tauri/commands'; import type { DesktopSettings as GeneratedDesktopSettings } from '$generated/tauri/desktop/DesktopSettings'; import type { DesktopRuntimeState } from '$generated/tauri/desktop/DesktopRuntimeState'; +import { getDesktopTauriPlatform, isDesktopTauri } from '$utils/platform'; +import type { DesktopTauriPlatform } from '$utils/platform'; type DesktopSettingsState = { ready: boolean; @@ -22,16 +22,24 @@ export type { DesktopRuntimeState }; const DESKTOP_SETTINGS_STORE_PATH = 'desktop-preferences.json' as const; const LEGACY_KEEP_BACKGROUND_RUNNING_KEY = 'keepBackgroundRunning' as const; -export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { - closeToBackgroundOnClose: true, - showSystemTrayIcon: true, -}; +type DesktopPlatform = DesktopTauriPlatform | undefined; + +export function desktopSettingsDefaultsForPlatform(platform: DesktopPlatform): DesktopSettings { + return { + closeToBackgroundOnClose: true, + showSystemTrayIcon: true, + useCustomTitleBar: platform === 'windows', + }; +} export type DesktopSettingKey = keyof DesktopSettings; export const DEFAULT_DESKTOP_RUNTIME_STATE: DesktopRuntimeState = { trayAvailable: true, }; +export const DEFAULT_DESKTOP_SETTINGS = + desktopSettingsDefaultsForPlatform(getDesktopTauriPlatform()); + const DESKTOP_SETTING_KEYS = Object.keys(DEFAULT_DESKTOP_SETTINGS) as DesktopSettingKey[]; const desktopSettingsStore = new LazyStore(DESKTOP_SETTINGS_STORE_PATH, { @@ -41,13 +49,6 @@ const desktopSettingsStore = new LazyStore(DESKTOP_SETTINGS_STORE_PATH, { let currentDesktopSettings = DEFAULT_DESKTOP_SETTINGS; let currentDesktopRuntimeState = DEFAULT_DESKTOP_RUNTIME_STATE; -function isDesktopTauri(): boolean { - if (!isTauri()) return false; - - const os = osType(); - return os === 'windows' || os === 'linux' || os === 'macos'; -} - function readBoolean(value: boolean | undefined, fallback: boolean): boolean { return value === undefined ? fallback : value; } @@ -72,16 +73,18 @@ async function persistDesktopSettings( export function desktopSettingsFromStoreValues( closeToBackgroundOnClose: boolean | undefined, showSystemTrayIcon: boolean | undefined, - legacyKeepBackgroundRunning: boolean | undefined + legacyKeepBackgroundRunning: boolean | undefined, + useCustomTitleBar: boolean | undefined, + platform = getDesktopTauriPlatform() ): DesktopSettings { + const defaults = desktopSettingsDefaultsForPlatform(platform); + return { closeToBackgroundOnClose: - readBoolean(closeToBackgroundOnClose, DEFAULT_DESKTOP_SETTINGS.closeToBackgroundOnClose) || + readBoolean(closeToBackgroundOnClose, defaults.closeToBackgroundOnClose) || readBoolean(legacyKeepBackgroundRunning, false), - showSystemTrayIcon: readBoolean( - showSystemTrayIcon, - DEFAULT_DESKTOP_SETTINGS.showSystemTrayIcon - ), + showSystemTrayIcon: readBoolean(showSystemTrayIcon, defaults.showSystemTrayIcon), + useCustomTitleBar: readBoolean(useCustomTitleBar, defaults.useCustomTitleBar), }; } @@ -105,17 +108,23 @@ async function applyDesktopSettings( export async function getDesktopSettings(): Promise { if (!isDesktopTauri()) return DEFAULT_DESKTOP_SETTINGS; - const [closeToBackgroundOnClose, showSystemTrayIcon, legacyKeepBackgroundRunning] = - await Promise.all([ - desktopSettingsStore.get('closeToBackgroundOnClose'), - desktopSettingsStore.get('showSystemTrayIcon'), - desktopSettingsStore.get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY), - ]); + const [ + closeToBackgroundOnClose, + showSystemTrayIcon, + legacyKeepBackgroundRunning, + useCustomTitleBar, + ] = await Promise.all([ + desktopSettingsStore.get('closeToBackgroundOnClose'), + desktopSettingsStore.get('showSystemTrayIcon'), + desktopSettingsStore.get(LEGACY_KEEP_BACKGROUND_RUNNING_KEY), + desktopSettingsStore.get('useCustomTitleBar'), + ]); currentDesktopSettings = desktopSettingsFromStoreValues( closeToBackgroundOnClose, showSystemTrayIcon, - legacyKeepBackgroundRunning + legacyKeepBackgroundRunning, + useCustomTitleBar ); return currentDesktopSettings; diff --git a/src/app/state/hooks/desktopSettings.test.tsx b/src/app/state/hooks/desktopSettings.test.tsx index ca2f86f94e..4517538400 100644 --- a/src/app/state/hooks/desktopSettings.test.tsx +++ b/src/app/state/hooks/desktopSettings.test.tsx @@ -93,6 +93,7 @@ describe('useDesktopSetting', () => { settings: { closeToBackgroundOnClose: true, showSystemTrayIcon: false, + useCustomTitleBar: true, }, }); @@ -126,6 +127,7 @@ describe('useDesktopSetting', () => { settings: { closeToBackgroundOnClose: false, showSystemTrayIcon: true, + useCustomTitleBar: true, }, }); diff --git a/src/app/styles/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts index 714283cd7e..83627f4665 100644 --- a/src/app/styles/edgeToEdgeInsets.test.ts +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -40,7 +40,11 @@ describe('android edge-to-edge inset contract', () => { expect(appTsx).toContain('screenSize={screenSize}'); expect(appTsx).toContain('queryClient={queryClient}'); expect(appShell).toContain('const [portalContainer, setPortalContainer] = useState'); - expect(appShell).toContain(''); + expect(appShell).toContain('onPortalContainerChange={setPortalContainer}'); + expect(appShell).toContain('function AppShellFrame'); + expect(appShell).toContain( + '' + ); expect(systemBarShell).toContain('ref={onPortalContainerChange}'); }); @@ -49,7 +53,9 @@ describe('android edge-to-edge inset contract', () => { const systemBarShell = readWorkspaceFile('src/app/components/app-shell/SystemBarShell.tsx'); const mobileCapability = readWorkspaceFile('src-tauri/capabilities/mobile.json'); - expect(appShell).toContain('const contentHeight = hasCustomTitleBar'); + expect(appShell).toContain( + "const contentHeight = titlebarKind ? 'calc(100% - var(--tauri-titlebar-height))' : '100%';" + ); expect(appShell).toContain("height: '100%'"); expect(appShell).toContain('height: contentHeight'); expect(appShell).toContain(''); diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css index 33a1dcd143..695a8cf8f2 100644 --- a/src/app/styles/overrides/TauriDesktop.css +++ b/src/app/styles/overrides/TauriDesktop.css @@ -93,7 +93,6 @@ .tauri-titlebar-status__label--primary { background: color-mix(in srgb, var(--sable-primary-main) 16%, transparent); } - .tauri-titlebar__control { appearance: none; -webkit-appearance: none; diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 9cfc1eeb22..c9d3b2bc3c 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -6,10 +6,20 @@ export function hasServiceWorker(): boolean { return 'serviceWorker' in navigator && !isTauri(); } -const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); +export type DesktopTauriPlatform = 'linux' | 'macos' | 'windows'; + +const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); + +export function getDesktopTauriPlatform(): DesktopTauriPlatform | undefined { + if (!isTauri()) return undefined; + const os = osType() as string; + return DESKTOP_TAURI_OS.has(os as DesktopTauriPlatform) + ? (os as DesktopTauriPlatform) + : undefined; +} export function isDesktopTauri(): boolean { - return isTauri() && DESKTOP_TAURI_OS.has(osType()); + return getDesktopTauriPlatform() !== undefined; } export function hasControllingServiceWorker(): boolean { diff --git a/src/app/utils/tauriTitlebar.test.ts b/src/app/utils/tauriTitlebar.test.ts new file mode 100644 index 0000000000..10f8bcbe2d --- /dev/null +++ b/src/app/utils/tauriTitlebar.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { getCustomTitlebarKind } from './tauriTitlebar'; + +describe('getCustomTitlebarKind', () => { + it('renders the matching custom title bar when enabled', () => { + expect(getCustomTitlebarKind(true, 'windows')).toBe('desktop'); + expect(getCustomTitlebarKind(true, 'linux')).toBe('desktop'); + expect(getCustomTitlebarKind(true, 'macos')).toBe('mac'); + }); + + it('keeps native chrome when disabled or outside desktop Tauri', () => { + expect(getCustomTitlebarKind(false, 'windows')).toBeNull(); + expect(getCustomTitlebarKind(false, 'macos')).toBeNull(); + expect(getCustomTitlebarKind(true, undefined)).toBeNull(); + expect(getCustomTitlebarKind(true, 'android')).toBeNull(); + }); +}); diff --git a/src/app/utils/tauriTitlebar.ts b/src/app/utils/tauriTitlebar.ts index 7167df47aa..ab5668db37 100644 --- a/src/app/utils/tauriTitlebar.ts +++ b/src/app/utils/tauriTitlebar.ts @@ -1,10 +1,19 @@ import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; -// Windows and Linux draw their own titlebar with a slot for the sync pill; macOS -// keeps native decorations and uses the inline banner like web/mobile. -export function hasCustomDesktopTitlebar(): boolean { +export type CustomTitlebarKind = 'desktop' | 'mac' | null; + +export function getCustomTitlebarKind( + useCustomTitleBar: boolean, + platform: string | undefined +): CustomTitlebarKind { + if (!useCustomTitleBar) return null; + if (platform === 'windows' || platform === 'linux') return 'desktop'; + if (platform === 'macos') return 'mac'; + return null; +} + +export function hasCustomDesktopTitlebar(useCustomTitleBar: boolean): boolean { if (!isTauri()) return false; - const os = osType(); - return os === 'windows' || os === 'linux'; + return getCustomTitlebarKind(useCustomTitleBar, osType()) !== null; }