From c08a4ea22b7d2a3b79f58eed326647947afde86e Mon Sep 17 00:00:00 2001 From: Jesse <35648348+Jessomadic@users.noreply.github.com> Date: Thu, 21 May 2026 19:51:29 -0400 Subject: [PATCH 1/4] fix windows oauth deep link forwarding --- app/src-tauri/Cargo.toml | 3 + app/src-tauri/src/deep_link_ipc_windows.rs | 373 +++++++++++++++++++++ app/src-tauri/src/lib.rs | 29 +- 3 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 app/src-tauri/src/deep_link_ipc_windows.rs diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 1fb7c4768..1225ad27c 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 000000000..0f6676dee --- /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 3b62ae265..73017cc81 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")] { From 726f6c12c8b2e69930f149771f41860f85761bbc Mon Sep 17 00:00:00 2001 From: Jesse <35648348+Jessomadic@users.noreply.github.com> Date: Thu, 21 May 2026 20:20:37 -0400 Subject: [PATCH 2/4] fix german i18n coverage --- app/src/lib/i18n/chunks/de-3.ts | 2 ++ app/src/lib/i18n/chunks/de-5.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 8cbb4e8ae..996a81855 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -104,6 +104,8 @@ const de3: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert', + 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c698c292f..38e0f6e52 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -208,9 +208,32 @@ const de5: TranslationMap = { 'settings.developerMenu.composioRouting.title': 'Composio Routing (Direktmodus)', 'settings.developerMenu.composioRouting.desc': 'Bring deinen eigenen Composio API-Schlüssel mit und leite Anrufe direkt an backend.composio.dev weiter', + 'settings.developerMenu.mcpServer.title': 'MCP Server', + 'settings.developerMenu.mcpServer.desc': + 'Konfiguriere externe MCP-Clients für die Verbindung mit OpenHuman', 'settings.developerMenu.integrationTriggers.title': 'Integrationsauslöser', 'settings.developerMenu.integrationTriggers.desc': 'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser', + 'settings.mcpServer.title': 'MCP Server', + 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Werkzeuge', + 'settings.mcpServer.toolsSectionDesc': + 'Werkzeuge, die über den MCP-stdio-Server verfügbar sind, wenn openhuman-core mcp ausgeführt wird', + 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', + 'settings.mcpServer.configSectionDesc': + 'Wähle deinen MCP-Client aus, um den passenden Konfigurationsausschnitt zu erstellen', + 'settings.mcpServer.copySnippet': 'In die Zwischenablage kopieren', + 'settings.mcpServer.copied': 'Kopiert!', + 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', + 'settings.mcpServer.binaryPathNotFound': + 'OpenHuman-Binärdatei nicht gefunden. Wenn du aus dem Quellcode arbeitest, baue 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', 'settings.appearance.menuDesc': 'Wähle hell, dunkel oder passend zu deinem Systemthema', 'settings.mascot.active': 'Aktiv', 'settings.mascot.characterDesc': 'Charakterbeschreibung', From 0defb51555dda7dd17718fb4324ff9ebc06938ae Mon Sep 17 00:00:00 2001 From: Jesse <35648348+Jessomadic@users.noreply.github.com> Date: Thu, 21 May 2026 20:24:09 -0400 Subject: [PATCH 3/4] chore format german i18n --- app/src/lib/i18n/chunks/de-5.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 38e0f6e52..c9a3abf88 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -226,8 +226,7 @@ const de5: TranslationMap = { 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', 'settings.mcpServer.binaryPathNotFound': 'OpenHuman-Binärdatei nicht gefunden. Wenn du aus dem Quellcode arbeitest, baue sie mit: cargo build --bin openhuman-core', - 'settings.mcpServer.openConfigError': - 'Konfigurationsdatei konnte nicht geöffnet werden', + 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', 'settings.mcpServer.clientCursor': 'Cursor', 'settings.mcpServer.clientCodex': 'Codex', From 3f49b679d9b281698350bef4c2fa369bdafad34c Mon Sep 17 00:00:00 2001 From: Jesse <35648348+Jessomadic@users.noreply.github.com> Date: Thu, 21 May 2026 20:48:38 -0400 Subject: [PATCH 4/4] fix windows installer dependency install --- .github/workflows/build-windows.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 3e93fab28..494341cee 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -66,8 +66,10 @@ jobs: if: steps.tauri-cli-cache.outputs.cache-hit != 'true' shell: bash run: cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli + - name: Enable Corepack + run: corepack enable - name: Install dependencies - run: yarn install --frozen-lockfile + run: pnpm install --frozen-lockfile # vite build runs via tauri.conf.json's beforeBuildCommand during the # "Build Tauri app" step below — no separate frontend build needed.