From 8560b3017347833f29e655c895eca8c0ad5fc905 Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Sat, 22 Aug 2026 01:28:50 +0800 Subject: [PATCH 1/3] feat(desktop): self-drawn titlebar on Linux Use the Windows-style self-drawn titlebar on Linux instead of the native GTK menubar/decorations, and fix the regressions that the switch exposes. - Disable OS decorations for Linux editor/settings windows; the frontend reuses the WindowsNativeTitleBar chrome when nativeWindowChrome is on. - Always hide the native menubar on Linux editor + settings windows. After install_application_menu re-attaches the app-wide menu (re-showing GTK menubars), re-hide every window: app.hide_menu() for app-wide-menu windows and window.hide_menu() for the settings window (window-level menu). - Re-assert the hidden menubar on window focus, since GTK can re-show it. - Fix the empty show_native_app_about stub on non-Windows: the self-drawn About entry now shows a real native dialog (GTK on Linux, AppKit on macOS) via tauri-plugin-dialog, reusing the same metadata as the Windows path. - Add Linux test coverage for the self-drawn controls, menu-hide policy, and decorations config. --- .gitignore | 1 + apps/desktop/src-tauri/src/app_exit.rs | 93 +++++++++---- apps/desktop/src-tauri/src/lib.rs | 3 +- apps/desktop/src-tauri/src/menu.rs | 42 +++++- apps/desktop/src-tauri/src/windows.rs | 124 +++++++++++++++--- apps/desktop/src-tauri/tauri.linux.conf.json | 4 +- apps/desktop/src/runtime/index.ts | 1 + apps/desktop/src/runtime/tauri/window.test.ts | 21 ++- apps/desktop/src/runtime/tauri/window.ts | 4 + packages/app/src/App.test.tsx | 17 +++ packages/app/src/App.tsx | 10 +- .../src/components/NativeTitleBar.test.tsx | 25 ++++ .../app/src/components/NativeTitleBar.tsx | 4 +- .../app/src/components/SettingsWindow.tsx | 12 +- packages/app/src/hooks/useNativeBindings.ts | 2 +- .../app/src/hooks/useStartupWindowReveal.ts | 2 +- packages/app/src/lib/tauri/window.ts | 4 + packages/app/src/runtime/index.ts | 2 + 18 files changed, 296 insertions(+), 75 deletions(-) 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/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, From ab0f623eec4cb50d4c15dc069f7f0effd297dcbd Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Tue, 25 Aug 2026 22:25:53 +0800 Subject: [PATCH 2/3] ci(tauri-rust): verify Linux and macOS builds via matrix Run the Tauri Rust job on both ubuntu-latest and macos-latest so #[cfg(target_os = "linux")] code paths are actually compiled and tested on CI, instead of only macOS. --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 From 2454529b8ac2086f1d4f2cfae839b3ffe0700877 Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Tue, 25 Aug 2026 23:12:33 +0800 Subject: [PATCH 3/3] fix(attachment): tolerate FIFO swap race in nonblocking source-open test The rejects_a_fifo_replaced_before_source_open_without_blocking test swaps the source file for a FIFO via remove+mkfifo inside the import hook. The main thread must not race the hook's replacement, and its writer-open probe must never block on the FIFO. The hook now signals after mkfifo completes so the main thread starts its timeout window only once the replacement is in place. The watchdog probe opens the FIFO nonblocking (WRONLY|NONBLOCK), which returns ENXIO when no reader is present instead of hanging, and releases the writer immediately. The import result is the single source of truth for pass/fail; writer-open success no longer panics as a sentinel because a transient nonblocking reader may legitimately make it succeed. --- .../src/markdown_files/attachment.rs | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) 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}"), };