diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 11912977f..db05ed0e9 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -13,5 +13,6 @@ allow = [ "service_uninstall_direct", "register_dictation_hotkey", "unregister_dictation_hotkey", + "activate_main_window", ] deny = [] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index bcfbb1604..249fdbec8 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -404,20 +404,27 @@ fn is_daemon_mode() -> bool { std::env::args().any(|arg| arg == "daemon" || arg == "--daemon") } -fn show_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - if let Err(err) = window.show() { - log::error!("[tray] failed to show main window: {err}"); - } - if let Err(err) = window.unminimize() { - log::error!("[tray] failed to unminimize main window: {err}"); - } - if let Err(err) = window.set_focus() { - log::error!("[tray] failed to focus main window: {err}"); - } - } else { - log::error!("[tray] main window not found"); - } +/// Tauri command: bring the main window to front from any webview (e.g. overlay orb click). +#[tauri::command] +fn activate_main_window(app: AppHandle) -> Result<(), String> { + log::debug!("[window] activate_main_window called from overlay"); + show_main_window(&app) +} + +fn show_main_window(app: &AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "main window not found".to_string())?; + window + .show() + .map_err(|err| format!("failed to show main window: {err}"))?; + window + .unminimize() + .map_err(|err| format!("failed to unminimize main window: {err}"))?; + window + .set_focus() + .map_err(|err| format!("failed to focus main window: {err}"))?; + Ok(()) } fn setup_tray(app: &AppHandle) -> tauri::Result<()> { @@ -444,7 +451,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { .on_menu_event(|app, event| match event.id().as_ref() { "tray_show_window" => { log::info!("[tray] action=show_window source=menu"); - show_main_window(app); + if let Err(err) = show_main_window(app) { + log::error!("[tray] failed to show main window from menu: {err}"); + } } "tray_quit" => { log::info!("[tray] action=quit source=menu"); @@ -460,7 +469,9 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { } = event { log::info!("[tray] action=show_window source=left_click"); - show_main_window(tray.app_handle()); + if let Err(err) = show_main_window(tray.app_handle()) { + log::error!("[tray] failed to show main window from tray click: {err}"); + } } }) .build(app)?; @@ -567,7 +578,8 @@ pub fn run() { service_status_direct, service_uninstall_direct, register_dictation_hotkey, - unregister_dictation_hotkey + unregister_dictation_hotkey, + activate_main_window ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -589,7 +601,9 @@ pub fn run() { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => { log::info!("[window] reopen event — showing main window"); - show_main_window(app_handle); + if let Err(err) = show_main_window(app_handle) { + log::error!("[macos] failed to show main window on reopen: {err}"); + } } RunEvent::Exit => { if let Some(core) = app_handle.try_state::() { diff --git a/app/src/overlay/OverlayApp.tsx b/app/src/overlay/OverlayApp.tsx index a726781a7..9853f3ec4 100644 --- a/app/src/overlay/OverlayApp.tsx +++ b/app/src/overlay/OverlayApp.tsx @@ -194,6 +194,18 @@ export default function OverlayApp() { setBubble(null); }, [clearDismissTimer]); + /** Click handler for the orb: idle → bring main window to front; active → dismiss bubble. */ + const handleOrbClick = useCallback(() => { + if (mode === 'idle') { + console.debug('[overlay] orb clicked while idle — activating main window'); + invoke('activate_main_window').catch(err => { + console.error('[overlay] failed to activate main window:', err); + }); + } else { + goIdle(); + } + }, [mode, goIdle]); + // ── Dictation: pressed / released ────────────────────────────────────── const handleDictationToggle = useCallback( (payload: DictationTogglePayload) => { @@ -420,16 +432,43 @@ export default function OverlayApp() { userDraggedRef.current = false; }, []); - /** Initiate native window drag on mouse-down. */ - const handleDragStart = useCallback( + // NSPanel (non-activating overlay) doesn't deliver synthesized `click` + // events to the webview, and calling `startDragging()` eagerly on + // mouse-down blocks `mouseup` from firing. We instead arm the drag only + // after the pointer moves past a small threshold, so a pure click fires + // `mouseup` normally and we can activate the main window there. + const pressRef = useRef<{ x: number; y: number; dragStarted: boolean } | null>(null); + const CLICK_SLOP_PX = 4; + /** Pending single-click, deferred so a follow-up double-click can cancel it. */ + const clickTimerRef = useRef(null); + const CLICK_DOUBLE_CLICK_DELAY_MS = 250; + + /** Record mouse-down position; defer drag until the pointer actually moves. */ + const handleDragStart = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + pressRef.current = { x: e.screenX, y: e.screenY, dragStarted: false }; + }, []); + + /** If pointer moves past the slop, escalate into a native window drag. */ + const handleMouseMove = useCallback( async (e: React.MouseEvent) => { - // Only drag on primary button; ignore if a click handler should fire - if (e.button !== 0) return; - e.preventDefault(); + const press = pressRef.current; + if (!press) return; + // If the primary button is no longer held, a prior mouseup was missed + // (window-drag steals it, focus change, etc). Drop the stale press so + // we don't spuriously start a drag on a hover. + if ((e.buttons & 1) === 0) { + pressRef.current = null; + return; + } + if (press.dragStarted) return; + const dx = Math.abs(e.screenX - press.x); + const dy = Math.abs(e.screenY - press.y); + if (dx <= CLICK_SLOP_PX && dy <= CLICK_SLOP_PX) return; + press.dragStarted = true; try { const appWindow = getCurrentWindow(); await appWindow.startDragging(); - // After the drag completes, persist the new position void persistPosition(); } catch { // startDragging can fail if not supported — fall through silently @@ -438,6 +477,47 @@ export default function OverlayApp() { [persistPosition] ); + /** + * On mouse-up, treat as a click if no drag was initiated. Emulates + * `onClick` for the non-activating panel. The click is deferred briefly + * so a follow-up `dblclick` (used to reset position) can cancel it. + */ + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + const press = pressRef.current; + pressRef.current = null; + if (e.button !== 0 || !press) return; + if (press.dragStarted) return; + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + } + clickTimerRef.current = window.setTimeout(() => { + clickTimerRef.current = null; + console.debug('[overlay] orb mouseup → click'); + handleOrbClick(); + }, CLICK_DOUBLE_CLICK_DELAY_MS); + }, + [handleOrbClick] + ); + + /** Double-click resets position — cancel any pending single-click first. */ + const handleDoubleClick = useCallback(() => { + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + resetPosition(); + }, [resetPosition]); + + useEffect(() => { + return () => { + if (clickTimerRef.current !== null) { + window.clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + }; + }, []); + useEffect(() => { const appWindow = getCurrentWindow(); const isActive = status === 'active'; @@ -551,9 +631,10 @@ export default function OverlayApp() { ? 'Attention message' : 'OpenHuman overlay' } - onClick={goIdle} onMouseDown={handleDragStart} - onDoubleClick={resetPosition} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onDoubleClick={handleDoubleClick} onMouseEnter={() => { setIsHovered(true); }}