diff --git a/packaging/flatpak/moe.sable.client.yml b/packaging/flatpak/moe.sable.client.yml index 48b198d42..4db2f5b37 100644 --- a/packaging/flatpak/moe.sable.client.yml +++ b/packaging/flatpak/moe.sable.client.yml @@ -18,6 +18,7 @@ finish-args: - --device=all - --talk-name=org.freedesktop.secrets - --talk-name=org.freedesktop.Notifications + - --own-name=moe.sable.client.SingleInstance - --talk-name=org.kde.StatusNotifierWatcher - --filesystem=xdg-download - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons diff --git a/src-tauri/src/deep_link_ipc.rs b/src-tauri/src/deep_link_ipc.rs index 82978229b..a041be9ee 100644 --- a/src-tauri/src/deep_link_ipc.rs +++ b/src-tauri/src/deep_link_ipc.rs @@ -1,9 +1,4 @@ -//! Deep-link forwarding for the Linux CEF build. -//! -//! CEF is one-process-per-cache, so a deep-link relaunch can't init Chromium to -//! forward itself. The primary binds a socket; the secondary forwards the URL -//! and exits before touching CEF. Delivery re-emits `deep-link://new-url`, the -//! event `@tauri-apps/plugin-deep-link`'s `onOpenUrl` already listens to. +//! Single-instance forwarding for Linux CEF. use std::{ io::{BufRead, BufReader, Write}, @@ -15,6 +10,7 @@ use std::{ // OIDC uses the private-use `moe.sable.app:/login?...` form; other flows `sable://`. const SCHEMES: &[&str] = &["moe.sable.app:", "sable:"]; +const ACTIVATE: &str = "activate"; fn is_deep_link(arg: &str) -> bool { SCHEMES.iter().any(|scheme| arg.starts_with(scheme)) @@ -44,42 +40,44 @@ where } pub enum ForwardResult { - /// URLs were written to the primary's socket; the caller must exit(0). + /// The launch was written to the primary's socket; the caller must exit(0). Forwarded, - /// A deep-link URL was in argv but no primary is listening → become primary. + /// No primary is listening, so the caller must become primary. NoPrimary, - /// No deep-link URLs in argv; a normal launch. - NoUrls, } -/// Forward any `sable://` URLs in argv to the primary instance. Call BEFORE -/// any CEF initialization. -pub fn try_forward_deep_links() -> ForwardResult { +/// Forward a launch to the primary before CEF initialization. +pub fn try_forward_to_primary() -> ForwardResult { let urls = collect_deep_link_urls_from_args(std::env::args()); - if urls.is_empty() { - return ForwardResult::NoUrls; - } - let path = socket_path(); match UnixStream::connect(&path) { Ok(mut stream) => { stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + let _ = writeln!(stream, "{ACTIVATE}"); for url in &urls { let _ = writeln!(stream, "{url}"); } - log::info!("[deep-link-ipc] forwarded {} URL(s) to primary", urls.len()); + log::info!( + "[deep-link-ipc] forwarded launch with {} URL(s) to primary", + urls.len() + ); ForwardResult::Forwarded } Err(_) => ForwardResult::NoPrimary, } } -static PENDING_URLS: OnceLock>>> = OnceLock::new(); -type LiveHandler = Box; +enum LaunchMessage { + Activate, + DeepLink(String), +} + +static PENDING_MESSAGES: OnceLock>>> = OnceLock::new(); +type LiveHandler = Box; static LIVE_HANDLER: OnceLock>> = OnceLock::new(); -fn pending_queue() -> &'static Arc>> { - PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) +fn pending_queue() -> &'static Arc>> { + PENDING_MESSAGES.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) } fn live_handler() -> &'static Mutex> { LIVE_HANDLER.get_or_init(|| Mutex::new(None)) @@ -93,15 +91,15 @@ fn redact_for_log(url: &str) -> String { .to_string() } -fn dispatch_url(url: String) { +fn dispatch(message: LaunchMessage) { if let Ok(guard) = live_handler().lock() { if let Some(handler) = guard.as_ref() { - handler(url); + handler(message); return; } } if let Ok(mut q) = pending_queue().lock() { - q.push(url); + q.push(message); } } @@ -152,9 +150,10 @@ fn handle_connection(stream: UnixStream) { stream.set_read_timeout(Some(Duration::from_secs(3))).ok(); for line in BufReader::new(stream).lines() { match line { + Ok(line) if line == ACTIVATE => dispatch(LaunchMessage::Activate), Ok(url) if is_deep_link(&url) => { log::info!("[deep-link-ipc] received {}", redact_for_log(&url)); - dispatch_url(url); + dispatch(LaunchMessage::DeepLink(url)); } Ok(_) => {} Err(_) => break, @@ -167,25 +166,79 @@ fn handle_connection(stream: UnixStream) { // `://` can't form a valid identifier). const NEW_URL_EVENT: &str = "deep-link://new-url"; -/// Install a live handler that emits `NEW_URL_EVENT` and drain anything queued -/// before now. Call from `setup()`. -pub fn drain_pending_urls(app: &tauri::AppHandle) { - use tauri::Emitter; +pub fn drain_pending_launches(app: &tauri::AppHandle) { + use tauri::{Emitter, Manager}; let app_for_handler = app.clone(); if let Ok(mut guard) = live_handler().lock() { - *guard = Some(Box::new(move |url: String| { - if let Err(e) = app_for_handler.emit(NEW_URL_EVENT, vec![url]) { - log::warn!("[deep-link-ipc] emit failed: {e}"); + *guard = Some(Box::new(move |message| match message { + LaunchMessage::Activate => { + if let Some(window) = app_for_handler.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + LaunchMessage::DeepLink(url) => { + if let Err(e) = app_for_handler.emit(NEW_URL_EVENT, vec![url]) { + log::warn!("[deep-link-ipc] emit failed: {e}"); + } } })); } - let pending: Vec = pending_queue() + let pending: Vec = pending_queue() .lock() .map(|mut q| std::mem::take(&mut *q)) .unwrap_or_default(); - for url in pending { - let _ = app.emit(NEW_URL_EVENT, vec![url]); + for message in pending { + match message { + LaunchMessage::Activate => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + LaunchMessage::DeepLink(url) => { + let _ = app.emit(NEW_URL_EVENT, vec![url]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + collect_deep_link_urls_from_args, handle_connection, live_handler, LaunchMessage, ACTIVATE, + }; + use std::{ + io::Write, + os::unix::net::UnixStream, + sync::{Arc, Mutex}, + }; + + #[test] + fn regular_launch_activates_the_primary() { + let activated = Arc::new(Mutex::new(false)); + let received = activated.clone(); + *live_handler().lock().unwrap() = Some(Box::new(move |message| { + if matches!(message, LaunchMessage::Activate) { + *received.lock().unwrap() = true; + } + })); + + let (reader, mut writer) = UnixStream::pair().unwrap(); + writeln!(writer, "{ACTIVATE}").unwrap(); + drop(writer); + handle_connection(reader); + + assert!(*activated.lock().unwrap()); + } + + #[test] + fn collects_only_supported_deep_links() { + assert_eq!( + collect_deep_link_urls_from_args(["sable", "--flag", "sable://room", "https://x"]), + ["sable://room"] + ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc7f1eb0c..94cca099b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -417,11 +417,11 @@ pub fn run() { app.deep_link().register_all()?; } - #[cfg(all(feature = "cef", target_os = "linux"))] - deep_link_ipc::drain_pending_urls(app.handle()); - show_or_create_main_window(app.handle())?; + #[cfg(all(feature = "cef", target_os = "linux"))] + deep_link_ipc::drain_pending_launches(app.handle()); + // Failsafe: if the frontend never calls show() (hung webview), force-show // the window after 5s so the app isn't invisible forever. #[cfg(desktop)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7cf438e13..21b75e645 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -42,6 +42,27 @@ fn is_cef_views() -> bool { } fn main() { + // CEF must not initialize in a second instance. + #[cfg(all(feature = "cef", target_os = "linux"))] + let _deep_link_guard = { + if let app_lib::deep_link_ipc::ForwardResult::Forwarded = + app_lib::deep_link_ipc::try_forward_to_primary() + { + return; + } + + let guard = app_lib::deep_link_ipc::bind_and_listen(); + if guard.is_none() + && matches!( + app_lib::deep_link_ipc::try_forward_to_primary(), + app_lib::deep_link_ipc::ForwardResult::Forwarded + ) + { + return; + } + guard + }; + // CEF (Chromium) runtime, Linux only. Must run before anything else — CEF // re-execs this binary for its subprocesses. #[cfg(all(feature = "cef", target_os = "linux"))] @@ -118,14 +139,6 @@ fn main() { return; } - // Deep-link relaunch: forward to the running primary and exit before - // CEF init (a second instance can't hold the CEF cache lock). - if let app_lib::deep_link_ipc::ForwardResult::Forwarded = - app_lib::deep_link_ipc::try_forward_deep_links() - { - return; - } - // Allow call media capture (mic, camera, screen-share) and geolocation for our webview. // Cache granted permissions so we only prompt once per kind. use std::collections::HashSet; @@ -309,9 +322,5 @@ fn main() { } } - // Deep-link primary: hold the forwarding socket for the process lifetime. - #[cfg(all(feature = "cef", target_os = "linux"))] - let _deep_link_guard = app_lib::deep_link_ipc::bind_and_listen(); - app_lib::run(); }