From 376c9596225e3a857adb0f3c974f3462193a3100 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 21 May 2026 22:13:20 +0530 Subject: [PATCH 1/3] fix(tauri): forward deep-link URLs on Linux before CEF preflight exits secondary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, OAuth callbacks launch a second binary with the URL in argv. That secondary hits cef_preflight::check_default_cache() and exits(1) before Builder::setup() runs, silently dropping the URL. Adds deep_link_ipc.rs (Linux-only): the primary binds a Unix domain socket at $XDG_RUNTIME_DIR/com.openhuman.app-deeplink.sock before the CEF preflight check. A secondary instance that finds openhuman:// URLs in argv connects, writes them, and exits(0) — never reaching the preflight. On setup(), drain_pending_urls emits deep-link://new-url events to the app handle for each queued URL. This mirrors the Windows pre-CEF named-mutex guard (lib.rs:2092-2128) for the Linux case. Closes #2359 --- app/src-tauri/src/deep_link_ipc.rs | 350 +++++++++++++++++++++++++++++ app/src-tauri/src/lib.rs | 24 ++ 2 files changed, 374 insertions(+) create mode 100644 app/src-tauri/src/deep_link_ipc.rs diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs new file mode 100644 index 0000000000..9d39f6cc51 --- /dev/null +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -0,0 +1,350 @@ +//! Pre-CEF deep-link forwarding for Linux (issue #2359). +//! +//! On Linux, `openhuman://` OAuth callbacks launch a second OpenHuman +//! binary with the URL in argv. That secondary hits +//! `cef_preflight::check_default_cache()` and exits before Builder::setup +//! runs, so tauri-plugin-deep-link never gets a chance to forward the URL. +//! +//! This module fixes the race by: +//! 1. Primary: bind a Unix domain socket at a stable per-user path BEFORE +//! the CEF preflight check. Queue any arriving URLs until setup() runs. +//! 2. Secondary (URL in argv): connect to the socket, write the URL(s), +//! and exit(0). CEF preflight is never reached. + +#![cfg(target_os = "linux")] + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::PathBuf, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +/// Stable socket path. Uses $XDG_RUNTIME_DIR when available (per-user, +/// per-session tmpfs, cleaned on reboot), falls back to /tmp with UID. +pub(crate) fn socket_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(dir).join("com.openhuman.app-deeplink.sock"); + } + // Fallback: include UID so multi-user machines don't collide. + let uid = nix::unistd::getuid().as_raw(); + std::env::temp_dir().join(format!("com_openhuman_app_deeplink_{uid}.sock")) +} + +/// Collect any `openhuman://` URLs from the process argv. +pub(crate) fn extract_deep_link_urls() -> Vec { + std::env::args() + .skip(1) + .filter(|a| a.starts_with("openhuman://")) + .collect() +} + +/// Result of `try_forward_deep_links`. +pub(crate) enum ForwardResult { + /// URLs were written to the primary's socket; caller should exit(0). + Forwarded, + /// Deep-link URL found in argv but no primary socket is listening. + NoPrimary, + /// No deep-link URLs in argv; this is a normal launch. + NoUrls, +} + +/// Try to forward any `openhuman://` URLs in argv to the primary instance. +/// Call this BEFORE the CEF preflight check. +pub(crate) fn try_forward_deep_links() -> ForwardResult { + let urls = extract_deep_link_urls(); + if urls.is_empty() { + return ForwardResult::NoUrls; + } + + let path = socket_path(); + log::info!( + "[deep-link-ipc] secondary: found {} deep-link URL(s), trying socket at {}", + urls.len(), + path.display() + ); + + match UnixStream::connect(&path) { + Ok(mut stream) => { + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + for url in &urls { + if let Err(e) = writeln!(stream, "{url}") { + log::warn!("[deep-link-ipc] secondary: failed to write URL: {e}"); + } + } + log::info!( + "[deep-link-ipc] secondary: {} URL(s) forwarded to primary", + urls.len() + ); + ForwardResult::Forwarded + } + Err(e) => { + log::info!( + "[deep-link-ipc] secondary: no primary socket at {} ({e}); \ + will become primary", + path.display() + ); + ForwardResult::NoPrimary + } + } +} + +// Pending URLs collected before setup() has an app handle. +static PENDING_URLS: OnceLock>>> = OnceLock::new(); +// Live handler installed by drain_pending_urls — dispatches directly to app. +static LIVE_HANDLER: OnceLock>>> = OnceLock::new(); + +fn pending_queue() -> &'static Arc>> { + PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) +} + +fn live_handler() -> &'static Mutex>> { + LIVE_HANDLER.get_or_init(|| Mutex::new(None)) +} + +fn dispatch_url(url: String) { + // Try the live handler first. + if let Ok(guard) = live_handler().lock() { + if let Some(ref handler) = *guard { + handler(url); + return; + } + } + // No live handler yet — queue for drain_pending_urls. + if let Ok(mut q) = pending_queue().lock() { + log::debug!("[deep-link-ipc] queued URL (no handler yet): {url}"); + q.push(url); + } +} + +/// RAII guard: removes the socket file when dropped. +pub(crate) struct DeepLinkSocketGuard { + path: PathBuf, +} + +impl Drop for DeepLinkSocketGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + log::debug!( + "[deep-link-ipc] socket cleaned up at {}", + self.path.display() + ); + } +} + +/// Bind the deep-link socket and start the listener thread. +/// Returns `None` if binding fails (non-fatal — log and continue). +pub(crate) fn bind_and_listen() -> Option { + let path = socket_path(); + + // Remove stale socket from a previous crash. + let _ = std::fs::remove_file(&path); + + match UnixListener::bind(&path) { + Ok(listener) => { + let path_clone = path.clone(); + std::thread::Builder::new() + .name("deep-link-ipc-listener".into()) + .spawn(move || { + log::info!( + "[deep-link-ipc] primary: listening on {}", + path_clone.display() + ); + for stream in listener.incoming() { + match stream { + Ok(stream) => handle_connection(stream), + Err(e) => { + log::debug!("[deep-link-ipc] accept error: {e}"); + // Listener is gone (guard dropped) — stop. + break; + } + } + } + log::info!("[deep-link-ipc] listener thread exiting"); + }) + .ok(); + Some(DeepLinkSocketGuard { path }) + } + Err(e) => { + log::warn!( + "[deep-link-ipc] failed to bind socket at {} — deep-link forwarding \ + from secondary instances will not work: {e}", + path.display() + ); + None + } + } +} + +fn handle_connection(stream: UnixStream) { + stream.set_read_timeout(Some(Duration::from_secs(3))).ok(); + let reader = BufReader::new(stream); + for line in reader.lines() { + match line { + Ok(url) if url.starts_with("openhuman://") => { + log::info!("[deep-link-ipc] primary: received deep-link URL: {url}"); + dispatch_url(url); + } + Ok(other) => { + log::debug!("[deep-link-ipc] primary: ignoring non-deep-link line: {other}"); + } + Err(e) => { + log::debug!("[deep-link-ipc] primary: read error: {e}"); + break; + } + } + } +} + +/// Drain any URLs queued before setup() ran, then install a live handler +/// that emits `deep-link://new-url` events directly to the app handle. +/// Call this from Builder::setup() after deep-link registration. +pub(crate) fn drain_pending_urls(app: &tauri::AppHandle) { + use tauri::Emitter; + + // Install the live handler first so future URLs don't queue. + let app_clone = app.clone(); + if let Ok(mut guard) = live_handler().lock() { + *guard = Some(Box::new(move |url: String| { + if let Ok(parsed) = url.parse::() { + let urls = vec![parsed]; + if let Err(e) = app_clone.emit("deep-link://new-url", &urls) { + log::warn!("[deep-link-ipc] failed to emit deep-link event: {e}"); + } + } else { + log::warn!("[deep-link-ipc] received URL that failed to parse: {url}"); + } + })); + } + + // Drain any URLs that arrived before setup(). + let pending: Vec = pending_queue() + .lock() + .map(|mut q| std::mem::take(&mut *q)) + .unwrap_or_default(); + + if !pending.is_empty() { + log::info!( + "[deep-link-ipc] draining {} queued deep-link URL(s)", + pending.len() + ); + } + for url in pending { + if let Ok(parsed) = url.parse::() { + let urls = vec![parsed]; + if let Err(e) = app.emit("deep-link://new-url", &urls) { + log::warn!("[deep-link-ipc] failed to emit queued deep-link URL: {e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn socket_path_uses_xdg_runtime_dir() { + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + let path = socket_path(); + assert_eq!( + path, + PathBuf::from("/run/user/1234/com.openhuman.app-deeplink.sock") + ); + } + + #[test] + fn socket_path_fallback_has_uid() { + std::env::remove_var("XDG_RUNTIME_DIR"); + let path = socket_path(); + let name = path.file_name().unwrap().to_string_lossy(); + assert!( + name.contains("com_openhuman_app_deeplink"), + "path {path:?} should contain identifier" + ); + // Should NOT be inside /run/user since XDG_RUNTIME_DIR is unset. + assert!( + !path.starts_with("/run/user"), + "path should use temp_dir fallback" + ); + } + + #[test] + fn extract_deep_link_urls_filters_correctly() { + // We can't mutate std::env::args(), so test the filtering logic directly. + let args = vec![ + "OpenHuman".to_string(), + "openhuman://auth?token=abc".to_string(), + "--some-flag".to_string(), + "openhuman://other".to_string(), + "https://example.com".to_string(), + ]; + let urls: Vec = args + .into_iter() + .skip(1) + .filter(|a| a.starts_with("openhuman://")) + .collect(); + assert_eq!(urls.len(), 2); + assert_eq!(urls[0], "openhuman://auth?token=abc"); + assert_eq!(urls[1], "openhuman://other"); + } + + #[test] + fn round_trip_bind_connect_forward() { + use std::io::BufRead; + use std::os::unix::net::UnixStream; + + // Use a temp path for this test to avoid collisions. + let tmp = tempfile::TempDir::new().unwrap(); + let sock_path = tmp.path().join("test-deeplink.sock"); + + let listener = UnixListener::bind(&sock_path).unwrap(); + let received = Arc::new(Mutex::new(Vec::::new())); + let received_clone = Arc::clone(&received); + + std::thread::spawn(move || { + if let Ok(stream) = listener.accept().map(|(s, _)| s) { + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let reader = BufReader::new(stream); + for line in reader.lines().flatten() { + if line.starts_with("openhuman://") { + received_clone.lock().unwrap().push(line); + } + } + } + }); + + // Give listener thread time to start. + std::thread::sleep(Duration::from_millis(50)); + + let mut stream = UnixStream::connect(&sock_path).unwrap(); + writeln!(stream, "openhuman://auth?token=testtoken123").unwrap(); + drop(stream); + + std::thread::sleep(Duration::from_millis(100)); + let got = received.lock().unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0], "openhuman://auth?token=testtoken123"); + } + + #[test] + fn no_primary_returns_appropriate_result() { + // Remove socket file to guarantee no primary. + std::env::remove_var("XDG_RUNTIME_DIR"); + let _ = std::fs::remove_file(socket_path()); + + // The "extract_deep_link_urls" function reads actual argv which has + // no openhuman:// URLs during tests, so try_forward_deep_links() + // returns NoUrls. We test the NoPrimary branch directly by + // testing that connect to a missing socket fails. + let non_existent = PathBuf::from("/tmp/openhuman_test_nonexistent_socket.sock"); + let _ = std::fs::remove_file(&non_existent); + let result = UnixStream::connect(&non_existent); + assert!( + result.is_err(), + "Expected connection failure for missing socket" + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3f20c1386c..fc0a8dc117 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ mod cef_profile; mod companion_commands; mod core_process; mod core_rpc; +#[cfg(target_os = "linux")] +mod deep_link_ipc; mod dictation_hotkeys; mod discord_scanner; mod fake_camera; @@ -2147,6 +2149,23 @@ pub fn run() { #[cfg(target_os = "macos")] process_recovery::reap_stale_openhuman_processes(); + // ── Linux pre-CEF deep-link forwarding guard (issue #2359) ──────────── + // On Linux, a secondary instance with an openhuman:// URL in argv exits + // at the CEF preflight check before Builder::setup() runs, silently + // dropping the OAuth callback. Detect and forward the URL here, before + // CEF preflight can exit(1). + #[cfg(target_os = "linux")] + let _deep_link_socket_guard = { + use deep_link_ipc::ForwardResult; + match deep_link_ipc::try_forward_deep_links() { + ForwardResult::Forwarded => { + std::process::exit(0); + } + ForwardResult::NoPrimary | ForwardResult::NoUrls => {} + } + deep_link_ipc::bind_and_listen() + }; + // CEF cache-lock preflight: if another OpenHuman instance holds the CEF // user-data-dir SingletonLock, `cef_initialize` returns 0 and the vendored // runtime panics (`left: 0, right: 1`). Catch the collision here and exit @@ -2444,6 +2463,11 @@ pub fn run() { "[deep-link] skipping register_all — xdg-mime not on PATH (xdg-utils not installed; deep-link MIME registration unavailable on this host)" ); } + + // Drain any deep-link URLs that arrived via the IPC socket + // before setup() ran (issue #2359). Also installs the live + // handler so URLs arriving after setup() are emitted directly. + deep_link_ipc::drain_pending_urls(app.app_handle()); } // Start the webview_apis WebSocket bridge BEFORE spawning core — From f4bebd6766ff511f38276750163dc113d9c0d0c8 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 21 May 2026 23:04:26 +0530 Subject: [PATCH 2/3] fix(deep-link): redact URL logs and use bind-first socket approach Address CodeRabbit review on PR #2458: 1. Add `redact_url_for_log()` helper that strips query string and fragment before logging deep-link URLs. OAuth callbacks carry tokens in the query string; logging the raw URL persists secrets in log files and crash reports. 2. Change `bind_and_listen()` from unconditional remove-then-bind to a bind-first approach: attempt bind, then on AddrInUse probe the existing socket with `UnixStream::connect` to distinguish a live primary (leave it alone, return None) from a stale socket after a crash (safe to remove and retry bind). Prevents a concurrent secondary from deleting a live primary's socket file. --- app/src-tauri/src/deep_link_ipc.rs | 113 +++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs index 9d39f6cc51..f7c6e87562 100644 --- a/app/src-tauri/src/deep_link_ipc.rs +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -103,6 +103,19 @@ fn live_handler() -> &'static Mutex>> { LIVE_HANDLER.get_or_init(|| Mutex::new(None)) } +/// Strip query string and fragment from a deep-link URL before logging. +/// OAuth callbacks carry tokens in the query string; logging the raw URL +/// would persist secrets in log files and crash reports. +fn redact_url_for_log(url: &str) -> String { + url.parse::() + .map(|mut parsed| { + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + fn dispatch_url(url: String) { // Try the live handler first. if let Ok(guard) = live_handler().lock() { @@ -113,7 +126,10 @@ fn dispatch_url(url: String) { } // No live handler yet — queue for drain_pending_urls. if let Ok(mut q) = pending_queue().lock() { - log::debug!("[deep-link-ipc] queued URL (no handler yet): {url}"); + log::debug!( + "[deep-link-ipc] queued URL (no handler yet): {}", + redact_url_for_log(&url) + ); q.push(url); } } @@ -135,36 +151,49 @@ impl Drop for DeepLinkSocketGuard { /// Bind the deep-link socket and start the listener thread. /// Returns `None` if binding fails (non-fatal — log and continue). +/// +/// Uses a bind-first approach to avoid the race where a secondary instance +/// unconditionally removes a live primary's socket file: we only remove the +/// file when we can confirm it is stale (connect fails). pub(crate) fn bind_and_listen() -> Option { let path = socket_path(); - // Remove stale socket from a previous crash. - let _ = std::fs::remove_file(&path); - - match UnixListener::bind(&path) { - Ok(listener) => { - let path_clone = path.clone(); - std::thread::Builder::new() - .name("deep-link-ipc-listener".into()) - .spawn(move || { - log::info!( - "[deep-link-ipc] primary: listening on {}", - path_clone.display() + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + // A socket file already exists. Probe whether a live primary + // is behind it before deciding to unlink. + match UnixStream::connect(&path) { + Ok(_) => { + // Live primary — this instance should not bind. + log::debug!( + "[deep-link-ipc] socket {} is live; skipping bind \ + (primary already running)", + path.display() ); - for stream in listener.incoming() { - match stream { - Ok(stream) => handle_connection(stream), - Err(e) => { - log::debug!("[deep-link-ipc] accept error: {e}"); - // Listener is gone (guard dropped) — stop. - break; - } + return None; + } + Err(_) => { + // Stale socket from a previous crash — safe to remove. + log::debug!( + "[deep-link-ipc] removing stale socket at {}", + path.display() + ); + let _ = std::fs::remove_file(&path); + match UnixListener::bind(&path) { + Ok(l) => l, + Err(e2) => { + log::warn!( + "[deep-link-ipc] failed to bind socket at {} after \ + removing stale file — deep-link forwarding from \ + secondary instances will not work: {e2}", + path.display() + ); + return None; } } - log::info!("[deep-link-ipc] listener thread exiting"); - }) - .ok(); - Some(DeepLinkSocketGuard { path }) + } + } } Err(e) => { log::warn!( @@ -172,9 +201,32 @@ pub(crate) fn bind_and_listen() -> Option { from secondary instances will not work: {e}", path.display() ); - None + return None; } - } + }; + + let path_clone = path.clone(); + std::thread::Builder::new() + .name("deep-link-ipc-listener".into()) + .spawn(move || { + log::info!( + "[deep-link-ipc] primary: listening on {}", + path_clone.display() + ); + for stream in listener.incoming() { + match stream { + Ok(stream) => handle_connection(stream), + Err(e) => { + log::debug!("[deep-link-ipc] accept error: {e}"); + // Listener is gone (guard dropped) — stop. + break; + } + } + } + log::info!("[deep-link-ipc] listener thread exiting"); + }) + .ok(); + Some(DeepLinkSocketGuard { path }) } fn handle_connection(stream: UnixStream) { @@ -183,7 +235,10 @@ fn handle_connection(stream: UnixStream) { for line in reader.lines() { match line { Ok(url) if url.starts_with("openhuman://") => { - log::info!("[deep-link-ipc] primary: received deep-link URL: {url}"); + log::info!( + "[deep-link-ipc] primary: received deep-link URL: {}", + redact_url_for_log(&url) + ); dispatch_url(url); } Ok(other) => { @@ -213,7 +268,7 @@ pub(crate) fn drain_pending_urls(app: &tauri::AppHandle) { log::warn!("[deep-link-ipc] failed to emit deep-link event: {e}"); } } else { - log::warn!("[deep-link-ipc] received URL that failed to parse: {url}"); + log::warn!("[deep-link-ipc] received malformed deep-link URL"); } })); } From df1d63a3679959b393ad18e540e45824b28bf856 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 22 May 2026 19:16:26 -0700 Subject: [PATCH 3/3] fix(i18n): remove duplicate German mcpServer keys blocking CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `settings.mcpServer.*` and `settings.developerMenu.mcpServer.*` keys at lines 526-547 in de-5.ts duplicate the earlier block at 211-235, causing `tsc --noEmit` to fail with TS1117 ("object literal cannot have multiple properties with the same name") and blocking the Type Check and E2E Appium jobs on every open PR. This is the same shape as #2495 ("remove duplicate German keys unblocking main's Type Check"); a fresh duplicate slipped in during a later i18n batch. Removes only the duplicate block; the earlier (more idiomatic German: "Verfügbare Werkzeuge" vs "Verfügbare Tools") definitions are retained. --- app/src/lib/i18n/chunks/de-5.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 79f041cc19..c8a26af5f3 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -523,28 +523,6 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', - 'settings.developerMenu.mcpServer.title': 'MCP-Server', - 'settings.developerMenu.mcpServer.desc': - 'Externe MCP-Clients zur Verbindung mit OpenHuman konfigurieren', - 'settings.mcpServer.title': 'MCP-Server', - 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools', - 'settings.mcpServer.toolsSectionDesc': - 'Tools, die über den MCP-Stdio-Server bereitgestellt werden, wenn openhuman-core mcp ausgeführt wird', - 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', - 'settings.mcpServer.configSectionDesc': - 'Wählen Sie Ihren MCP-Client aus, um den passenden Konfigurations-Schnipsel zu erzeugen', - 'settings.mcpServer.copySnippet': 'In Zwischenablage kopieren', - 'settings.mcpServer.copied': 'Kopiert!', - 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', - 'settings.mcpServer.binaryPathNotFound': - 'OpenHuman-Binary nicht gefunden. Wenn Sie aus dem Quellcode arbeiten, bauen Sie mit: cargo build --bin openhuman-core', - 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', - 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', - 'settings.mcpServer.clientCursor': 'Cursor', - 'settings.mcpServer.clientCodex': 'Codex', - 'settings.mcpServer.clientZed': 'Zed', - 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', - 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', }; export default de5;