diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index d099f7735..bf8c1098d 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8,6 +8,7 @@ version = "0.52.0" dependencies = [ "env_logger", "log", + "objc2-app-kit", "reqwest 0.12.28", "semver", "serde", @@ -2441,8 +2442,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", "objc2-foundation", ] @@ -2470,6 +2501,41 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 93da06e79..cf7343133 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -40,6 +40,8 @@ semver = "1" log = "0.4" env_logger = "0.11" +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = "0.3.2" [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 23af2745d..d6741b476 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -1,22 +1,21 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window (desktop only)", - "platforms": [ - "linux", - "macOS", - "windows" - ], - "windows": [ - "main" - ], + "description": "Capability for the main and overlay windows (desktop only)", + "platforms": ["linux", "macOS", "windows"], + "windows": ["main", "overlay"], "permissions": [ "core:default", + "core:window:default", + "core:window:allow-hide", "core:window:allow-show", "core:window:allow-set-focus", "core:window:allow-unminimize", + "core:window:allow-start-dragging", + "core:window:allow-set-always-on-top", + "core:event:default", "deep-link:default", "opener:default", "allow-core-process" ] -} \ No newline at end of file +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fe671edf1..2c8aac3de 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -9,13 +9,16 @@ use std::sync::Mutex; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, RunEvent, + AppHandle, Emitter, Manager, PhysicalPosition, RunEvent, WebviewWindow, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; #[cfg(any(windows, target_os = "linux"))] use tauri_plugin_deep_link::DeepLinkExt; +#[cfg(target_os = "macos")] +use objc2_app_kit::{NSStatusWindowLevel, NSWindow, NSWindowCollectionBehavior}; + /// Tracks the currently registered dictation hotkey string so we can unregister it later. struct DictationHotkeyState(Mutex>); @@ -53,6 +56,64 @@ fn core_rpc_url() -> String { .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()) } +#[tauri::command] +fn overlay_parent_rpc_url() -> Option { + let url = std::env::var("OPENHUMAN_CORE_RPC_URL").ok()?; + let trimmed = url.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +fn pin_overlay_bottom_right(window: &WebviewWindow) { + let Ok(Some(monitor)) = window.current_monitor() else { + log::warn!("[overlay] could not resolve current monitor for positioning"); + return; + }; + let Ok(size) = window.outer_size() else { + log::warn!("[overlay] could not resolve overlay size for positioning"); + return; + }; + + let margin = 20i32; + let x = monitor.position().x + monitor.size().width as i32 - size.width as i32 - margin; + let y = monitor.position().y + monitor.size().height as i32 - size.height as i32 - margin; + + if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) { + log::warn!("[overlay] failed to pin overlay bottom-right: {err}"); + } else { + log::info!("[overlay] pinned overlay bottom-right at {},{}", x, y); + } +} + +#[cfg(target_os = "macos")] +fn configure_overlay_window_macos(window: &WebviewWindow) { + if let Err(err) = window.set_always_on_top(true) { + log::warn!("[overlay] failed to set always-on-top: {err}"); + } + if let Err(err) = window.set_visible_on_all_workspaces(true) { + log::warn!("[overlay] failed to set visible-on-all-workspaces: {err}"); + } + + match window.ns_window() { + Ok(ns_window) => unsafe { + let window: &NSWindow = &*ns_window.cast(); + let mut behavior = window.collectionBehavior(); + behavior.insert(NSWindowCollectionBehavior::FullScreenAuxiliary); + behavior.insert(NSWindowCollectionBehavior::CanJoinAllSpaces); + window.setCollectionBehavior(behavior); + window.setLevel(NSStatusWindowLevel); + log::info!( + "[overlay] macOS overlay configured for all spaces/fullscreen auxiliary at status level" + ); + }, + Err(err) => { + log::warn!("[overlay] failed to access native NSWindow handle: {err}"); + } + } +} + /// Resolve the core binary, preferring the staged sidecar. fn resolve_core_bin() -> Result { if let Some(bin) = core_process::default_core_bin() { @@ -410,6 +471,24 @@ pub fn run() { } } + #[cfg(target_os = "macos")] + { + if let Some(window) = app.get_webview_window("overlay") { + configure_overlay_window_macos(&window); + } else { + log::warn!("[overlay] overlay window not found during setup"); + } + } + + if let Some(window) = app.get_webview_window("overlay") { + pin_overlay_bottom_right(&window); + if let Err(err) = window.show() { + log::warn!("[overlay] failed to show overlay on startup: {err}"); + } else { + log::info!("[overlay] overlay shown on startup"); + } + } + if let Err(err) = setup_tray(app.handle()) { log::error!("[tray] failed to setup tray icon: {err}"); } @@ -418,6 +497,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ core_rpc_url, + overlay_parent_rpc_url, check_core_update, apply_core_update, restart_core_process, diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 74baac761..4451e1fb4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -20,23 +20,29 @@ "decorations": true, "resizable": true, "center": true + }, + { + "label": "overlay", + "title": "OpenHuman Overlay", + "width": 248, + "height": 228, + "minWidth": 248, + "minHeight": 228, + "transparent": true, + "decorations": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "visible": true, + "center": false } ], - "security": { - "csp": null - }, + "security": { "csp": null }, "macOSPrivateApi": true }, "bundle": { "active": true, - "targets": [ - "app", - "dmg", - "deb", - "nsis", - "msi", - "appimage" - ], + "targets": ["app", "dmg", "deb", "nsis", "msi", "appimage"], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -44,19 +50,13 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": [ - "../../src/openhuman/agent/prompts" - ], - "externalBin": [ - "binaries/openhuman-core" - ], + "resources": ["../../src/openhuman/agent/prompts"], + "externalBin": ["binaries/openhuman-core"], "createUpdaterArtifacts": false, "macOS": { "minimumSystemVersion": "10.15", "entitlements": "entitlements.sidecar.plist", - "dmg": { - "background": "./images/background-dmg.png" - } + "dmg": { "background": "./images/background-dmg.png" } } }, "plugins": { @@ -67,12 +67,6 @@ "https://github.com/tinyhumansai/openhuman/releases/latest/download/latest.json" ] }, - "deep-link": { - "desktop": { - "schemes": [ - "openhuman" - ] - } - } + "deep-link": { "desktop": { "schemes": ["openhuman"] } } } } diff --git a/app/src/components/RotatingTetrahedronCanvas.tsx b/app/src/components/RotatingTetrahedronCanvas.tsx index bb5a4f67e..75e9c95b2 100644 --- a/app/src/components/RotatingTetrahedronCanvas.tsx +++ b/app/src/components/RotatingTetrahedronCanvas.tsx @@ -4,6 +4,10 @@ import * as THREE from 'three'; import { useEffect, useRef, useState } from 'react'; import { ConvexGeometry } from 'three/addons/geometries/ConvexGeometry.js'; +interface RotatingTetrahedronCanvasProps { + inverted?: boolean; +} + /** Start from a regular tetrahedron and lightly truncate each corner to create small blunted edges. */ function bluntedTetrahedronPoints(scale: number, bluntness = 0.12): THREE.Vector3[] { const tetra = [ @@ -25,10 +29,34 @@ function bluntedTetrahedronPoints(scale: number, bluntness = 0.12): THREE.Vector return points; } -export default function RotatingTetrahedronCanvas() { +export default function RotatingTetrahedronCanvas({ + inverted = false, +}: RotatingTetrahedronCanvasProps) { const canvasRef = useRef(null); + const fillMaterialRef = useRef(null); + const edgeMaterialRef = useRef(null); + const speedRef = useRef(2); const [webglFailed, setWebglFailed] = useState(false); + useEffect(() => { + const fillMaterial = fillMaterialRef.current; + const edgeMaterial = edgeMaterialRef.current; + if (!fillMaterial || !edgeMaterial) { + return; + } + + fillMaterial.color.set(inverted ? '#0f172a' : '#dbeafe'); + fillMaterial.opacity = inverted ? 0.1 : 0.72; + fillMaterial.emissive.set(inverted ? '#020617' : '#334155'); + fillMaterial.emissiveIntensity = inverted ? 0.4 : 0.35; + fillMaterial.needsUpdate = true; + + edgeMaterial.color.set(inverted ? '#f8fafc' : '#f8fafc'); + edgeMaterial.needsUpdate = true; + + speedRef.current = inverted ? 20 : 2; + }, [inverted]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -75,16 +103,18 @@ export default function RotatingTetrahedronCanvas() { const geometry = new ConvexGeometry(bluntedTetrahedronPoints(0.98, 0.11)); const fillMaterial = new THREE.MeshLambertMaterial({ - color: '#8e86e9', + color: inverted ? '#0f172a' : '#dbeafe', transparent: true, - opacity: 0.2, - emissive: '#0c1208', - emissiveIntensity: 1, + opacity: inverted ? 0.58 : 0.72, + emissive: inverted ? '#020617' : '#334155', + emissiveIntensity: inverted ? 0.4 : 0.35, }); + fillMaterialRef.current = fillMaterial; const fillMesh = new THREE.Mesh(geometry, fillMaterial); const edgeGeometry = new THREE.EdgesGeometry(geometry); - const edgeMaterial = new THREE.LineBasicMaterial({ color: '#868ee9' }); + const edgeMaterial = new THREE.LineBasicMaterial({ color: inverted ? '#020617' : '#f8fafc' }); + edgeMaterialRef.current = edgeMaterial; const edges = new THREE.LineSegments(edgeGeometry, edgeMaterial); fillMesh.rotation.x = 0.35; @@ -118,8 +148,9 @@ export default function RotatingTetrahedronCanvas() { if (canvas.parentElement) observer.observe(canvas.parentElement); resize(); - const speed = 2; + speedRef.current = inverted ? 7 : 2; const animate = () => { + const speed = speedRef.current; fillMesh.rotation.y += 0.0038 * speed; fillMesh.rotation.x += 0.0002 * speed; edges.rotation.y += 0.0038 * speed; @@ -138,8 +169,11 @@ export default function RotatingTetrahedronCanvas() { geometry.dispose(); fillMaterial.dispose(); edgeMaterial.dispose(); + fillMaterialRef.current = null; + edgeMaterialRef.current = null; renderer.dispose(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Scene created once; inverted changes handled by separate effect. }, []); if (webglFailed) { diff --git a/app/src/index.css b/app/src/index.css index 0108645ee..ff7a3b4c1 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -41,6 +41,26 @@ bottom: 0; } + html[data-window='overlay'], + html[data-window='overlay'] body, + html[data-window='overlay'] #root { + background: transparent; + overflow: hidden; + user-select: none; + } + + @keyframes overlay-bubble-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.92); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + * { color: inherit; } diff --git a/app/src/main.tsx b/app/src/main.tsx index 77fa2cae2..da8eea2a6 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,4 +1,6 @@ // IMPORTANT: Polyfills must be imported FIRST +import { isTauri as tauriRuntimeAvailable } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -6,6 +8,7 @@ import App from './App'; import ErrorReportNotification from './components/ErrorReportNotification'; import './index.css'; import { getCoreStateSnapshot } from './lib/coreState/store'; +import OverlayApp from './overlay/OverlayApp'; import './polyfills'; import { initSentry } from './services/analytics'; import { setStoreForApiClient } from './services/apiClient'; @@ -13,6 +16,9 @@ import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener'; setStoreForApiClient(() => getCoreStateSnapshot().snapshot.sessionToken); +const currentWindowLabel = tauriRuntimeAvailable() ? getCurrentWindow().label : 'main'; +const isOverlayWindow = currentWindowLabel === 'overlay'; + const ensureDefaultHashRoute = () => { const hash = window.location.hash; if (!hash || hash === '#') { @@ -26,21 +32,25 @@ const ensureDefaultHashRoute = () => { // Initialize Sentry early (before React renders) initSentry(); -ensureDefaultHashRoute(); +document.documentElement.dataset.window = currentWindowLabel; -// Deep link listener — try/catch handles non-Tauri environments -setupDesktopDeepLinkListener().catch(err => { - console.error('[DeepLink] setup error:', err); -}); +if (!isOverlayWindow) { + ensureDefaultHashRoute(); + + // Deep link listener — try/catch handles non-Tauri environments + setupDesktopDeepLinkListener().catch(err => { + console.error('[DeepLink] setup error:', err); + }); +} ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + {isOverlayWindow ? : } ); -// Mount error notification in an isolated React root so it survives App crashes -const errorRoot = document.createElement('div'); -errorRoot.id = 'error-report-root'; -document.body.appendChild(errorRoot); -ReactDOM.createRoot(errorRoot).render(); +if (!isOverlayWindow) { + // Mount error notification in an isolated React root so it survives App crashes. + const errorRoot = document.createElement('div'); + errorRoot.id = 'error-report-root'; + document.body.appendChild(errorRoot); + ReactDOM.createRoot(errorRoot).render(); +} diff --git a/app/src/overlay/OverlayApp.tsx b/app/src/overlay/OverlayApp.tsx new file mode 100644 index 000000000..2d466d369 --- /dev/null +++ b/app/src/overlay/OverlayApp.tsx @@ -0,0 +1,156 @@ +import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; + +const OVERLAY_WIDTH = 248; +const OVERLAY_HEIGHT = 228; +const SCENARIO_THREE_TEXT = '"Noted. Need milk."'; + +type OverlayStatus = 'idle' | 'active'; +type OverlayScenario = 1 | 2 | 3; + +interface OverlayBubble { + id: string; + text: string; + tone: 'neutral' | 'accent' | 'success'; + compact?: boolean; +} + +function bubbleToneClass(tone: OverlayBubble['tone']) { + switch (tone) { + case 'accent': + return 'bg-blue-700 text-white'; + case 'success': + return 'bg-emerald-500 text-emerald-950'; + default: + return 'bg-slate-700 text-white'; + } +} + +function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) { + const [displayedText, setDisplayedText] = useState(''); + const indexRef = useRef(0); + + useEffect(() => { + if (!bubble.text) { + return () => { + indexRef.current = 0; + setDisplayedText(''); + }; + } + + const timeoutId = window.setInterval( + () => { + indexRef.current += 1; + setDisplayedText(bubble.text.slice(0, indexRef.current)); + if (indexRef.current >= bubble.text.length) { + window.clearInterval(timeoutId); + } + }, + bubble.compact ? 28 : 32 + ); + + return () => { + window.clearInterval(timeoutId); + indexRef.current = 0; + setDisplayedText(''); + }; + }, [bubble.compact, bubble.id, bubble.text]); + + return ( +
+ {displayedText || ' '} +
+ ); +} + +export default function OverlayApp() { + const [scenario, setScenario] = useState(1); + + useEffect(() => { + const appWindow = getCurrentWindow(); + const size = new LogicalSize(OVERLAY_WIDTH, OVERLAY_HEIGHT); + void appWindow.setSize(size).catch(error => { + console.warn('[overlay] failed to resize overlay window', error); + }); + void appWindow.setMinSize(size).catch(error => { + console.warn('[overlay] failed to set overlay min size', error); + }); + void appWindow.setMaxSize(size).catch(error => { + console.warn('[overlay] failed to set overlay max size', error); + }); + }, []); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setScenario(current => { + if (current === 1) return 2; + if (current === 2) return 3; + return 1; + }); + }, 5000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [scenario]); + + const status: OverlayStatus = scenario === 1 ? 'idle' : 'active'; + + const bubbles = useMemo(() => { + if (scenario === 1) { + return []; + } + + if (scenario === 2) { + return [ + { + id: 'assistant', + text: '"Hey I think your coffee is getting cold. Want me to get you a new one?"', + tone: 'accent', + }, + ]; + } + + return [{ id: 'stt', text: SCENARIO_THREE_TEXT, tone: 'accent' }]; + }, [scenario]); + + const orbClassName = useMemo(() => { + if (status === 'active') { + return 'border-blue-950 bg-blue-700'; + } + return 'border-slate-950 bg-slate-800'; + }, [status]); + const tetrahedronInverted = status === 'active'; + + return ( +
+
+
+ {bubbles.map(bubble => ( +
+ +
+ ))} +
+ +
+ +
+
+
+ ); +} diff --git a/app/src/overlay/parentCoreRpc.ts b/app/src/overlay/parentCoreRpc.ts new file mode 100644 index 000000000..48f564ca3 --- /dev/null +++ b/app/src/overlay/parentCoreRpc.ts @@ -0,0 +1,118 @@ +/** + * HTTP JSON-RPC to the desktop core sidecar shared by the main app and overlay. + * Mirrors the naming normalization in app/src/services/coreRpcClient.ts (subset). + */ + +let nextJsonRpcId = 1; + +export const normalizeLegacyMethod = (method: string): string => { + if (method.startsWith('openhuman.accessibility_')) { + return method.replace('openhuman.accessibility_', 'openhuman.screen_intelligence_'); + } + return method; +}; + +/** RpcOutcome with non-empty logs serializes as `{ result, logs }` in the core. */ +const unwrapCliCompatibleJson = (raw: unknown): T => { + if ( + raw !== null && + typeof raw === 'object' && + Object.prototype.hasOwnProperty.call(raw, 'result') && + Object.prototype.hasOwnProperty.call(raw, 'logs') + ) { + const keys = Object.keys(raw); + const { logs } = raw as { logs: unknown }; + if (keys.length === 2 && Array.isArray(logs)) { + return (raw as { result: T }).result; + } + } + return raw as T; +}; + +interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +interface JsonRpcResponse { + jsonrpc?: string; + id?: number | string | null; + result?: T; + error?: JsonRpcError; +} + +const DEFAULT_RPC_TIMEOUT_MS = 10_000; + +const isJsonRpcEnvelope = (value: unknown): value is JsonRpcResponse => { + if (value === null || typeof value !== 'object') { + return false; + } + + return ( + Object.prototype.hasOwnProperty.call(value, 'error') || + Object.prototype.hasOwnProperty.call(value, 'result') || + Object.prototype.hasOwnProperty.call(value, 'id') || + Object.prototype.hasOwnProperty.call(value, 'jsonrpc') + ); +}; + +export const callParentCoreRpc = async ( + rpcUrl: string, + method: string, + params: Record = {}, + timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS +): Promise => { + const normalizedMethod = normalizeLegacyMethod(method); + const payload = { + jsonrpc: '2.0' as const, + id: nextJsonRpcId++, + method: normalizedMethod, + params, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if ( + (err instanceof DOMException && err.name === 'AbortError') || + (err instanceof Error && err.name === 'AbortError') + ) { + throw new Error( + `Core RPC request timed out after ${timeoutMs}ms (method: ${normalizedMethod})` + ); + } + throw err; + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`); + } + + const json = await response.json(); + if (!isJsonRpcEnvelope(json)) { + throw new Error('Invalid JSON-RPC envelope'); + } + + if (json.error) { + throw new Error(json.error.message || 'Core RPC returned an error'); + } + if (!Object.prototype.hasOwnProperty.call(json, 'result')) { + throw new Error('Core RPC response missing result'); + } + + return unwrapCliCompatibleJson(json.result); +}; diff --git a/app/src/services/apiClient.ts b/app/src/services/apiClient.ts index 1e3020e4b..8070e3eff 100644 --- a/app/src/services/apiClient.ts +++ b/app/src/services/apiClient.ts @@ -104,7 +104,10 @@ class ApiClient { } // Handle abort/timeout specifically - if (error instanceof DOMException && error.name === 'AbortError') { + if ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ) { throw { success: false, error: `Request timed out after ${timeout / 1000}s` } as ApiError; } diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 5fc070483..fd155dfac 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -574,8 +574,7 @@ pub async fn run_server( run_server_inner(host, port, socketio_enabled, false).await } -/// Like [`run_server`] but marks the instance as embedded — the overlay will -/// **not** be auto-spawned, preventing recursive overlay launches. +/// Like [`run_server`] but marks the instance as embedded. pub async fn run_server_embedded( host: Option<&str>, port: Option, @@ -585,9 +584,6 @@ pub async fn run_server_embedded( } /// Internal server entrypoint. -/// -/// When `embedded_core` is `true` the server skips auto-spawning the overlay -/// (useful when the overlay itself hosts an embedded core to avoid recursion). async fn run_server_inner( host: Option<&str>, port: Option, @@ -654,30 +650,7 @@ async fn run_server_inner( log::info!("[rpc:socketio] disabled (--jsonrpc-only)"); } - // Derive the real bound address from the listener so ephemeral port 0, - // wildcard binds, and IPv6 are handled correctly. - let bound_addr = listener.local_addr()?; - let parent_core_rpc_url = { - let (h, p) = (bound_addr.ip(), bound_addr.port()); - let effective_ip = if h.is_unspecified() { - if h.is_ipv6() { - std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST) - } else { - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) - } - } else { - h - }; - if effective_ip.is_ipv6() { - format!("http://[{effective_ip}]:{p}/rpc") - } else { - format!("http://{effective_ip}:{p}/rpc") - } - }; - log::debug!("[core] parent_core_rpc_url = {parent_core_rpc_url}"); - // Optional background bootstrap for local AI services. - let is_embedded = embedded_core; tokio::spawn(async move { match crate::openhuman::config::Config::load_or_init().await { Ok(config) => { @@ -686,14 +659,10 @@ async fn run_server_inner( service.bootstrap(&config).await; } - // Launch the overlay Tauri app (transparent debug/voice panel) as a child process. - // Skip when running as an embedded core inside the overlay itself. - if is_embedded { - log::info!("[overlay] embedded core — skipping overlay auto-spawn"); - } else if config.overlay_enabled { - crate::openhuman::overlay::spawn_overlay(parent_core_rpc_url.as_str()); + if embedded_core { + log::debug!("[core] embedded core startup"); } else { - log::info!("[overlay] overlay disabled by config (overlay_enabled = false)"); + log::debug!("[core] desktop core startup"); } // Start the voice server (records + transcribes) and/or the @@ -758,7 +727,7 @@ async fn run_server_inner( } } Err(err) => { - log::warn!("[core] config load failed, skipping local-ai and overlay: {err}"); + log::warn!("[core] config load failed, skipping local-ai startup: {err}"); } } }); diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index a4f1a2554..9c1610d7a 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -840,15 +840,6 @@ impl Config { } } - if let Ok(flag) = std::env::var("OPENHUMAN_OVERLAY_ENABLED") { - let normalized = flag.trim().to_ascii_lowercase(); - match normalized.as_str() { - "1" | "true" | "yes" | "on" => self.overlay_enabled = true, - "0" | "false" | "no" | "off" => self.overlay_enabled = false, - _ => {} - } - } - if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment { self.proxy.apply_to_process_env(); } diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index c2611bd9c..33ca41784 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -8,7 +8,6 @@ mod autocomplete; mod autonomy; mod channels; mod defaults; -pub(crate) use defaults::default_true; mod dictation; mod hardware; mod heartbeat_cron; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 0bb93df28..33d2a8e2e 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -130,11 +130,6 @@ pub struct Config { #[serde(default)] pub dictation: DictationConfig, - /// Whether to launch the overlay Tauri app (floating debug/voice panel) - /// when the core RPC server starts. Defaults to `true`. - #[serde(default = "default_true")] - pub overlay_enabled: bool, - /// Whether the user has completed the onboarding flow. #[serde(default)] pub onboarding_completed: bool, @@ -188,7 +183,6 @@ impl Default for Config { orchestrator: OrchestratorConfig::default(), update: UpdateConfig::default(), dictation: DictationConfig::default(), - overlay_enabled: true, onboarding_completed: false, } } diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 620f458c1..4469be6d9 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -37,7 +37,6 @@ pub mod learning; pub mod local_ai; pub mod memory; pub mod migration; -pub mod overlay; pub mod providers; pub mod referral; pub mod screen_intelligence; diff --git a/src/openhuman/overlay/mod.rs b/src/openhuman/overlay/mod.rs deleted file mode 100644 index 3c296353e..000000000 --- a/src/openhuman/overlay/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Overlay process launcher — discovers and spawns the `openhuman-overlay` -//! Tauri application as a child process so the floating debug/voice panel -//! appears automatically when the core RPC server is running. - -mod process; - -pub use process::spawn_overlay; diff --git a/src/openhuman/overlay/process.rs b/src/openhuman/overlay/process.rs deleted file mode 100644 index 1422c1fc0..000000000 --- a/src/openhuman/overlay/process.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Discovery and lifecycle management for the `openhuman-overlay` binary. -//! -//! The overlay is a separate Tauri application that provides a transparent -//! floating panel with voice transcription, autocomplete debug info, and -//! Globe/Fn hotkey toggling. The core process launches it as a fire-and-forget -//! child so that it appears automatically when the core RPC server starts. - -use std::path::PathBuf; -use std::process::{Command, Stdio}; - -/// Attempt to find and spawn the overlay binary. -/// -/// `parent_core_rpc_url` must be the JSON-RPC URL of the **same** core process -/// that spawned the overlay (e.g. `http://127.0.0.1:7788/rpc`) so the overlay -/// UI can query autocomplete, screen intelligence, and voice state from the -/// desktop sidecar instead of a separate in-process core instance. -/// -/// This is best-effort: if the binary is not found or fails to launch, a -/// warning is logged and the core continues normally. -pub fn spawn_overlay(parent_core_rpc_url: &str) { - let Some(overlay_bin) = find_overlay_binary() else { - log::warn!( - "[overlay] openhuman-overlay binary not found — skipping overlay launch (set OPENHUMAN_OVERLAY_BIN or build overlay/src-tauri)" - ); - return; - }; - - log::info!( - "[overlay] launching overlay: {} (parent RPC {})", - overlay_bin.display(), - parent_core_rpc_url - ); - - let mut cmd = Command::new(&overlay_bin); - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .env( - "OPENHUMAN_OVERLAY_PARENT_RPC_URL", - parent_core_rpc_url.trim(), - ) - // The desktop core sets OPENHUMAN_CORE_PORT; the overlay must not inherit it or its - // optional embedded JSON-RPC server will try to bind the same port as the parent. - .env_remove("OPENHUMAN_CORE_PORT"); - - match cmd.spawn() { - Ok(child) => { - log::info!("[overlay] overlay process spawned (pid={})", child.id()); - } - Err(err) => { - log::warn!( - "[overlay] failed to spawn overlay at {}: {err}", - overlay_bin.display() - ); - } - } -} - -/// Search for the `openhuman-overlay` binary in standard locations. -/// -/// Search order: -/// 1. `OPENHUMAN_OVERLAY_BIN` env var (explicit override) -/// 2. Next to the current executable (`openhuman-overlay` / `openhuman-overlay.exe`) -/// 3. macOS: inside the `.app` bundle Resources directory -/// 4. Dev builds: `overlay/src-tauri/target/debug/openhuman-overlay` -fn find_overlay_binary() -> Option { - // 1. Explicit env var override - if let Ok(path) = std::env::var("OPENHUMAN_OVERLAY_BIN") { - let candidate = PathBuf::from(&path); - if candidate.exists() { - log::debug!( - "[overlay] found via OPENHUMAN_OVERLAY_BIN: {}", - candidate.display() - ); - return Some(candidate); - } - log::debug!("[overlay] OPENHUMAN_OVERLAY_BIN set but path does not exist: {path}"); - } - - let exe = std::env::current_exe().ok()?; - let exe_dir = exe.parent()?; - - // 2. Next to the current executable - let standalone = exe_dir.join(overlay_binary_name()); - if standalone.exists() { - log::debug!("[overlay] found next to exe: {}", standalone.display()); - return Some(standalone); - } - - // 3. macOS: Resources directory inside the .app bundle - #[cfg(target_os = "macos")] - { - if let Some(resources_dir) = exe_dir.parent().map(|p| p.join("Resources")) { - let in_resources = resources_dir.join(overlay_binary_name()); - if in_resources.exists() { - log::debug!("[overlay] found in Resources: {}", in_resources.display()); - return Some(in_resources); - } - } - } - - // 4. Dev builds: walk up from current exe to find overlay/src-tauri/target - if cfg!(debug_assertions) { - // Try relative to CARGO_MANIFEST_DIR at compile time - let dev_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("overlay") - .join("src-tauri") - .join("target") - .join("debug") - .join(overlay_binary_name()); - if dev_path.exists() { - log::debug!("[overlay] found dev build: {}", dev_path.display()); - return Some(dev_path); - } - - // Also check for the macOS .app bundle in dev - #[cfg(target_os = "macos")] - { - let dev_app_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("overlay") - .join("src-tauri") - .join("target") - .join("debug") - .join("bundle") - .join("macos") - .join("openhuman-overlay.app") - .join("Contents") - .join("MacOS") - .join("openhuman-overlay"); - if dev_app_path.exists() { - log::debug!( - "[overlay] found dev .app bundle: {}", - dev_app_path.display() - ); - return Some(dev_app_path); - } - } - } - - None -} - -fn overlay_binary_name() -> &'static str { - #[cfg(windows)] - { - "openhuman-overlay.exe" - } - #[cfg(not(windows))] - { - "openhuman-overlay" - } -}