diff --git a/Cargo.lock b/Cargo.lock index 7feebbbd80..717ef0c102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5162,6 +5162,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", + "uiautomation", "unicode-normalization", "unicode-segmentation", "unicode-width", @@ -8237,6 +8238,29 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" +[[package]] +name = "uiautomation" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744" +dependencies = [ + "chrono", + "uiautomation_derive", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "uiautomation_derive" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "uint" version = "0.9.5" @@ -9136,6 +9160,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.54.0" @@ -9184,6 +9229,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -9256,6 +9312,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -9421,6 +9487,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index d27b6d8a12..e8cdc8b33d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -205,16 +205,23 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c # AppContainer / process-jail backend in `openhuman::cwd_jail`. # Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: # AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, -# and the GENERIC_* file access masks. +# and the GENERIC_* file access masks. `Win32_System_Com` is used by the +# UIA accessibility backend (`accessibility::uia_interact`) to initialise COM +# on the worker thread before creating the UI Automation client. windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", + "Win32_System_Com", "Win32_System_Memory", "Win32_System_Threading", ] } +# Microsoft UI Automation (UIA) bindings — the Windows backend for the +# `ax_interact` tool (`accessibility::uia_interact`). Safe Rust wrappers over +# the UIA COM API; the Windows analogue of the macOS AXUIElement Swift helper. +uiautomation = "0.25" [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 2ad79bb22c..d3255a2609 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5415,6 +5415,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", + "uiautomation", "unicode-normalization", "unicode-segmentation", "unicode-width", @@ -9232,6 +9233,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uiautomation" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68495a701b9f2f21f29353ac446f0d27dd0d7ce97aa9ccf9061bca0446cd744" +dependencies = [ + "chrono", + "uiautomation_derive", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "uiautomation_derive" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffcc4d404aa1c03a848f95cf5feadc3e63946d7f095bf388770b85550093d388" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "uint" version = "0.9.5" @@ -10033,11 +10057,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -10049,6 +10085,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.54.0" @@ -10118,7 +10163,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -10209,6 +10265,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -10410,6 +10476,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d7287ab48e..b3c0675432 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -34,6 +34,8 @@ mod meet_call; mod meet_scanner; mod meet_video; mod native_notifications; +#[cfg(target_os = "macos")] +mod notch_window; mod notification_settings; mod process_kill; mod process_recovery; @@ -915,6 +917,33 @@ fn mascot_native_window_is_open() -> bool { false } +/// Show the notch activity indicator. macOS only — transparent NSPanel + WKWebView +/// anchored to the top-centre of the primary screen. Displays live voice and +/// agent status (listening, thinking, executing) in a pill that emerges from +/// the physical notch on supported MacBook Pros. +#[tauri::command] +fn notch_window_show(app: AppHandle) -> Result<(), String> { + log::info!("[notch-window] show requested"); + #[cfg(target_os = "macos")] + { + return notch_window::show(&app); + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + Ok(()) // No-op on non-macOS + } +} + +/// Hide the notch activity indicator. +#[tauri::command] +fn notch_window_hide(_app: AppHandle) -> Result<(), String> { + log::info!("[notch-window] hide requested"); + #[cfg(target_os = "macos")] + notch_window::hide(); + Ok(()) +} + /// Hide or show the OS top-level main-window frame on Windows by enumerating /// this process's top-level windows and matching the visible /// `Chrome_WidgetWin_1` host. `WebviewWindow::hwnd()` from the vendored CEF @@ -2761,6 +2790,47 @@ pub fn run() { // let _ = window.show(); // } + // Notch activity indicator: transparent pill at the top-centre of + // the primary screen. Shows live voice / agent state. macOS only + // (physical notch or menu-bar HUD on older hardware). + #[cfg(target_os = "macos")] + if let Err(err) = notch_window::show(&app.handle()) { + log::warn!("[notch-window] auto-show on startup failed: {err}"); + } + + // Synthetic-input main-thread executor. enigo's macOS keyboard-layout + // lookup (TSMGetInputSourceProperty) MUST run on the app main thread + // or it traps (`_dispatch_assert_queue_fail`/EXC_BREAKPOINT) and + // crashes the CEF host (Change 1.15, confirmed via crash report). The + // keyboard/mouse tools run on tokio workers, so they dispatch their + // enigo ops here via the native registry; we run each on the real + // main thread through `run_on_main_thread`. + { + use openhuman_core::core::event_bus::register_native_global; + use openhuman_core::openhuman::tools::{ + MainThreadInputOp, INPUT_ON_MAIN_THREAD_METHOD, + }; + let input_app = app.handle().clone(); + register_native_global::, _, _>( + INPUT_ON_MAIN_THREAD_METHOD, + move |req| { + let input_app = input_app.clone(); + async move { + let (tx, rx) = tokio::sync::oneshot::channel(); + let run = req.run; + input_app + .run_on_main_thread(move || { + let _ = tx.send((run)()); + }) + .map_err(|e| format!("run_on_main_thread dispatch failed: {e}"))?; + rx.await + .map_err(|_| "main-thread input op was cancelled".to_string()) + } + }, + ); + log::info!("[computer] registered main-thread synthetic-input executor"); + } + // Tray icon setup moved to RunEvent::Ready (see below) — GTK is only // initialized after the event loop starts, so we must delay tray creation // until the Ready event fires. Creating the tray here would panic on @@ -3108,6 +3178,8 @@ pub fn run() { native_notifications::show_native_notification, mascot_window_show, mascot_window_hide, + notch_window_show, + notch_window_hide, file_logging::reveal_logs_folder, file_logging::logs_folder_path, workspace_paths::open_workspace_path, diff --git a/app/src-tauri/src/notch_window.rs b/app/src-tauri/src/notch_window.rs new file mode 100644 index 0000000000..25b2d9516e --- /dev/null +++ b/app/src-tauri/src/notch_window.rs @@ -0,0 +1,383 @@ +//! Native macOS NSPanel + WKWebView host for the notch activity indicator. +//! +//! A transparent, click-through floating panel anchored to the top-centre of +//! the primary screen. On MacBook Pros with a physical notch the pill visually +//! emerges from the notch; on older Macs it acts as a top-centre floating HUD. +//! +//! Architecture mirrors `mascot_native_window` — a native NSPanel avoids the +//! CEF transparency limitation (vendored tauri-cef cannot render transparent +//! windowed-mode browsers; only off-screen rendering supports transparency, +//! which the runtime does not enable). The WKWebView loads the same Vite entry +//! point at `?window=notch` so the React tree can branch in `main.tsx`. +//! +//! IPC strategy: no Tauri IPC bridge. The panel polls +//! `OPENHUMAN_CORE_RPC_URL` (set by `CoreProcessHandle` once the embedded +//! server is ready) and injects it via `evaluateJavaScript` so the React app +//! can open a Socket.IO connection to receive live voice and agent events. + +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; +use std::ptr::NonNull; +use std::rc::Rc; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::{msg_send, MainThreadMarker, MainThreadOnly}; +use objc2_app_kit::{ + NSBackingStoreType, NSColor, NSPanel, NSScreen, NSWindowCollectionBehavior, NSWindowStyleMask, +}; +use objc2_foundation::{NSNumber, NSPoint, NSRect, NSSize, NSString, NSTimer, NSURLRequest, NSURL}; +use objc2_web_kit::{WKWebView, WKWebViewConfiguration}; +use tauri::{AppHandle, Manager}; + +use crate::AppRuntime; + +/// Logical width of the notch panel. Wide enough to display voice/action text. +const PANEL_WIDTH: f64 = 380.0; +/// Logical height — covers the menu-bar / notch depth with headroom for the pill. +const PANEL_HEIGHT: f64 = 54.0; +/// URL-inject timer interval in seconds. +const INJECT_POLL_SECONDS: f64 = 1.0; +/// Ticks to wait before the first inject attempt (page-load delay). +const PAGE_LOAD_TICKS: u32 = 2; + +struct NotchPanel { + panel: Retained, + #[allow(dead_code)] + webview: Retained, + inject_timer: Retained, +} + +impl NotchPanel { + fn order_out(&self) { + self.inject_timer.invalidate(); + self.panel.orderOut(None); + } +} + +thread_local! { + /// Accessed only from the main thread. NSPanel/WKWebView are not Send/Sync + /// so a thread-local is the simplest safe storage. + static NOTCH: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn is_open() -> bool { + NOTCH.with(|cell| cell.borrow().is_some()) +} + +pub(crate) fn hide() { + NOTCH.with(|cell| { + if let Some(existing) = cell.borrow_mut().take() { + log::info!("[notch-window] dropping panel"); + existing.order_out(); + } + }); +} + +pub(crate) fn show(app: &AppHandle) -> Result<(), String> { + if NOTCH.with(|cell| cell.borrow().is_some()) { + log::debug!("[notch-window] already open"); + return Ok(()); + } + + let mtm = MainThreadMarker::new() + .ok_or_else(|| "notch_window::show called off the main thread".to_string())?; + + let source = resolve_page_source(app)?; + log::info!("[notch-window] loading source={source:?}"); + + let frame = top_center_frame(mtm); + log::debug!( + "[notch-window] frame origin=({:.0},{:.0}) size=({:.0},{:.0})", + frame.origin.x, + frame.origin.y, + frame.size.width, + frame.size.height + ); + + let panel = unsafe { build_panel(mtm, frame) }; + let webview = unsafe { build_webview(mtm, &panel, &source) }; + + panel.orderFrontRegardless(); + + let inject_timer = unsafe { spawn_inject_timer(webview.clone()) }; + + NOTCH.with(|cell| { + *cell.borrow_mut() = Some(NotchPanel { + panel, + webview, + inject_timer, + }); + }); + log::info!("[notch-window] panel shown at top-center"); + Ok(()) +} + +// ── Page source ─────────────────────────────────────────────────────────────── + +#[derive(Debug)] +enum PageSource { + Dev { url: String }, + Bundled { index_html: PathBuf, root: PathBuf }, +} + +fn resolve_page_source(app: &AppHandle) -> Result { + if let Some(mut url) = app.config().build.dev_url.as_ref().cloned() { + let query = url + .query() + .map(|q| format!("{q}&window=notch")) + .unwrap_or_else(|| "window=notch".into()); + url.set_query(Some(&query)); + return Ok(PageSource::Dev { + url: url.to_string(), + }); + } + + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("resolve resource_dir: {e}"))?; + for candidate in [ + resource_dir.join("index.html"), + resource_dir.join("dist").join("index.html"), + ] { + if candidate.is_file() { + let root = candidate + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| resource_dir.clone()); + return Ok(PageSource::Bundled { + index_html: candidate, + root, + }); + } + } + Err(format!( + "notch bundled index.html not found under resource_dir={}", + resource_dir.display() + )) +} + +// ── Frame geometry ──────────────────────────────────────────────────────────── + +fn primary_screen_frame(mtm: MainThreadMarker) -> NSRect { + let screens = NSScreen::screens(mtm); + if let Some(primary) = screens.firstObject() { + return primary.frame(); + } + log::warn!("[notch-window] NSScreen::screens returned empty — falling back to 1440×900"); + NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1440.0, 900.0)) +} + +/// Centre the panel horizontally at the very top of the primary screen. +/// +/// AppKit uses a bottom-left origin, so: +/// top-y = screen.origin.y + screen.height − PANEL_HEIGHT +/// center-x = screen.origin.x + (screen.width − PANEL_WIDTH) / 2 +fn top_center_frame(mtm: MainThreadMarker) -> NSRect { + let screen = primary_screen_frame(mtm); + let x = screen.origin.x + (screen.size.width - PANEL_WIDTH) / 2.0; + let y = screen.origin.y + screen.size.height - PANEL_HEIGHT; + NSRect::new(NSPoint::new(x, y), NSSize::new(PANEL_WIDTH, PANEL_HEIGHT)) +} + +// ── NSPanel construction ────────────────────────────────────────────────────── + +unsafe fn build_panel(mtm: MainThreadMarker, frame: NSRect) -> Retained { + let style = NSWindowStyleMask::Borderless | NSWindowStyleMask::NonactivatingPanel; + let panel: Retained = unsafe { + let allocated = NSPanel::alloc(mtm); + msg_send![ + allocated, + initWithContentRect: frame, + styleMask: style, + backing: NSBackingStoreType::Buffered, + defer: false, + ] + }; + + unsafe { + panel.setOpaque(false); + let clear = NSColor::clearColor(); + panel.setBackgroundColor(Some(&clear)); + panel.setHasShadow(false); + + // Float above the menu bar. NSStatusWindowLevel = 25, which sits above + // NSMainMenuWindowLevel = 24. Same recipe used by the mascot panel and + // the `configure_overlay_window_macos` helper. + panel.setLevel(25); + panel.setCollectionBehavior( + NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::Transient + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::IgnoresCycle, + ); + panel.setFloatingPanel(true); + panel.setHidesOnDeactivate(false); + panel.setBecomesKeyOnlyIfNeeded(true); + panel.setWorksWhenModal(true); + + // Fully click-through: the panel never steals mouse events. Menu-bar + // items remain clickable through the transparent regions. + panel.setIgnoresMouseEvents(true); + + let _: () = msg_send![&*panel, setExcludedFromWindowsMenu: true]; + } + + panel +} + +// ── WKWebView construction ──────────────────────────────────────────────────── + +unsafe fn build_webview( + mtm: MainThreadMarker, + panel: &NSPanel, + source: &PageSource, +) -> Retained { + let config: Retained = unsafe { + let alloc = WKWebViewConfiguration::alloc(mtm); + msg_send![alloc, init] + }; + + let frame = NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(PANEL_WIDTH, PANEL_HEIGHT), + ); + let webview: Retained = + unsafe { WKWebView::initWithFrame_configuration(WKWebView::alloc(mtm), frame, &config) }; + + unsafe { + // Disable WKWebView's own background so CSS `background: transparent` works. + // There is no public API for this on macOS — KVC against the private + // `drawsBackground` property is the canonical approach (used by wry, Electron). + let no = NSNumber::numberWithBool(false); + let key = NSString::from_str("drawsBackground"); + let _: () = msg_send![&*webview, setValue: &*no, forKey: &*key]; + + // Auto-resize to fill the panel content view. + let _: () = msg_send![&*webview, setAutoresizingMask: 18u64]; // width|height + + let webview_ref: &objc2::runtime::AnyObject = &*webview; + let webview_view = webview_ref as *const _ as *mut objc2::runtime::AnyObject; + let _: () = msg_send![panel, setContentView: webview_view]; + + match source { + PageSource::Dev { url } => { + let ns_url_str = NSString::from_str(url); + let ns_url = NSURL::URLWithString(&ns_url_str); + if let Some(ns_url) = ns_url { + let request = NSURLRequest::requestWithURL(&ns_url); + let _ = webview.loadRequest(&request); + } else { + log::warn!("[notch-window] could not parse dev url={url}"); + } + } + PageSource::Bundled { index_html, root } => { + let Ok(mut file_url) = url::Url::from_file_path(index_html) else { + log::warn!( + "[notch-window] index_html not absolute: {}", + index_html.display() + ); + return webview; + }; + file_url.set_query(Some("window=notch")); + let Ok(read_access_url) = url::Url::from_file_path(root) else { + log::warn!( + "[notch-window] resource root not absolute: {}", + root.display() + ); + return webview; + }; + let ns_url_str = NSString::from_str(file_url.as_str()); + let read_access_str = NSString::from_str(read_access_url.as_str()); + match ( + NSURL::URLWithString(&ns_url_str), + NSURL::URLWithString(&read_access_str), + ) { + (Some(ns_url), Some(read_access_ns)) => { + let _ = + webview.loadFileURL_allowingReadAccessToURL(&ns_url, &read_access_ns); + log::info!( + "[notch-window] loaded bundled index={} root={}", + index_html.display(), + root.display() + ); + } + _ => log::warn!( + "[notch-window] could not parse bundled file URLs index={} root={}", + file_url, + read_access_url + ), + } + } + } + } + + webview +} + +// ── Core-URL injection timer ────────────────────────────────────────────────── + +/// Spawn a 1 Hz repeating timer that waits for the embedded core to become +/// ready (indicated by `CoreProcessHandle` setting `OPENHUMAN_CORE_RPC_URL` +/// in the process env), then injects the base URL into the WKWebView. +/// +/// After the first successful inject the timer becomes a no-op until it is +/// invalidated by `NotchPanel::order_out()` when the panel is hidden. +unsafe fn spawn_inject_timer(webview: Retained) -> Retained { + let tick_count: Rc> = Rc::new(Cell::new(0)); + let injected: Rc> = Rc::new(Cell::new(false)); + + let block = RcBlock::new(move |_timer: NonNull| { + tick_count.set(tick_count.get() + 1); + + if injected.get() || tick_count.get() < PAGE_LOAD_TICKS { + return; + } + + let Ok(rpc_url) = std::env::var("OPENHUMAN_CORE_RPC_URL") else { + return; // Core not ready yet — try again next tick. + }; + + // Strip `/rpc` path suffix; Socket.IO connects to the base host. + let base_url = rpc_url.trim_end_matches("/rpc").to_string(); + + // The core Socket.IO handshake rejects unauthenticated clients, and this + // WKWebView has no Tauri IPC, so `getCoreRpcToken()` can't `invoke`. Hand + // the per-process bearer in via a global the same way as the URL (our own + // first-party webview — same trust as the renderer's `core_rpc_token`). + // The token is published *after* the URL env is set (post embedded spawn), + // so wait for it rather than injecting an empty token that gets rejected. + let token = match crate::core_process::current_rpc_token() { + Some(t) if !t.is_empty() => t, + _ => return, // bearer not published yet — retry next tick + }; + log::info!( + "[notch-window] injecting core url + bearer (token_len={})", + token.len() + ); + + // Set a global AND dispatch a custom event so React can pick up the URL + // regardless of whether the component mounted before or after this fires. + let js = format!( + "window.__OPENHUMAN_NOTCH_CORE_TOKEN__='{token}';\ + window.__OPENHUMAN_NOTCH_CORE_URL__='{base_url}';\ + window.dispatchEvent(new CustomEvent('notch:core-url',{{detail:{{url:'{base_url}'}}}}));" + ); + let js_str = NSString::from_str(&js); + unsafe { + let _: () = msg_send![ + &*webview, + evaluateJavaScript: &*js_str, + completionHandler: std::ptr::null::() + ]; + } + + injected.set(true); + log::debug!("[notch-window] injected core URL base={base_url}"); + }); + + unsafe { + NSTimer::scheduledTimerWithTimeInterval_repeats_block(INJECT_POLL_SECONDS, true, &block) + } +} diff --git a/app/src/components/settings/panels/VoiceDebugPanel.tsx b/app/src/components/settings/panels/VoiceDebugPanel.tsx index b3222c73e6..a7384467ce 100644 --- a/app/src/components/settings/panels/VoiceDebugPanel.tsx +++ b/app/src/components/settings/panels/VoiceDebugPanel.tsx @@ -102,6 +102,7 @@ const VoiceDebugPanel = () => { min_duration_secs: settings.min_duration_secs, silence_threshold: settings.silence_threshold, custom_dictionary: settings.custom_dictionary, + always_on_enabled: settings.always_on_enabled, }); setNotice(t('voice.debug.settingsSaved')); await loadData(true); @@ -203,6 +204,37 @@ const VoiceDebugPanel = () => { {settings && ( <> + {/* Always-on listening (Phase 2) — opt-in, privacy-sensitive. */} +
+
+ + {t('voice.debug.alwaysOn')} + +

+ {t('voice.debug.alwaysOnDesc')} +

+
+ +
+