diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 69d79b41..7cd2b23d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6423,6 +6423,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-store", "tauri-plugin-updater", + "tauri-plugin-window-state", "thiserror 1.0.69", "tiberius", "tokio", @@ -6984,6 +6985,21 @@ dependencies = [ "zip 4.6.1", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.13.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e109cc37..0c5f060c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,7 @@ tauri-plugin-store = "2" tauri-plugin-dialog = "2" tauri-plugin-deep-link = "2" tauri-plugin-os = "2" +tauri-plugin-window-state = "2" url = "2" base64 = "0.22" chrono = "0.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8591ac90..6f58b933 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,15 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:default", + "core:window:allow-show", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-is-maximized", + "core:window:allow-is-fullscreen", + "core:window:allow-start-dragging", + "window-state:default", "opener:default", "store:default", "dialog:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 248a9c24..1d4abbae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_window_state::Builder::default().build()) .manage(app_state.clone()) .manage(store.clone()) .setup(move |app| { @@ -160,6 +161,18 @@ pub fn run() { } } + // On non-macOS, remove native window decorations BEFORE showing + // the window so there is no flash of the native title bar + #[cfg(not(target_os = "macos"))] + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_decorations(false); + } + + // Show the window after full initialization + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5a39ccd8..82bf951e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "x": 16, "y": 18 }, - "hiddenTitle": true + "hiddenTitle": true, + "visible": false } ], "security": { diff --git a/src/__tests__/useWindowControls.test.ts b/src/__tests__/useWindowControls.test.ts new file mode 100644 index 00000000..6aa72f6b --- /dev/null +++ b/src/__tests__/useWindowControls.test.ts @@ -0,0 +1,89 @@ +/** + * @jest-environment node + */ +import { shouldReserveMacTrafficLightInset } from '@/composables/useWindowControls' + +describe('shouldReserveMacTrafficLightInset', () => { + it('returns true on macOS when not fullscreen', () => { + expect(shouldReserveMacTrafficLightInset(true, false, true)).toBe(true) + }) + + it('returns false on macOS when fullscreen', () => { + expect(shouldReserveMacTrafficLightInset(true, true, true)).toBe(false) + }) + + it('returns false on non-macOS even when not fullscreen', () => { + expect(shouldReserveMacTrafficLightInset(false, false, true)).toBe(false) + }) + + it('returns false on non-macOS fullscreen', () => { + expect(shouldReserveMacTrafficLightInset(false, true, true)).toBe(false) + }) + + it('returns false in browser (non-desktop) context', () => { + expect(shouldReserveMacTrafficLightInset(true, false, false)).toBe(false) + }) +}) + +describe('useWindowControls', () => { + const mockMinimize = jest.fn() + const mockToggleMaximize = jest.fn() + const mockClose = jest.fn() + const mockIsMaximized = jest.fn() + const mockIsFullscreen = jest.fn() + const mockOnResized = jest.fn() + + beforeEach(() => { + jest.resetModules() + jest.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + minimize: mockMinimize, + toggleMaximize: mockToggleMaximize, + close: mockClose, + isMaximized: mockIsMaximized, + isFullscreen: mockIsFullscreen, + onResized: mockOnResized, + }), + })) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('executes minimize action', async () => { + const { useWindowControls } = await import('@/composables/useWindowControls') + const controls = useWindowControls() + await controls.minimize() + expect(mockMinimize).toHaveBeenCalled() + }) + + it('executes toggleMaximize action', async () => { + const { useWindowControls } = await import('@/composables/useWindowControls') + const controls = useWindowControls() + await controls.toggleMaximize() + expect(mockToggleMaximize).toHaveBeenCalled() + }) + + it('executes close action', async () => { + const { useWindowControls } = await import('@/composables/useWindowControls') + const controls = useWindowControls() + await controls.close() + expect(mockClose).toHaveBeenCalled() + }) + + it('tracks isMaximized and isFullscreen state', async () => { + mockIsMaximized.mockResolvedValue(true) + mockIsFullscreen.mockResolvedValue(false) + mockOnResized.mockResolvedValue(jest.fn()) + + const { useWindowControls } = await import('@/composables/useWindowControls') + const controls = useWindowControls() + + // The composable refreshes state on mount via onMounted. + // Since we're in a test environment outside Vue, onMounted doesn't fire. + // We verify that the default state is sensible. + expect(controls.isMaximized.value).toBe(false) + expect(controls.isFullscreen.value).toBe(false) + }) +}) diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 5cf2a189..c17d3452 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -1,8 +1,12 @@