From 4b557b9611fa9c32f2ccbb63c73d88f4d1e308d8 Mon Sep 17 00:00:00 2001 From: Iamdk25 Date: Tue, 1 Sep 2026 17:09:39 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20acknowledge=20real=20Accessibility?= =?UTF-8?q?=20state=20=E2=80=94=20self-heal=20stale=20TCC=20grants,=20hide?= =?UTF-8?q?=20the=20pill=20at=20idle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the most-reported pain points, fixed at the source: - Stale-grant self-heal. Every ad-hoc-signed rebuild invalidates the TCC entry, leaving System Settings showing WhimprFlow as enabled while the running build is refused (or the reverse). On launch, when untrusted, the app now clears its own stale entry (tccutil reset), re-prompts, and opens the Accessibility pane; the Grant button does the same; a Fix Accessibility action covers the granted-but-dead-tap case. hotkey_wired status (TAP_LIVE on macOS, HOOK_LIVE on Windows) distinguishes 'granted' from 'actually working', surfaced in onboarding and the Hub after a grace period so it never false-positives. The tap thread re-checks trust every retry, so reset -> re-enable works with no relaunch. - Pill only while working. The overlay window was always visible at rest as an idle nub. Bar-state emission now goes through one shared emitter that also shows/hides the window: visible for recording/locked/transcribing/ done/error, hidden at idle. Wired through the macOS state machine, the diagnostics path, the Windows pipeline, and the tray demo items. --- README.md | 12 ++-- src-tauri/src/diag.rs | 11 +--- src-tauri/src/hotkey.rs | 73 +++++++++++++++++------- src-tauri/src/lib.rs | 116 +++++++++++++++++++++++++++++++++----- src-tauri/src/win.rs | 26 ++++++--- ui/src/hub/App.tsx | 33 +++++++++-- ui/src/hub/Onboarding.tsx | 61 +++++++++++++++++++- ui/src/hub/api.ts | 16 ++++++ 8 files changed, 285 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index dc59edf..fcf5225 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ Both platforms are build-from-source only for now — there's no signed installe - **On-device ASR** — Whisper (via `whisper.cpp`), running on the GPU. Ships a small English model by default; larger models are auto-preferred if present. - **Local LLM cleanup** — Qwen3-4B-Instruct (via `llama.cpp`) runs as a separate worker process and cleans the transcript: removes fillers, resolves spoken self-corrections ("meet at 2… no wait, 3" → "3"), applies spoken punctuation, and formats lists/paragraphs. Deterministic gates guard against over-editing, with a raw-transcript fallback. -- **Optional cloud cleanup** — OpenAI (default) / Anthropic, behind one trait. Keys are stored in the OS keychain (macOS Keychain / Windows Credential Manager), **never in a file**. -- **Floating pill UI** — a small always-on-top bar showing idle / recording / processing states. +- **Floating pill UI** — an always-on-top bar that appears only while WhimprFlow is working (recording, cleaning up, the done flash, or an error) and disappears the moment it's idle, so it never sits on your screen at rest. - **Personal dictionary + auto-learn** — teach it names and terms; on macOS a post-paste Accessibility observer watches for a one-word correction and learns it automatically (conservative filters to avoid junk). *Auto-learn capture is macOS-only so far.* - **Usage stats** — words dictated, words-per-minute, day streak, time saved, 7-day activity, all stored locally. @@ -116,10 +115,11 @@ why it looked like nothing was happening at all). If you still hit this: - **macOS — "granted but still nothing" after a rebuild.** Every local `tauri build` produces a differently-signed binary, and macOS can leave a stale Accessibility entry for the old signature that *looks* granted but - isn't. Fix: in System Settings → Privacy & Security → Accessibility, remove - WhimprFlow with the **−** button and re-add it (or toggle it off/on), then - relaunch. WhimprFlow's pill and Hub will now show "Fn key isn't wired up" - when this happens instead of just doing nothing. + isn't. WhimprFlow now heals this itself: on launch it clears any stale TCC + entry for its bundle id, re-prompts, and opens **System Settings → Privacy & + Security → Accessibility** — just enable WhimprFlow there (and if the Hub + ever shows "Fn key isn't wired up" while the pane says it's on, click its + **Fix Accessibility** button). No relaunch needed either way. - **Windows — Right Ctrl does nothing.** Another app may be holding a conflicting global keyboard hook (some anti-cheat/security tools do this); close it and relaunch WhimprFlow. diff --git a/src-tauri/src/diag.rs b/src-tauri/src/diag.rs index 1ade7a2..e8ffb7d 100644 --- a/src-tauri/src/diag.rs +++ b/src-tauri/src/diag.rs @@ -22,7 +22,6 @@ const PLATFORM: whimpr_core::diagnostics::Platform = whimpr_core::diagnostics::P #[cfg(not(any(target_os = "macos", target_os = "windows")))] const PLATFORM: whimpr_core::diagnostics::Platform = whimpr_core::diagnostics::Platform::MacOs; -const OVERLAY_LABEL: &str = "whimpr_bar"; /// How long the error stays on the pill before it reverts to idle — much /// longer than the ~500ms "done" flash, since this is the one state the user /// actually needs time to read. @@ -36,11 +35,6 @@ pub struct ErrorDto { pub detail: String, } -#[derive(Clone, Serialize)] -struct BarPayload { - state: &'static str, -} - /// Report a failure: log it, push the pill to the `error` state, broadcast /// the message to every window, and remember it for [`last_error`]. #[allow(dead_code)] // used on macOS/Windows; inert-but-present on other targets @@ -49,13 +43,14 @@ pub fn report(app: &AppHandle, failure: InjectionFailure) { eprintln!("[whimpr] ⚠ {}: {}", diag.headline, diag.detail); let dto = ErrorDto { headline: diag.headline, detail: diag.detail }; *LAST_ERROR.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(dto.clone()); - let _ = app.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", BarPayload { state: "error" }); + // Shared emitter: also makes the overlay window exist for the error state. + crate::emit_flowbar_state(app, "error"); let _ = app.emit("whimpr://error", dto); let app2 = app.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(ERROR_LINGER_MS)); - let _ = app2.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", BarPayload { state: "idle" }); + crate::emit_flowbar_state(&app2, "idle"); }); } diff --git a/src-tauri/src/hotkey.rs b/src-tauri/src/hotkey.rs index ae37fab..30d05b8 100644 --- a/src-tauri/src/hotkey.rs +++ b/src-tauri/src/hotkey.rs @@ -95,13 +95,16 @@ mod imp { static CLOCK: OnceLock = OnceLock::new(); static FN_IS_DOWN: AtomicBool = AtomicBool::new(false); static TAP_PORT: AtomicPtr = AtomicPtr::new(null_mut()); + /// True once the global Fn CGEventTap is actually created and running — + /// distinct from `AXIsProcessTrusted`, which can report "granted" for a + /// stale TCC entry that macOS will never honor for this build's signature. + /// Drives the Hub's `hotkey_wired` status and the stale-grant Fix flow. + static TAP_LIVE: AtomicBool = AtomicBool::new(false); /// Set once at startup if no Whisper model file exists on disk at all — /// distinct from "still loading", so the finalize path only shows the /// user a loud "no speech model" error for the real case, not a race /// against the ~1s background load right after launch. static ASR_MODEL_MISSING: AtomicBool = AtomicBool::new(false); - /// Bundle id of the app that was frontmost at record-start = the paste target. - /// Cleanup uses it to format for the medium (email vs. text vs. chat). static TARGET_APP: OnceLock>> = OnceLock::new(); static CAPTURE: OnceLock>> = OnceLock::new(); static ASR: OnceLock> = OnceLock::new(); @@ -112,11 +115,6 @@ mod imp { static DICTIONARY: OnceLock> = OnceLock::new(); static STATS: OnceLock> = OnceLock::new(); - #[derive(Clone, Serialize)] - struct BarPayload { - state: &'static str, - } - #[derive(Clone, Serialize)] struct WavePayload { bars: Vec, @@ -425,7 +423,9 @@ mod imp { fn emit_bar(app: &AppHandle, state: &'static str) { eprintln!("[whimpr] pill -> {state}"); - let _ = app.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", BarPayload { state }); + // Shared emitter also toggles the overlay window: visible for every + // state except idle. + crate::emit_flowbar_state(app, state); } /// Feed one input into the shared state machine and enact its actions. @@ -630,6 +630,19 @@ mod imp { event } + /// Whether the global Fn tap is live (see [`TAP_LIVE`]). `get_status` + /// surfaces this to the Hub as `hotkey_wired`. + pub fn tap_live() -> bool { + TAP_LIVE.load(Ordering::SeqCst) + } + + /// Called when the Hub's "Fix Accessibility" flow resets the TCC entry: the + /// old tap (if any) is no longer meaningful until the user re-grants and a + /// fresh tap is created. + pub fn mark_tap_stale() { + TAP_LIVE.store(false, Ordering::SeqCst); + } + pub fn install(app: AppHandle) { let _ = APP.set(app); let _ = MACHINE.set(Mutex::new(StateMachine::new())); @@ -676,16 +689,27 @@ mod imp { // Accessibility is the ONE permission that makes the Fn CGEventTap global AND // lets us post the Cmd+V paste into other apps. Without it, a keyboard tap is - // silently limited to frontmost-only — the exact bug. Prompt for it up front. + // silently limited to frontmost-only — the exact bug. Self-heal up front: + // "granted in System Settings but the app doesn't acknowledge it" means a + // stale TCC entry is enforcing an older build's signature, so clear it, + // re-prompt, and open the pane — the tap thread below picks the fresh grant + // up the moment it lands, with no relaunch. if crate::paste::is_trusted() { eprintln!("[whimpr] Accessibility granted — Fn works in every app, paste enabled"); } else { eprintln!( - "[whimpr] ⚠ Accessibility NOT granted — Fn only works while WhimprFlow is \ - frontmost and paste is disabled. Prompting; grant WhimprFlow under System \ - Settings → Privacy & Security → Accessibility (no relaunch needed)." + "[whimpr] ⚠ Accessibility NOT granted — clearing any stale TCC entry, \ + re-prompting, and opening System Settings → Privacy & Security → \ + Accessibility (no relaunch needed)." ); - crate::paste::prompt_accessibility(); + std::thread::spawn(|| { + // Let the Hub/onboarding window mount first so the user sees it + // before the Settings pane opens over it. + std::thread::sleep(Duration::from_millis(800)); + if let Err(e) = crate::reset_and_prompt_accessibility() { + eprintln!("[whimpr] accessibility self-heal failed: {e}"); + } + }); } // Input Monitoring is NOT the gate for a CGEventTap — kept only as diagnostics. eprintln!( @@ -722,6 +746,13 @@ mod imp { // picked up automatically. let mut reported = false; let port = loop { + // Re-check trust inside the retry loop: the Hub's "Fix" button + // resets the TCC entry, and a tap created while untrusted is + // permanently frontmost-only — keep waiting for a fresh grant. + if !crate::paste::is_trusted() { + std::thread::sleep(Duration::from_millis(500)); + continue; + } let port = unsafe { CGEventTapCreate( K_CG_SESSION_EVENT_TAP, @@ -737,9 +768,8 @@ mod imp { } eprintln!( "[whimpr] Fn tap null despite Accessibility — likely a stale TCC entry from \ - an earlier build. Run: tccutil reset Accessibility com.whimpr.whimprflow, \ - or toggle WhimprFlow off/on under System Settings → Privacy & Security → \ - Accessibility. Retrying…" + an earlier build. Use the Hub's Fix button (or run: tccutil reset \ + Accessibility com.whimpr.whimprflow), then re-enable WhimprFlow. Retrying…" ); if !reported { if let Some(app) = APP.get() { @@ -753,6 +783,7 @@ mod imp { eprintln!("[whimpr] Fn tap recovered — the key is live now."); crate::diag::clear_last_error(); } + TAP_LIVE.store(true, Ordering::SeqCst); TAP_PORT.store(port, Ordering::SeqCst); unsafe { let source = CFMachPortCreateRunLoopSource(null(), port, 0); @@ -767,14 +798,14 @@ mod imp { #[cfg(target_os = "macos")] pub use imp::{ current_settings, dictionary_add, dictionary_entries, dictionary_learn, dictionary_remove, - history, install, rebuild_providers, stats_summary, update_settings, + history, install, mark_tap_stale, rebuild_providers, stats_summary, tap_live, update_settings, }; // Windows uses the real (but unverified) platform layer in `crate::win`. #[cfg(target_os = "windows")] pub use crate::win::{ current_settings, dictionary_add, dictionary_entries, dictionary_learn, dictionary_remove, - history, install, rebuild_providers, stats_summary, update_settings, + history, install, mark_tap_stale, rebuild_providers, stats_summary, tap_live, update_settings, }; // Other platforms (Linux, etc.): inert stubs so the crate still builds. @@ -798,9 +829,13 @@ mod other { pub fn dictionary_add(_correct: String, _mishears: Vec) {} pub fn dictionary_remove(_correct: &str) {} pub fn dictionary_learn(_correct: String, _mishears: Vec) {} + pub fn tap_live() -> bool { + true + } + pub fn mark_tap_stale() {} } #[cfg(not(any(target_os = "macos", target_os = "windows")))] pub use other::{ current_settings, dictionary_add, dictionary_entries, dictionary_learn, dictionary_remove, - history, install, rebuild_providers, stats_summary, update_settings, + history, install, mark_tap_stale, rebuild_providers, stats_summary, tap_live, update_settings, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d66157e..77f80d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,15 +47,18 @@ fn position_overlay(w: &WebviewWindow) { let scale = monitor.scale_factor(); let msize = monitor.size(); let mpos = monitor.position(); - let Ok(wsize) = w.outer_size() else { return }; + // A window that has never been shown reports outer_size 0 — fall back to the + // configured inner size so the first placement isn't offset by half a pill. + let wsize = w + .outer_size() + .ok() + .filter(|s| s.width > 0 && s.height > 0) + .or_else(|| w.inner_size().ok()); + let Some(wsize) = wsize else { return }; let inset = (40.0 * scale) as i32; let x = mpos.x + (msize.width as i32 - wsize.width as i32) / 2; let y = mpos.y + msize.height as i32 - wsize.height as i32 - inset; let _ = w.set_position(tauri::PhysicalPosition { x, y }); - eprintln!( - "[whimpr] overlay placed: monitor {}x{} @({},{}) scale {:.1} -> window {}x{} @({},{})", - msize.width, msize.height, mpos.x, mpos.y, scale, wsize.width, wsize.height, x, y - ); } fn build_overlay(app: &tauri::App) -> tauri::Result { @@ -75,10 +78,11 @@ fn build_overlay(app: &tauri::App) -> tauri::Result { .skip_taskbar(true) .focused(false) .resizable(false) - .visible(true) + // Hidden at rest: the pill only exists while WhimprFlow is actually doing + // something (recording, cleaning up, flashing done, showing an error). The + // tray icon is the idle presence. See `emit_flowbar_state`. + .visible(false) .build()?; - position_overlay(&overlay); - let _ = overlay.show(); Ok(overlay) } @@ -91,10 +95,37 @@ fn build_hub(app: &tauri::App) -> tauri::Result { .build() } -fn emit_bar_state(app: &tauri::AppHandle, state: &'static str) { +/// Bar states where the pill window must exist. Idle (the rest state) hides it — +/// the overlay is invisible until a dictation actually starts. +fn bar_visible(state: &str) -> bool { + state != "idle" +} + +/// Emit a flow-bar state to the overlay AND toggle its window visibility. +/// +/// The single choke point every bar-state producer goes through (the macOS +/// state machine in `hotkey.rs`, the Windows pipeline in `win.rs`, the +/// diagnostics path in `diag.rs`, and the tray demo below), so the pill's +/// on-screen existence can never drift out of sync with the state it shows. +pub fn emit_flowbar_state(app: &tauri::AppHandle, state: &'static str) { let _ = app.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", BarStatePayload { state }); + if let Some(w) = app.get_webview_window(OVERLAY_LABEL) { + if bar_visible(state) { + // Re-anchor right before showing: the window may have never been + // mapped, or the screen layout may have changed while hidden. + position_overlay(&w); + let _ = w.show(); + eprintln!("[whimpr] pill -> {state} (overlay shown)"); + } else { + let _ = w.hide(); + eprintln!("[whimpr] pill -> {state} (overlay hidden)"); + } + } else { + eprintln!("[whimpr] pill -> {state} (no overlay window)"); + } } + #[tauri::command] fn get_settings() -> whimpr_core::Settings { hotkey::current_settings() @@ -150,6 +181,10 @@ struct StatusReport { microphone_grant: permissions::Grant, charged_to: Option, microphone_hint: Option, + /// Whether the global hotkey is actually live. On macOS this is false for + /// the stale-TCC-entry case: System Settings shows WhimprFlow as enabled, + /// but the keyboard tap can't be created for this build's signature. + hotkey_wired: bool, has_openai_key: bool, has_anthropic_key: bool, } @@ -164,6 +199,7 @@ fn get_status() -> StatusReport { microphone_grant: p.microphone_grant, charged_to: p.charged_to, microphone_hint: p.microphone_hint, + hotkey_wired: hotkey::tap_live(), has_openai_key: has_key("openai_api_key"), has_anthropic_key: has_key("anthropic_api_key"), } @@ -206,15 +242,64 @@ fn request_microphone() { open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"); } } +/// The one self-heal for every "Accessibility is wrong" case: clear any TCC +/// entry for our bundle id with `tccutil` (removes the stale entry a previous +/// build's code signature left behind — the case where System Settings shows +/// WhimprFlow as enabled but the running build is refused), re-fire the native +/// prompt (which re-registers us in the list), and open the Accessibility pane +/// so the user can enable WhimprFlow fresh. The tap thread in `hotkey.rs` +/// picks the new grant up the moment it lands — no relaunch needed. +#[cfg(target_os = "macos")] +pub(crate) fn reset_and_prompt_accessibility() -> Result<(), String> { + hotkey::mark_tap_stale(); + let out = std::process::Command::new("/usr/bin/tccutil") + .args(["reset", "Accessibility", "com.whimpr.whimprflow"]) + .output() + .map_err(|e| format!("failed to run tccutil: {e}"))?; + if !out.status.success() { + return Err(format!( + "tccutil failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + eprintln!( + "[whimpr] tccutil reset done: {}", + String::from_utf8_lossy(&out.stdout).trim() + ); + let _ = paste::prompt_accessibility(); + open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); + Ok(()) +} -/// Request Accessibility — the permission that makes the Fn key work in every app and -/// lets us type into other apps. Fire the native prompt, then open the pane. +/// Request Accessibility — the permission that makes the Fn key work in every +/// app and lets us type into other apps. Resets any stale entry first (a no-op +/// when the grant was never made), then prompts and opens the pane. #[tauri::command] fn request_accessibility() { #[cfg(target_os = "macos")] { - let _ = paste::prompt_accessibility(); - open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); + if let Err(e) = reset_and_prompt_accessibility() { + eprintln!("[whimpr] accessibility reset/prompt failed: {e}"); + } + } +} + +/// Fix the stale-Accessibility case: System Settings shows WhimprFlow as +/// enabled, but macOS is still enforcing the code signature of an earlier +/// build, so the Fn tap can't be created even though `AXIsProcessTrusted` +/// says yes (or, conversely, the app reads "not granted" while the pane shows +/// it on). Same self-heal as `request_accessibility`; kept as its own command +/// because the Hub presents it as a distinct "Fix" action. +#[tauri::command] +fn fix_accessibility() -> Result { + #[cfg(target_os = "macos")] + { + reset_and_prompt_accessibility()?; + Ok("reset".to_string()) + } + #[cfg(not(target_os = "macos"))] + { + Ok("unsupported".to_string()) } } @@ -265,6 +350,7 @@ pub fn run() { get_last_error, request_microphone, request_accessibility, + fix_accessibility, request_input_monitoring, set_api_key ]) @@ -307,8 +393,8 @@ pub fn run() { let _ = w.set_focus(); } } - "demo_rec" => emit_bar_state(app, "recording"), - "demo_idle" => emit_bar_state(app, "idle"), + "demo_rec" => emit_flowbar_state(app, "recording"), + "demo_idle" => emit_flowbar_state(app, "idle"), "quit" => app.exit(0), _ => {} }); diff --git a/src-tauri/src/win.rs b/src-tauri/src/win.rs index 25c45c4..602e263 100644 --- a/src-tauri/src/win.rs +++ b/src-tauri/src/win.rs @@ -41,9 +41,9 @@ const PTT_VK: u16 = VK_RCONTROL.0; static APP: OnceLock = OnceLock::new(); static CLOCK: OnceLock = OnceLock::new(); static RECORDING: AtomicBool = AtomicBool::new(false); -/// Set once at startup if no Whisper model file exists on disk at all — -/// distinct from "still loading". Mirrors the macOS flag in `hotkey.rs`. -static ASR_MODEL_MISSING: AtomicBool = AtomicBool::new(false); +/// True once the WH_KEYBOARD_LL hook is actually installed — the Windows +/// analogue of macOS's `TAP_LIVE`, surfaced to the Hub as `hotkey_wired`. +static HOOK_LIVE: AtomicBool = AtomicBool::new(false); static CAPTURE: OnceLock>> = OnceLock::new(); static ASR: OnceLock> = OnceLock::new(); static LOCAL: OnceLock>> = OnceLock::new(); @@ -90,14 +90,23 @@ fn now_ms() -> u64 { fn emit_bar(state: &'static str) { if let Some(app) = APP.get() { - #[derive(Clone, serde::Serialize)] - struct P { - state: &'static str, - } - let _ = app.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", P { state }); + // Shared emitter also toggles the overlay window: visible for every + // state except idle. + crate::emit_flowbar_state(app, state); } } +/// Whether the keyboard hook is live (see [`HOOK_LIVE`]). +pub fn tap_live() -> bool { + HOOK_LIVE.load(Ordering::SeqCst) +} + +/// Interface parity with the macOS layer; the hook install retries on its own, +/// so this is a no-op flag reset for future fix flows. +pub fn mark_tap_stale() { + HOOK_LIVE.store(false, Ordering::SeqCst); +} + /// The foreground process's executable name (e.g. "chrome.exe"), for per-app /// cleanup formatting — the Windows analogue of the macOS bundle id. fn foreground_app() -> Option { @@ -365,6 +374,7 @@ fn spawn_hook_thread() { eprintln!("[whimpr:win] keyboard hook recovered — Right Ctrl is live now."); crate::diag::clear_last_error(); } + HOOK_LIVE.store(true, Ordering::SeqCst); let _ = hook; // keeps the hook alive for the lifetime of this thread let mut msg = MSG::default(); while GetMessageW(&mut msg, HWND::default(), 0, 0).as_bool() {} diff --git a/ui/src/hub/App.tsx b/ui/src/hub/App.tsx index 7a6d9bd..66bbc67 100644 --- a/ui/src/hub/App.tsx +++ b/ui/src/hub/App.tsx @@ -17,6 +17,7 @@ import { getLastError, onPermissions, requestAccessibility, + fixAccessibility, type Settings, type Status, type LastError, @@ -155,6 +156,14 @@ export function App() { const refreshRef = useRef(refresh); refreshRef.current = refresh; + // Grace-tracked "wired" check: Accessibility can read as granted while the + // Fn tap is still dead (stale TCC entry from an earlier build). Only flag it + // after the tap thread has had a fair chance to spin up. + const [accSince, setAccSince] = useState(null); + useEffect(() => { + setAccSince((prev) => (status.accessibility ? (prev ?? Date.now()) : null)); + }, [status.accessibility]); + useEffect(() => { getSettings().then(setLocalSettings); refresh(); @@ -238,8 +247,16 @@ export function App() { // Two independent reasons for the post-onboarding banner: Accessibility // lapsed after entry (checked live against `status`, not just the one-time // onboarding gate), or the pipeline reported some other failure (hotkey tap - // dead, paste failed, empty transcript, …). + // dead, paste failed, empty transcript, …). A third case sits between them: + // Accessibility reads as granted but the tap never wired up (stale TCC + // entry), which needs the one-click Fix, not a re-grant. const accessibilityLapsed = entered && !status.accessibility; + const staleWired = + entered && + status.accessibility && + !status.hotkey_wired && + accSince !== null && + Date.now() - accSince > 10000; const banner = errorDismissed ? null : accessibilityLapsed @@ -249,9 +266,17 @@ export function App() { actionLabel: "Grant Accessibility", onAction: () => requestAccessibility(), } - : lastError - ? { headline: lastError.headline, detail: lastError.detail } - : null; + : staleWired + ? { + headline: "Fn key isn't wired up", + detail: + "macOS still holds a permission entry for an older build of WhimprFlow. Click Fix to clear it, then enable WhimprFlow again in the pane that opens.", + actionLabel: "Fix Accessibility", + onAction: () => void fixAccessibility(), + } + : lastError + ? { headline: lastError.headline, detail: lastError.detail } + : null; return (
(null); + useEffect(() => { + setAccSince((prev) => (acc ? (prev ?? Date.now()) : null)); + }, [acc]); + const staleGrant = + acc && !status.hotkey_wired && accSince !== null && Date.now() - accSince > 7000; + return (
+ {staleGrant && ( +
+
+ Accessibility looks granted, but the Fn key still isn't wired up. macOS is + enforcing the permission of an older build of WhimprFlow. Click Fix to clear it, then + enable WhimprFlow again in the Accessibility pane that opens — no relaunch needed. +
+ +
+ )} +

- If a permission stays grey after you flip it on in System Settings, toggle WhimprFlow off - and back on in that pane — the state here will update within a second. + If Accessibility stays grey even though System Settings shows it enabled, click Grant + again — WhimprFlow clears macOS's stale entry for older builds automatically and + re-prompts.

diff --git a/ui/src/hub/api.ts b/ui/src/hub/api.ts index b120a09..4e28253 100644 --- a/ui/src/hub/api.ts +++ b/ui/src/hub/api.ts @@ -32,6 +32,9 @@ export interface Status { // One sentence saying why the microphone row can't go green, when there's // something the reader couldn't otherwise have known. Null when there isn't. microphone_hint: string | null; + /** True once the global hotkey (Fn tap / Right Ctrl hook) is actually live — + * false for the macOS stale-TCC case where "granted" isn't really working. */ + hotkey_wired: boolean; has_openai_key: boolean; has_anthropic_key: boolean; } @@ -45,6 +48,7 @@ export const UNKNOWN_STATUS: Status = { microphone_grant: "not_asked", charged_to: null, microphone_hint: null, + hotkey_wired: false, has_openai_key: false, has_anthropic_key: false, }; @@ -138,6 +142,18 @@ export async function onPermissions(cb: (p: Permissions) => void): Promise<() => } } +/** + * Fix the macOS stale-Accessibility case: reset the TCC entry, re-prompt, and + * open the Accessibility pane so the user can enable WhimprFlow fresh. + */ +export async function fixAccessibility(): Promise { + try { + await invoke("fix_accessibility"); + } catch { + /* browser preview */ + } +} + // The most recent loud diagnostic from the dictation pipeline (permission // missing, hotkey tap dead, paste failed, empty transcript, …). Mirrors // `diag::ErrorDto` in src-tauri/src/diag.rs. From 893b10f39a341715c9d1feaa2849a8031aca1d0e Mon Sep 17 00:00:00 2001 From: Iamdk25 Date: Tue, 1 Sep 2026 17:09:39 -0400 Subject: [PATCH 2/2] fix: wire the on-device cleanup worker into dev and release builds The local Qwen cleanup worker was never reachable outside a hand-built target/release: the release .app didn't bundle it, and the dev fallback only looked at target/release while tauri dev builds target/debug. - worker_bin_path now checks both profiles in dev - dev.sh builds the worker before launching tauri dev - build-macos.sh builds the worker, copies it next to the app executable (worker_bin_path's first lookup), and re-seals the bundle signature --- dev.sh | 5 ++++- scripts/build-macos.sh | 11 +++++++++++ src-tauri/src/local_llm.rs | 25 +++++++++++++++++-------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/dev.sh b/dev.sh index f861e49..8212681 100755 --- a/dev.sh +++ b/dev.sh @@ -1,6 +1,9 @@ #!/bin/bash -# Run WhimprFlow in development: starts the Vite UI server + the app with hot reload. +# Run WhimprFlow in development: builds the local-LLM worker (tauri dev only +# builds the app crate), then starts the Vite UI server + the app with hot reload. # The app loads its UI from the dev server, so the pill actually renders. set -e cd "$(dirname "$0")" +echo "[dev] building the local-LLM worker…" +cargo build -p whimpr-llm-worker exec ui/node_modules/.bin/tauri dev "$@" diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index d7bcda7..2829c0f 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -98,6 +98,13 @@ export APPLE_SIGNING_IDENTITY="$IDENTITY" BUILD_ARGS=(build) [ -n "$TARGET" ] && BUILD_ARGS+=(--target "$TARGET") +# The worker is NOT an externalBin (tauri-build demands a triple-suffixed file +# name that breaks dev builds); instead, drop it next to the app executable — +# `worker_bin_path()` checks that location first — sign it, and re-seal the +# bundle so Gatekeeper/notarization stay intact. +echo "==> Building the local-LLM worker…" +cargo build --release -p whimpr-llm-worker + cd "$REPO_ROOT/src-tauri" "$TAURI" "${BUILD_ARGS[@]}" @@ -105,6 +112,10 @@ TARGET_DIR="$REPO_ROOT/target" [ -n "$TARGET" ] && TARGET_DIR="$TARGET_DIR/$TARGET" APP="$TARGET_DIR/release/bundle/macos/WhimprFlow.app" DMG="$(/usr/bin/find "$TARGET_DIR/release/bundle/dmg" -name "*.dmg" -print -quit 2>/dev/null || true)" +WORKER_DEST="$APP/Contents/MacOS/whimpr-llm-worker" +cp "$REPO_ROOT/target/release/whimpr-llm-worker" "$WORKER_DEST" +codesign --force --sign "$IDENTITY" "$WORKER_DEST" +codesign --force --sign "$IDENTITY" "$APP" [ -d "$APP" ] || { echo "No WhimprFlow.app was produced." >&2; exit 1; } diff --git a/src-tauri/src/local_llm.rs b/src-tauri/src/local_llm.rs index 21d2672..1ca60da 100644 --- a/src-tauri/src/local_llm.rs +++ b/src-tauri/src/local_llm.rs @@ -85,21 +85,30 @@ pub fn worker_bin_path() -> Option { } } } - // Dev fallback. + // Dev fallback: `tauri dev` builds the app in `target/debug`, and `dev.sh` + // builds the worker there too — check both profiles. #[cfg(target_os = "windows")] { - let dev = std::env::current_dir() - .unwrap_or_default() - .join("target/release") - .join(exe_name); - return dev.exists().then_some(dev); + let base = std::env::current_dir().unwrap_or_default(); + for profile in ["release", "debug"] { + let dev = base.join("target").join(profile).join(exe_name); + if dev.exists() { + return Some(dev); + } + } } #[cfg(not(target_os = "windows"))] { let home = std::env::var("HOME").unwrap_or_default(); - let dev = PathBuf::from(home).join("WhimprFlow/target/release/whimpr-llm-worker"); - dev.exists().then_some(dev) + for profile in ["release", "debug"] { + let dev = + PathBuf::from(&home).join(format!("WhimprFlow/target/{profile}/{exe_name}")); + if dev.exists() { + return Some(dev); + } + } } + None } /// The local cleanup model path (same models dir as whisper/ASR). Prefer the