Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 64 additions & 29 deletions apps/desktop/src-tauri/src/app_exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<R: Runtime>(app: &tauri::AppHandle<R>) -> Vec<AppExitWindowInfo> {
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::<Vec<_>>()
}

fn count_app_exit_user_windows<R: Runtime>(app: &tauri::AppHandle<R>) -> 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<String> {
windows
.iter()
.filter(|window| is_app_exit_user_window(window))
Expand All @@ -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<i32>, user_window_count: usize) -> bool {
code.is_none() && user_window_count > 0
}

pub(crate) fn handle_app_exit_requested<R: Runtime>(
app: &tauri::AppHandle<R>,
code: Option<i32>,
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::<Vec<_>>();
/// 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<R: Runtime>(app: &tauri::AppHandle<R>) {
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<R: Runtime>(
app: &tauri::AppHandle<R>,
code: Option<i32>,
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::*;
Expand All @@ -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
Expand All @@ -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"));
}
}
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 57 additions & 9 deletions apps/desktop/src-tauri/src/markdown_files/attachment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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(())
},
);
Expand All @@ -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}"),
};
Expand Down
42 changes: 36 additions & 6 deletions apps/desktop/src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,6 @@ fn application_about_metadata() -> AboutMetadata<'static> {
}
}

#[cfg(any(windows, test))]
fn native_about_full_version(metadata: &AboutMetadata<'_>) -> Option<String> {
match (&metadata.version, &metadata.short_version) {
(Some(version), Some(short_version)) => Some(format!("{version} ({short_version})")),
Expand All @@ -333,12 +332,10 @@ fn native_about_full_version(metadata: &AboutMetadata<'_>) -> Option<String> {
}
}

#[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;

Expand Down Expand Up @@ -411,10 +408,43 @@ fn show_native_app_about_for_window<R: tauri::Runtime>(
Ok(())
}

#[cfg(not(windows))]
#[cfg(target_os = "linux")]
fn show_native_app_about_for_window<R: tauri::Runtime>(
window: &tauri::Window<R>,
) -> 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<R: tauri::Runtime>(
_window: &tauri::Window<R>,
) -> 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(())
}

Expand Down Expand Up @@ -582,7 +612,7 @@ pub(crate) fn create_application_menu<R: tauri::Runtime>(
create_application_menu_for_language(app, language, None, &[])
}

#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
pub(crate) fn create_settings_window_menu<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> tauri::Result<Menu<R>> {
Expand Down Expand Up @@ -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(())
}
Expand Down
Loading
Loading