diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 1fb7c4768c..1225ad27cd 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -169,6 +169,9 @@ windows-sys = { version = "0.59", features = [ # parameter is gated behind it in windows-sys 0.59. "Win32_System_Threading", "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Pipes", ] } [features] diff --git a/app/src-tauri/src/deep_link_ipc_windows.rs b/app/src-tauri/src/deep_link_ipc_windows.rs new file mode 100644 index 0000000000..0f6676dee3 --- /dev/null +++ b/app/src-tauri/src/deep_link_ipc_windows.rs @@ -0,0 +1,373 @@ +//! Pre-CEF deep-link forwarding for Windows. +//! +//! `openhuman://` OAuth callbacks launch a second `OpenHuman.exe` with the +//! URL in argv. The Windows pre-CEF mutex guard exits secondaries before Tauri's +//! single-instance/deep-link plugins can run, so the URL must be forwarded here. + +#![cfg(target_os = "windows")] + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, + time::Duration, +}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, GetLastError, ERROR_BROKEN_PIPE, ERROR_PIPE_CONNECTED, HANDLE, + INVALID_HANDLE_VALUE, + }, + Storage::FileSystem::{ + CreateFileW, ReadFile, WriteFile, FILE_GENERIC_WRITE, OPEN_EXISTING, PIPE_ACCESS_INBOUND, + }, + System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, + PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, + }, +}; + +const PIPE_NAME: &str = r"\\.\pipe\com.openhuman.app-deeplink"; +const FORWARD_RETRY_ATTEMPTS: usize = 40; +const FORWARD_RETRY_DELAY: Duration = Duration::from_millis(50); + +pub(crate) enum ForwardResult { + Forwarded, + NoPrimary, + NoUrls, +} + +pub(crate) fn collect_deep_link_urls_from_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .skip(1) + .filter_map(|arg| { + let arg = arg.as_ref(); + arg.starts_with("openhuman://").then(|| arg.to_string()) + }) + .collect() +} + +pub(crate) fn extract_deep_link_urls() -> Vec { + collect_deep_link_urls_from_args(std::env::args()) +} + +pub(crate) fn try_forward_deep_links() -> ForwardResult { + let urls = extract_deep_link_urls(); + if urls.is_empty() { + return ForwardResult::NoUrls; + } + + log::info!( + "[deep-link-ipc] secondary: found {} deep-link URL(s), forwarding to primary", + urls.len() + ); + + for attempt in 1..=FORWARD_RETRY_ATTEMPTS { + match open_pipe_for_write() { + Some(handle) => { + let result = write_urls(handle, &urls); + unsafe { + CloseHandle(handle); + } + if result { + log::info!( + "[deep-link-ipc] secondary: forwarded {} deep-link URL(s)", + urls.len() + ); + return ForwardResult::Forwarded; + } + } + None if attempt < FORWARD_RETRY_ATTEMPTS => { + std::thread::sleep(FORWARD_RETRY_DELAY); + } + None => {} + } + } + + log::warn!( + "[deep-link-ipc] secondary: primary pipe was unavailable; deep-link URL was not forwarded" + ); + ForwardResult::NoPrimary +} + +static PENDING_URLS: OnceLock>>> = OnceLock::new(); +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)) +} + +pub(crate) 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) { + if let Ok(guard) = live_handler().lock() { + if let Some(ref handler) = *guard { + handler(url); + return; + } + } + + if let Ok(mut queue) = pending_queue().lock() { + log::debug!( + "[deep-link-ipc] queued URL before setup: {}", + redact_url_for_log(&url) + ); + queue.push(url); + } +} + +pub(crate) struct DeepLinkPipeGuard { + stop: Arc, +} + +impl Drop for DeepLinkPipeGuard { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(handle) = open_pipe_for_write() { + unsafe { + CloseHandle(handle); + } + } + } +} + +pub(crate) fn bind_and_listen() -> Option { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + + match std::thread::Builder::new() + .name("deep-link-ipc-windows".into()) + .spawn(move || listener_loop(thread_stop)) + { + Ok(_) => { + log::info!("[deep-link-ipc] primary: named pipe listener started"); + Some(DeepLinkPipeGuard { stop }) + } + Err(err) => { + log::warn!("[deep-link-ipc] failed to spawn listener thread: {err}"); + None + } + } +} + +fn listener_loop(stop: Arc) { + while !stop.load(Ordering::SeqCst) { + let pipe = match create_pipe_for_read() { + Some(pipe) => pipe, + None => { + std::thread::sleep(Duration::from_millis(250)); + continue; + } + }; + + let connected = unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) != 0 } + || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED; + + if connected { + for url in read_urls(pipe) { + log::info!( + "[deep-link-ipc] primary: received deep-link URL: {}", + redact_url_for_log(&url) + ); + dispatch_url(url); + } + } + + unsafe { + CloseHandle(pipe); + } + } +} + +fn pipe_name_wide() -> Vec { + PIPE_NAME.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn create_pipe_for_read() -> Option { + let name = pipe_name_wide(); + let handle = unsafe { + CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_INBOUND, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 4096, + 4096, + 0, + std::ptr::null(), + ) + }; + + if handle == INVALID_HANDLE_VALUE { + log::warn!( + "[deep-link-ipc] CreateNamedPipeW failed with os error {}", + unsafe { GetLastError() } + ); + None + } else { + Some(handle) + } +} + +fn open_pipe_for_write() -> Option { + let name = pipe_name_wide(); + let handle = unsafe { + CreateFileW( + name.as_ptr(), + FILE_GENERIC_WRITE, + 0, + std::ptr::null(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + + (handle != INVALID_HANDLE_VALUE).then_some(handle) +} + +fn write_urls(handle: HANDLE, urls: &[String]) -> bool { + let payload = urls.join("\n") + "\n"; + let bytes = payload.as_bytes(); + let mut written = 0u32; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr(), + bytes.len() as u32, + &mut written, + std::ptr::null_mut(), + ) + } != 0; + + ok && written == bytes.len() as u32 +} + +fn read_urls(handle: HANDLE) -> Vec { + let mut all = Vec::new(); + let mut buf = [0u8; 1024]; + + loop { + let mut read = 0u32; + let ok = unsafe { + ReadFile( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + } != 0; + + if !ok { + let err = unsafe { GetLastError() }; + if err != ERROR_BROKEN_PIPE { + log::debug!("[deep-link-ipc] ReadFile stopped with os error {err}"); + } + break; + } + + if read == 0 { + break; + } + + all.extend_from_slice(&buf[..read as usize]); + } + + String::from_utf8_lossy(&all) + .lines() + .filter(|line| line.starts_with("openhuman://")) + .map(ToOwned::to_owned) + .collect() +} + +pub(crate) fn drain_pending_urls(app: &tauri::AppHandle) { + use tauri::Emitter; + + 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::() { + if let Err(err) = app_clone.emit("deep-link://new-url", &vec![parsed]) { + log::warn!("[deep-link-ipc] failed to emit deep-link event: {err}"); + } + } else { + log::warn!("[deep-link-ipc] received malformed deep-link URL"); + } + })); + } + + let pending = pending_queue() + .lock() + .map(|mut queue| std::mem::take(&mut *queue)) + .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::() { + if let Err(err) = app.emit("deep-link://new-url", &vec![parsed]) { + log::warn!("[deep-link-ipc] failed to emit queued deep-link URL: {err}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_deep_link_urls_filters_args() { + let urls = collect_deep_link_urls_from_args([ + "OpenHuman.exe", + "openhuman://auth?token=secret&key=auth", + "--flag", + "https://example.test", + "openhuman://oauth/success?integrationId=abc", + ]); + + assert_eq!( + urls, + vec![ + "openhuman://auth?token=secret&key=auth", + "openhuman://oauth/success?integrationId=abc" + ] + ); + } + + #[test] + fn redact_url_removes_query_and_fragment() { + assert_eq!( + redact_url_for_log("openhuman://auth?token=secret&key=auth#frag"), + "openhuman://auth" + ); + } + + #[test] + fn pipe_name_is_stable_and_app_scoped() { + assert_eq!(PIPE_NAME, r"\\.\pipe\com.openhuman.app-deeplink"); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3b62ae2654..73017cc81d 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 = "windows")] +mod deep_link_ipc_windows; mod dictation_hotkeys; mod discord_scanner; mod fake_camera; @@ -2080,11 +2082,10 @@ pub fn run() { // // Fix: acquire a named Win32 mutex at the very top of `run()` — before // any CEF or builder work — so any secondary instance sees - // `ERROR_ALREADY_EXISTS` and exits immediately. The mutex name uses - // a `-cef-init` suffix distinct from the plugin's own `-sim` mutex so - // the two guards don't interfere; the plugin still handles WM_COPYDATA - // forwarding for graceful "focus primary" behaviour once the app is - // fully initialised. + // `ERROR_ALREADY_EXISTS` and exits immediately. If the secondary was + // launched for an `openhuman://` OAuth callback, forward that URL to the + // primary through our pre-CEF pipe before exiting; the Tauri deep-link + // plugin cannot run on this early secondary path. // // The RAII guard holds the mutex handle for the lifetime of `run()`. // Windows releases all process handles automatically on exit, so @@ -2103,9 +2104,17 @@ pub fn run() { if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { // Another instance is already past this point — exit before we - // touch CEF at all. The plugin's WM_COPYDATA path won't run - // here (it needs an AppHandle from setup()), but the primary - // is already showing its window so the user experience is fine. + // touch CEF at all. Forward deep links first so OAuth callbacks + // are not dropped by this early pre-plugin exit. + match deep_link_ipc_windows::try_forward_deep_links() { + deep_link_ipc_windows::ForwardResult::Forwarded + | deep_link_ipc_windows::ForwardResult::NoUrls => {} + deep_link_ipc_windows::ForwardResult::NoPrimary => { + log::warn!( + "[single-instance] secondary had deep-link argv but could not reach primary pipe" + ); + } + } if !handle.is_null() { unsafe { CloseHandle(handle) }; } @@ -2127,6 +2136,9 @@ pub fn run() { OwnedMutex(handle as isize) }; + #[cfg(windows)] + let _deep_link_pipe_guard = deep_link_ipc_windows::bind_and_listen(); + // CEF cache-lock preflight (macOS only): if another OpenHuman instance // is already holding the CEF user-data-dir, the vendored // `tauri-runtime-cef` panics inside `cef::initialize` with a Rust @@ -2416,6 +2428,7 @@ pub fn run() { if let Err(err) = app.deep_link().register_all() { log::warn!("[deep-link] register_all failed (non-fatal): {err}"); } + deep_link_ipc_windows::drain_pending_urls(app.app_handle()); } #[cfg(target_os = "linux")] {