diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07e20d7d..17adc0ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,14 +193,37 @@ jobs: fi tauri-rust: - name: Tauri Rust - runs-on: macos-latest + name: Tauri Rust (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + label: Linux + - os: macos-latest + label: macOS timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + libssl-dev \ + libxdo-dev \ + patchelf + - name: Install Rust run: | rustup toolchain install stable --profile minimal diff --git a/.gitignore b/.gitignore index a4fb5197..77f86eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ Thumbs.db .cargo-target/ apps/desktop/src-tauri/gen/schemas/windows-schema.json +apps/desktop/src-tauri/gen/schemas/linux-schema.json .superpowers diff --git a/apps/desktop/src-tauri/src/app_exit.rs b/apps/desktop/src-tauri/src/app_exit.rs index 7a5a27a0..938fd668 100644 --- a/apps/desktop/src-tauri/src/app_exit.rs +++ b/apps/desktop/src-tauri/src/app_exit.rs @@ -3,18 +3,38 @@ use tauri::{Emitter, Manager, Runtime}; const APP_EXIT_REQUESTED_EVENT: &str = "markra://app-exit-requested"; -#[derive(Clone, Copy)] -struct AppExitWindowInfo<'a> { +#[derive(Clone)] +struct AppExitWindowInfo { focused: bool, - label: &'a str, + label: String, visible: bool, } -fn is_app_exit_user_window(window: &AppExitWindowInfo<'_>) -> bool { - window.visible && !is_settings_window_label(window.label) +fn is_app_exit_user_window(window: &AppExitWindowInfo) -> bool { + window.visible && !is_settings_window_label(&window.label) } -fn app_exit_target_label<'a>(windows: &'a [AppExitWindowInfo<'a>]) -> Option<&'a str> { +fn collect_app_exit_window_infos(app: &tauri::AppHandle) -> Vec { + let windows = app.webview_windows(); + windows + .values() + .map(|window| AppExitWindowInfo { + focused: window.is_focused().unwrap_or(false), + label: window.label().to_string(), + visible: window.is_visible().unwrap_or(false), + }) + .collect::>() +} + +fn count_app_exit_user_windows(app: &tauri::AppHandle) -> usize { + let window_infos = collect_app_exit_window_infos(app); + window_infos + .iter() + .filter(|window| is_app_exit_user_window(window)) + .count() +} + +fn app_exit_target_label(windows: &[AppExitWindowInfo]) -> Option { windows .iter() .filter(|window| is_app_exit_user_window(window)) @@ -24,42 +44,57 @@ fn app_exit_target_label<'a>(windows: &'a [AppExitWindowInfo<'a>]) -> Option<&'a .iter() .find(|window| is_app_exit_user_window(window)) }) - .map(|window| window.label) + .map(|window| window.label.clone()) } fn should_intercept_app_exit(code: Option, user_window_count: usize) -> bool { code.is_none() && user_window_count > 0 } -pub(crate) fn handle_app_exit_requested( - app: &tauri::AppHandle, - code: Option, - api: tauri::ExitRequestApi, -) { - let windows = app.webview_windows(); - let window_infos = windows - .values() - .map(|window| AppExitWindowInfo { - focused: window.is_focused().unwrap_or(false), - label: window.label(), - visible: window.is_visible().unwrap_or(false), - }) - .collect::>(); +/// Emits the app-exit-requested event to the focused (or first) user window so +/// the frontend can run its discard/save confirmation flow. No-op when there +/// is no visible user window to confirm with. This does not call +/// `ExitRequestApi::prevent_exit`; that is the caller's responsibility for the +/// `RunEvent::ExitRequested` path, and the self-drawn Quit menu path does not +/// have an exit request to prevent. +fn emit_app_exit_requested(app: &tauri::AppHandle) { + let window_infos = collect_app_exit_window_infos(app); let user_window_count = window_infos .iter() .filter(|window| is_app_exit_user_window(window)) .count(); - if !should_intercept_app_exit(code, user_window_count) { + if user_window_count == 0 { return; } - api.prevent_exit(); - if let Some(window) = app_exit_target_label(&window_infos).and_then(|label| windows.get(label)) + if let Some(label) = + app_exit_target_label(&window_infos).and_then(|label| app.get_webview_window(&label)) { - let _ = window.emit(APP_EXIT_REQUESTED_EVENT, ()); + let _ = label.emit(APP_EXIT_REQUESTED_EVENT, ()); } } +/// Triggers the app-wide exit confirmation flow from the self-drawn menu Quit +/// item. Routes through the same frontend listener as a native window-close +/// exit request so discard/save confirmation and `exitNativeApp()` run once. +#[tauri::command] +pub(crate) fn request_app_exit(app: tauri::AppHandle) { + emit_app_exit_requested(&app); +} + +pub(crate) fn handle_app_exit_requested( + app: &tauri::AppHandle, + code: Option, + api: tauri::ExitRequestApi, +) { + if !should_intercept_app_exit(code, count_app_exit_user_windows(app)) { + return; + } + + api.prevent_exit(); + emit_app_exit_requested(app); +} + #[cfg(test)] mod tests { use super::*; @@ -79,7 +114,7 @@ mod tests { fn ignores_settings_windows_for_app_exit_interception() { let windows = [AppExitWindowInfo { focused: true, - label: "markra-settings", + label: "markra-settings".to_string(), visible: false, }]; let user_window_count = windows @@ -97,16 +132,16 @@ mod tests { let windows = [ AppExitWindowInfo { focused: true, - label: "markra-settings", + label: "markra-settings".to_string(), visible: false, }, AppExitWindowInfo { focused: false, - label: "main", + label: "main".to_string(), visible: true, }, ]; - assert_eq!(app_exit_target_label(&windows), Some("main")); + assert_eq!(app_exit_target_label(&windows).as_deref(), Some("main")); } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cb94ebe1..0b3f26b1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -34,7 +34,7 @@ use ai_chat_attachments::{ delete_ai_chat_attachment_session, read_ai_chat_attachment, save_ai_chat_attachment, }; use ai_http::{request_ai_provider_json, request_native_chat, request_native_chat_stream}; -use app_exit::handle_app_exit_requested; +use app_exit::{handle_app_exit_requested, request_app_exit}; use app_logs::open_log_folder; use backup::backup_markdown_folder; use clipboard::{read_clipboard_content, read_clipboard_text}; @@ -316,6 +316,7 @@ pub fn run() { open_settings_window, prewarm_settings_window, mark_settings_window_ready, + request_app_exit, hide_settings_window, open_external_url, request_ai_provider_json, diff --git a/apps/desktop/src-tauri/src/markdown_files/attachment.rs b/apps/desktop/src-tauri/src/markdown_files/attachment.rs index 6a6cbf87..630da459 100644 --- a/apps/desktop/src-tauri/src/markdown_files/attachment.rs +++ b/apps/desktop/src-tauri/src/markdown_files/attachment.rs @@ -1240,7 +1240,10 @@ mod tests { #[test] fn rejects_a_fifo_replaced_before_source_open_without_blocking() { use std::sync::mpsc; - use std::time::Duration; + use std::time::{Duration, Instant}; + + const FIFO_OPEN_DEADLINE: Duration = Duration::from_secs(5); + const FIFO_OPEN_POLL_INTERVAL: Duration = Duration::from_millis(10); let fixture = AttachmentFixture::new(); let source_fixture = AttachmentFixture::new(); @@ -1251,6 +1254,9 @@ mod tests { let source_path = source.to_string_lossy().to_string(); let thread_source = source.clone(); let (result_sender, result_receiver) = mpsc::channel(); + // Signals the main thread once the hook has finished replacing the source with a FIFO, + // so the timeout window below never races the hook's remove+mkfifo swap. + let (fifo_ready_sender, fifo_ready_receiver) = mpsc::channel(); let import_thread = std::thread::spawn(move || { let result = import_local_file_with_scope_and_hook( note, @@ -1266,6 +1272,9 @@ mod tests { if !status.success() { return Err("mkfifo failed".to_string()); } + fifo_ready_sender + .send(()) + .expect("test receiver should remain"); Ok(()) }, ); @@ -1274,17 +1283,56 @@ mod tests { .expect("test receiver should remain"); }); + fifo_ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("hook should finish replacing the source with a FIFO"); + let result = match result_receiver.recv_timeout(Duration::from_millis(250)) { Ok(result) => result, Err(mpsc::RecvTimeoutError::Timeout) => { - let writer = fs::OpenOptions::new() - .write(true) - .open(&source) - .expect("FIFO writer should release the blocked source open"); - drop(writer); - let _ = result_receiver.recv_timeout(Duration::from_secs(1)); - import_thread.join().expect("import thread should finish"); - panic!("source open blocked on a FIFO replacement"); + // The import thread is still running after 250ms, which means it is blocked + // opening the FIFO for reading. Probe with a nonblocking writer open: on a FIFO + // this succeeds immediately when a reader is present and returns ENXIO when there + // is none, so the probe itself can never hang. Release the writer right away; if + // a reader races us it still proceeds, and the import result below stays + // authoritative for what the test asserts. + let deadline = Instant::now() + FIFO_OPEN_DEADLINE; + loop { + match result_receiver.recv_timeout(FIFO_OPEN_POLL_INTERVAL) { + Ok(result) => break result, + Err(mpsc::RecvTimeoutError::Timeout) => { + match rustix::fs::open( + &source, + rustix::fs::OFlags::WRONLY | rustix::fs::OFlags::NONBLOCK, + rustix::fs::Mode::empty(), + ) { + Ok(fd) => { + drop(fd); + // The blocked reader now has a writer; wait for the import + // thread to observe the FIFO and reject it. + break result_receiver.recv_timeout(FIFO_OPEN_DEADLINE).expect( + "import should finish after the FIFO reader is released", + ); + } + Err(error) + if error == rustix::io::Errno::NXIO + || error.kind() == io::ErrorKind::WouldBlock => + { + // No reader yet (ENXIO); keep probing until the deadline. + if Instant::now() >= deadline { + break result_receiver + .recv_timeout(FIFO_OPEN_DEADLINE) + .expect( + "import should finish after the FIFO reader is released", + ); + } + } + Err(error) => panic!("nonblocking FIFO probe failed: {error}"), + } + } + Err(error) => panic!("source import channel failed: {error}"), + } + } } Err(error) => panic!("source import channel failed: {error}"), }; diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs index 6796c2cf..1d7efe1a 100644 --- a/apps/desktop/src-tauri/src/menu.rs +++ b/apps/desktop/src-tauri/src/menu.rs @@ -324,7 +324,6 @@ fn application_about_metadata() -> AboutMetadata<'static> { } } -#[cfg(any(windows, test))] fn native_about_full_version(metadata: &AboutMetadata<'_>) -> Option { match (&metadata.version, &metadata.short_version) { (Some(version), Some(short_version)) => Some(format!("{version} ({short_version})")), @@ -333,12 +332,10 @@ fn native_about_full_version(metadata: &AboutMetadata<'_>) -> Option { } } -#[cfg(any(windows, test))] fn native_about_dialog_title(metadata: &AboutMetadata<'_>) -> String { format!("About {}", metadata.name.as_deref().unwrap_or("Markra")) } -#[cfg(any(windows, test))] fn native_about_dialog_message(metadata: &AboutMetadata<'_>) -> String { use std::fmt::Write; @@ -411,10 +408,43 @@ fn show_native_app_about_for_window( Ok(()) } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +fn show_native_app_about_for_window( + window: &tauri::Window, +) -> Result<(), String> { + // On Linux the self-drawn titlebar's "About Markra" entry is wired to + // this command instead of a native predefined About item (the native + // menubar is hidden). Surface the same metadata used by the Windows + // implementation through tauri-plugin-dialog so the panel renders with + // the platform-native toolkit (GTK on Linux). + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + + let metadata = application_about_metadata(); + let title = native_about_dialog_title(&metadata); + let message = native_about_dialog_message(&metadata); + let app_handle = window.app_handle().clone(); + let window = window.clone(); + + std::thread::spawn(move || { + app_handle + .dialog() + .message(message) + .title(title) + .buttons(MessageDialogButtons::Ok) + .kind(MessageDialogKind::Info) + .parent(&window) + .blocking_show(); + }); + + Ok(()) +} + +#[cfg(not(any(target_os = "windows", target_os = "linux")))] fn show_native_app_about_for_window( _window: &tauri::Window, ) -> Result<(), String> { + // macOS keeps the native menu bar, whose About item opens the AppKit + // about panel directly, so this command never reaches the frontend there. Ok(()) } @@ -582,7 +612,7 @@ pub(crate) fn create_application_menu( create_application_menu_for_language(app, language, None, &[]) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] pub(crate) fn create_settings_window_menu( app: &tauri::AppHandle, ) -> tauri::Result> { @@ -1068,7 +1098,7 @@ pub(crate) fn install_application_menu( app.set_menu(menu).map_err(|error| error.to_string())?; state.remember_installed(profile, config); - crate::windows::hide_native_menu_for_settings_window_in_app(&app); + crate::windows::hide_native_menus_for_app(&app); Ok(()) } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d5efeced..5eb1f6e4 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -179,32 +179,58 @@ fn should_hide_native_menu_for_window_label_on_platform(platform: &str, label: & return true; } - platform == "windows" && is_editor_window_label(label) + (platform == "windows" || platform == "linux") && is_editor_window_label(label) } -fn should_hide_native_menu_for_window_label(label: &str) -> bool { - should_hide_native_menu_for_window_label_on_platform(current_window_chrome_platform(), label) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NativeMenuWindowAction { + Keep, + Hide, + Remove, +} + +fn native_menu_window_action_for_platform(platform: &str, label: &str) -> NativeMenuWindowAction { + if platform == "linux" && is_settings_window_label(label) { + return NativeMenuWindowAction::Remove; + } + + if should_hide_native_menu_for_window_label_on_platform(platform, label) { + return NativeMenuWindowAction::Hide; + } + + NativeMenuWindowAction::Keep } fn editor_window_decorations_for_platform(platform: &str) -> bool { - platform != "windows" + platform != "windows" && platform != "linux" } -pub(crate) fn hide_native_menu_for_settings_window(window: &tauri::WebviewWindow) +pub(crate) fn hide_native_menu_for_window(window: &tauri::WebviewWindow) where R: tauri::Runtime, { - if should_hide_native_menu_for_window_label(window.label()) { - let _ = window.hide_menu(); + match native_menu_window_action_for_platform(current_window_chrome_platform(), window.label()) { + NativeMenuWindowAction::Keep => {} + NativeMenuWindowAction::Hide => { + let _ = window.hide_menu(); + } + NativeMenuWindowAction::Remove => { + // On GTK, hide_menu() leaves the GtkMenuBar in the widget tree and + // WebKit/tao's first show uses show_all(), which makes it flash back + // into view. Removing it destroys the widget instead. + let _ = window.remove_menu(); + } } } -pub(crate) fn hide_native_menu_for_settings_window_in_app(app: &tauri::AppHandle) +pub(crate) fn hide_native_menus_for_app(app: &tauri::AppHandle) where R: tauri::Runtime, { + let _ = app.hide_menu(); + if let Some(window) = app.get_webview_window(SETTINGS_WINDOW_LABEL) { - hide_native_menu_for_settings_window(&window); + hide_native_menu_for_window(&window); } } @@ -331,7 +357,22 @@ where } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub(crate) fn apply_window_event_chrome(window: &tauri::Window, event: &tauri::WindowEvent) +where + R: tauri::Runtime, +{ + // GTK may re-show a window's menubar whenever the window is focused (for + // example after the app-wide menu is reinstalled). On Linux the app draws + // its own titlebar, so re-assert that the native menubar stays hidden. + if matches!(event, tauri::WindowEvent::Focused(true)) { + if let Some(webview_window) = window.app_handle().get_webview_window(window.label()) { + hide_native_menu_for_window(&webview_window); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] pub(crate) fn apply_window_event_chrome(_window: &tauri::Window, _event: &tauri::WindowEvent) where R: tauri::Runtime, @@ -354,7 +395,7 @@ where R: tauri::Runtime, { if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - hide_native_menu_for_settings_window(&window); + hide_native_menu_for_window(&window); } } @@ -529,7 +570,7 @@ where Ok(window) => { remember_native_menu_webview_window(&window); hide_native_macos_window_controls(&window); - hide_native_menu_for_settings_window(&window); + hide_native_menu_for_window(&window); } Err(error) => { eprintln!("failed to create blank editor window: {error}"); @@ -879,6 +920,7 @@ where R: tauri::Runtime, { let Ok(mut state) = settings_window_runtime_state().lock() else { + hide_native_menu_for_window(window); let _ = window.show(); let _ = window.set_focus(); return; @@ -888,6 +930,9 @@ where next_settings_window_idle_destroy_generation(&mut state); drop(state); + // app.set_menu() can reattach the app-wide GTK menu to this window while + // it is hidden. Detach it immediately before the first visible frame. + hide_native_menu_for_window(window); let _ = window.show(); let _ = window.set_focus(); } @@ -1036,7 +1081,7 @@ fn handle_existing_settings_window( hide_settings_window_instance(window); } ExistingSettingsWindowAction::Show => { - hide_native_menu_for_settings_window(window); + hide_native_menu_for_window(window); if request_settings_window_show_when_ready(target) { show_settings_window(window); if let Some(target) = target { @@ -1114,7 +1159,7 @@ fn spawn_settings_window_with_mode( .shadow(settings_window_shadow()) .center(); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] let builder = match crate::menu::create_settings_window_menu(&app) { Ok(menu) => builder.menu(menu), Err(error) => { @@ -1140,7 +1185,7 @@ fn spawn_settings_window_with_mode( match builder.build() { Ok(window) => { hide_native_macos_window_controls(&window); - hide_native_menu_for_settings_window(&window); + hide_native_menu_for_window(&window); if !app_has_visible_user_window(&app, None) { reset_settings_window_runtime_state(); let _ = window.close(); @@ -1282,11 +1327,17 @@ mod tests { assert!(!settings_window_decorations()); } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] { assert!(editor_window_decorations()); assert!(settings_window_decorations()); } + + #[cfg(target_os = "linux")] + { + assert!(!editor_window_decorations()); + assert!(!settings_window_decorations()); + } } #[cfg(target_os = "windows")] @@ -1446,6 +1497,9 @@ mod tests { &std::fs::read_to_string(config_path).expect("Linux Tauri config should exist"), ) .expect("Linux Tauri config should be valid JSON"); + let decorations = config + .pointer("/app/windows/0/decorations") + .and_then(serde_json::Value::as_bool); let transparent = config .pointer("/app/windows/0/transparent") .and_then(serde_json::Value::as_bool); @@ -1453,8 +1507,9 @@ mod tests { .pointer("/app/windows/0/visible") .and_then(serde_json::Value::as_bool); + assert_eq!(decorations, Some(false)); assert_eq!(transparent, Some(false)); - assert_eq!(visible, Some(true)); + assert_eq!(visible, Some(false)); } #[test] @@ -1552,13 +1607,46 @@ mod tests { "macos", MAIN_WINDOW_LABEL )); + assert!(should_hide_native_menu_for_window_label_on_platform( + "linux", + MAIN_WINDOW_LABEL + )); + assert!(should_hide_native_menu_for_window_label_on_platform( + "linux", + "markra-editor-1" + )); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_settings_window_removes_native_menu_before_showing() { + assert_eq!( + native_menu_window_action_for_platform("linux", SETTINGS_WINDOW_LABEL), + NativeMenuWindowAction::Remove + ); + assert_eq!( + native_menu_window_action_for_platform("linux", MAIN_WINDOW_LABEL), + NativeMenuWindowAction::Hide + ); + assert_eq!( + native_menu_window_action_for_platform("linux", "markra-editor-1"), + NativeMenuWindowAction::Hide + ); + assert_eq!( + native_menu_window_action_for_platform("windows", SETTINGS_WINDOW_LABEL), + NativeMenuWindowAction::Hide + ); + assert_eq!( + native_menu_window_action_for_platform("macos", MAIN_WINDOW_LABEL), + NativeMenuWindowAction::Keep + ); } #[test] fn windows_editor_windows_are_self_drawn() { assert!(!editor_window_decorations_for_platform("windows")); assert!(editor_window_decorations_for_platform("macos")); - assert!(editor_window_decorations_for_platform("linux")); + assert!(!editor_window_decorations_for_platform("linux")); } #[test] diff --git a/apps/desktop/src-tauri/tauri.linux.conf.json b/apps/desktop/src-tauri/tauri.linux.conf.json index 9591df0c..41beaa08 100644 --- a/apps/desktop/src-tauri/tauri.linux.conf.json +++ b/apps/desktop/src-tauri/tauri.linux.conf.json @@ -8,10 +8,10 @@ "height": 800, "minWidth": 360, "minHeight": 320, - "decorations": true, + "decorations": false, "transparent": false, "shadow": true, - "visible": true + "visible": false } ] } diff --git a/apps/desktop/src/runtime/index.ts b/apps/desktop/src/runtime/index.ts index 4fb0d99e..641f0c0f 100644 --- a/apps/desktop/src/runtime/index.ts +++ b/apps/desktop/src/runtime/index.ts @@ -195,6 +195,7 @@ export const desktopRuntime = { openExternalUrl: windowRuntime.openNativeExternalUrl, openSettingsWindow: windowRuntime.openSettingsWindow, prewarmSettingsWindow: windowRuntime.prewarmSettingsWindow, + requestAppExit: windowRuntime.requestNativeAppExit, markSettingsWindowReady: windowRuntime.markSettingsWindowReady, hideSettingsWindow: windowRuntime.hideSettingsWindow, setEditorWindowRestoreState: windowRuntime.setNativeEditorWindowRestoreState, diff --git a/apps/desktop/src/runtime/tauri/window.test.ts b/apps/desktop/src/runtime/tauri/window.test.ts index 15e5d79c..2e87fdc9 100644 --- a/apps/desktop/src/runtime/tauri/window.test.ts +++ b/apps/desktop/src/runtime/tauri/window.test.ts @@ -13,9 +13,10 @@ import { listNativeEditorWindowRestoreStates, hideSettingsWindow, markSettingsWindowReady, - minimizeNativeWindow, - openNativeBlankEditorWindow, - openSettingsWindow, + minimizeNativeWindow, + openNativeBlankEditorWindow, + requestNativeAppExit, + openSettingsWindow, prewarmSettingsWindow, setNativeEditorWindowRestoreState, showNativeWindow, @@ -145,12 +146,20 @@ describe("native window actions", () => { expect(mockedGetCurrentWindow).not.toHaveBeenCalled(); }); - it("opens a blank editor window through the native command", async () => { + it("opens a blank editor window through the native command", async () => { + mockedInvoke.mockResolvedValue(undefined); + + await openNativeBlankEditorWindow(); + + expect(mockedInvoke).toHaveBeenCalledWith("open_blank_editor_window"); + }); + + it("requests an app-wide exit through the native command", async () => { mockedInvoke.mockResolvedValue(undefined); - await openNativeBlankEditorWindow(); + await requestNativeAppExit(); - expect(mockedInvoke).toHaveBeenCalledWith("open_blank_editor_window"); + expect(mockedInvoke).toHaveBeenCalledWith("request_app_exit"); }); it("shows the current Tauri window", async () => { diff --git a/apps/desktop/src/runtime/tauri/window.ts b/apps/desktop/src/runtime/tauri/window.ts index 942d1375..131bdfed 100644 --- a/apps/desktop/src/runtime/tauri/window.ts +++ b/apps/desktop/src/runtime/tauri/window.ts @@ -52,6 +52,10 @@ export function openNativeBlankEditorWindow() { return invokeNative("open_blank_editor_window"); } +export function requestNativeAppExit() { + return invokeNative("request_app_exit"); +} + export async function listenNativeSettingsWindowTarget(onTarget: (target: NativeSettingsWindowTarget) => unknown) { if (!("__TAURI_INTERNALS__" in window)) { return () => {}; diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 3a80adb9..a838fbb0 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -2793,6 +2793,23 @@ describe("Markra workspace", () => { it("shows a close button in the web settings window", async () => { mockedConsumeWelcomeDocumentState.mockResolvedValue(false); mockedResolveDesktopPlatform.mockReturnValue("linux"); + configureAppRuntime({ + ...createDefaultAppRuntime(), + features: { + ai: false, + export: true, + nativeWindowChrome: false, + networkProxy: false, + pandoc: false, + s3ImageUpload: false, + spellcheck: false, + updater: false + }, + platform: { + resolveDesktopOsVersion: () => null, + resolveDesktopPlatform: () => "linux" + } + }); window.history.pushState({}, "", "/?settings=1"); const { container } = renderApp(); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 30e1eb74..9b153b2f 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -161,6 +161,7 @@ import { openBlankEditorWindow, openNativeExternalUrl, openSettingsWindow, + requestNativeAppExit, prewarmSettingsWindow, showNativeAppAbout, toggleNativeWindowFullscreen, @@ -470,8 +471,8 @@ function WorkspaceApp() { const aiFeatureEnabled = appFeatures.ai; const exportFeatureEnabled = appFeatures.export; const markdownBundleFeatureEnabled = exportFeatureEnabled && appFeatures.markdownBundle === true; - const nativeWindowChromeEnabled = appFeatures.nativeWindowChrome && desktopPlatform !== "linux"; - const windowsSelfDrawnChromeEnabled = nativeWindowChromeEnabled && desktopPlatform === "windows"; + const nativeWindowChromeEnabled = appFeatures.nativeWindowChrome; + const windowsSelfDrawnChromeEnabled = nativeWindowChromeEnabled && (desktopPlatform === "windows" || desktopPlatform === "linux"); const pandocFeatureEnabled = appFeatures.pandoc; const s3ImageUploadFeatureEnabled = appFeatures.s3ImageUpload; const spellcheckFeatureEnabled = appFeatures.spellcheck; @@ -2980,7 +2981,7 @@ function WorkspaceApp() { showNativeAppAbout().catch(() => {}); }, []); const handleExitApp = useCallback(() => { - closeNativeWindow().catch(() => {}); + requestNativeAppExit().catch(() => {}); }, []); const rawFileTreeRootName = rootNameForDocument(document.path); const fileTreeRootName = @@ -4817,7 +4818,8 @@ function WorkspaceApp() { onResizeStart: compactViewport ? undefined : startFileTreeResize, onSaveFileAsTemplate: handleSaveMarkdownFileAsTemplate, onSelectOutlineItem: editor.selectOutlineItem, - onToggleMarkdownFiles: handleFileTreeToggle + onToggleMarkdownFiles: handleFileTreeToggle, + platform: windowsSelfDrawnChromeEnabled ? "windows" : desktopPlatform }} windowsSelfDrawnChrome={windowsSelfDrawnChromeEnabled} workspaceOperationOverlay={workspaceOperationOverlay} diff --git a/packages/app/src/components/NativeTitleBar.test.tsx b/packages/app/src/components/NativeTitleBar.test.tsx index 9b0da3ac..58b94f76 100644 --- a/packages/app/src/components/NativeTitleBar.test.tsx +++ b/packages/app/src/components/NativeTitleBar.test.tsx @@ -501,6 +501,31 @@ describe("NativeTitleBar", () => { expect(toggleMarkdownFiles).toHaveBeenCalledTimes(1); }); + it("renders self-drawn window controls on Linux", () => { + const { container } = render( + {}} + onOpenMarkdown={() => {}} + onSaveMarkdown={() => {}} + onToggleMarkdownFiles={() => {}} + onToggleTheme={() => {}} + /> + ); + + expect(container.querySelector(".windows-app-chrome")).toBeInTheDocument(); + expect(container.querySelector(".native-titlebar")).toHaveClass("top-10"); + expect(screen.getByRole("button", { name: "Minimize window" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Maximize or restore window" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close window" })).toBeInTheDocument(); + }); + it("places the Windows folder context in the center of the top chrome instead of the centered titlebar", () => { const { container } = render( titlebarActions?.length === 0 ? [] : normalizeTitlebarActions(titlebarActions), @@ -691,7 +691,7 @@ export function NativeTitleBar({ ); }; - if (platform === "windows") { + if (platform === "windows" || (platform === "linux" && nativeWindowChrome)) { return ( {activeSettingsCategory === "general" ? ( Promise; }; -function scheduleAfterNextPaint(callback: () => unknown) { +export function scheduleAfterNextPaint(callback: () => unknown) { if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") { const timeout = setTimeout(callback, 0); return () => clearTimeout(timeout); diff --git a/packages/app/src/lib/tauri/window.ts b/packages/app/src/lib/tauri/window.ts index e3d90f4e..cd96a2a4 100644 --- a/packages/app/src/lib/tauri/window.ts +++ b/packages/app/src/lib/tauri/window.ts @@ -41,6 +41,10 @@ export function openBlankEditorWindow() { return getAppRuntime().window.openBlankEditorWindow(); } +export function requestNativeAppExit() { + return getAppRuntime().window.requestAppExit(); +} + export function listenNativeSettingsWindowTarget(onTarget: (target: NativeSettingsWindowTarget) => unknown) { return getAppRuntime().window.listenSettingsWindowTarget(onTarget); } diff --git a/packages/app/src/runtime/index.ts b/packages/app/src/runtime/index.ts index 873611a3..e6a98e0b 100644 --- a/packages/app/src/runtime/index.ts +++ b/packages/app/src/runtime/index.ts @@ -397,6 +397,7 @@ export type AppWindowRuntime = { openExternalUrl: (url: string) => Promise; openSettingsWindow: (target?: NativeSettingsWindowTarget) => Promise; prewarmSettingsWindow: () => Promise; + requestAppExit: () => Promise; markSettingsWindowReady: () => Promise; hideSettingsWindow: () => Promise; setEditorWindowRestoreState: (input: SetNativeEditorWindowRestoreStateInput) => Promise; @@ -626,6 +627,7 @@ export function createDefaultAppRuntime(): AppRuntime { }, openSettingsWindow: async () => undefined, prewarmSettingsWindow: async () => undefined, + requestAppExit: async () => undefined, markSettingsWindowReady: async () => undefined, hideSettingsWindow: async () => undefined, setEditorWindowRestoreState: async () => undefined,