Skip to content

Conversation

@dannysmith
Copy link
Owner

@dannysmith dannysmith commented Dec 1, 2025

  1. fn key as trigger (macOS) - Use the fn/Globe key to start/stop recording
  2. Escape to cancel recording - Cancel mid-recording without transcribing (most useful when PTT is off)

Feature 1: fn Key Support (macOS)

How it works

The fn/Globe key is a modifier key that generates NSEventType::FlagsChanged events with NSEventModifierFlags::Function. Standard shortcut libraries (like tauri-plugin-global-shortcut) cannot capture modifier-only keys.

Implementation

Parallel input system for fn key:

  1. src-tauri/src/shortcut/fn_monitor.rs (macOS-only)

    • Uses NSEvent::addGlobalMonitorForEventsMatchingMask_handler from objc2
    • Monitors for NSEventMask::FlagsChanged events
    • Checks NSEventModifierFlags::Function to detect fn press/release
    • Calls the same dispatch_binding_event() function as regular shortcuts
  2. src-tauri/src/shortcut/mod.rs

    • Routes "fn" bindings to fn_monitor instead of tauri-plugin-global-shortcut
    • is_fn_binding() helper checks for fn-only bindings
    • validate_shortcut_string() allows "fn" as valid on macOS
  3. Dependencies (macOS only in Cargo.toml):

    [target.'cfg(target_os = "macos")'.dependencies]
    objc2 = "0.6"
    objc2-app-kit = { version = "0.3", features = ["NSEvent"] }
    objc2-foundation = "0.3"
    block2 = "0.6"
  4. Frontend (optional): "Use fn" button in HandyShortcut.tsx - see Reviewer Notes

Permissions

Requires Accessibility permission (same as already needed for enigo pasting). No additional permission prompts for users.


Feature 2: Escape to Cancel Recording

How it works

The cancel shortcut is a dynamic binding - only registered while recording is active.

Implementation

  1. CancelAction in actions.rs:

    • Calls cancel_current_operation() to discard recording
    • Does NOT unregister itself (would deadlock inside callback)
  2. Cancel binding in settings.rs:

    • dynamic: true - not registered at startup
    • Default binding: "Escape"
  3. Dynamic registration in shortcut/mod.rs:

    • register_dynamic_binding() - idempotent (unregisters first if already registered)
    • unregister_dynamic_binding() - removes binding at runtime
    • init_shortcuts() skips dynamic bindings
  4. Lifecycle:

    • TranscribeAction::start() registers cancel via run_on_main_thread()
    • TranscribeAction::stop() unregisters cancel via run_on_main_thread()
    • CancelAction::start() does NOT unregister (next registration handles cleanup)

Key design decisions

Why idempotent registration?

Unregistering from inside the shortcut's own callback causes a deadlock (global_shortcut holds internal locks). Instead, register_dynamic_binding() unregisters first, so CancelAction doesn't need to unregister itself.

Why release toggle lock before calling action?

dispatch_binding_event() releases the toggle state lock BEFORE calling action.start()/stop(). This prevents deadlock when CancelAction calls cancel_current_operation() which also needs the lock.

Linux Notes

Dynamic shortcut registration (used for the cancel shortcut) is disabled on Linux due to
instability with the tauri-plugin-global-shortcut plugin. See PR cjpais#392.

This means the Escape-to-cancel feature is not available on Linux. The cancel shortcut will
silently be a no-op.

Potential future improvement: The dynamic binding architecture in this branch provides
a cleaner foundation than the original async-spawn approach. If the underlying Linux shortcut
issues are resolved upstream, enabling dynamic bindings on Linux would only require removing
the #[cfg(target_os = "linux")] guards in register_dynamic_binding() and
unregister_dynamic_binding() in shortcut/mod.rs.

Reviewer Notes: fn Key UI

The fn key backend works regardless of UI changes. Users can always configure fn key manually
by editing settings_store.json. Eg:

    "bindings": {
      "transcribe": {
        "current_binding": "fn",
        "default_binding": "option+space",
        "description": "Converts your speech into text.",
        "dynamic": false,
        "id": "transcribe",
        "name": "Transcribe"
      }
    }

UI Visibility Option

The commit "Remove fn key UI (manual config only)" removes the "Use fn" button from the
settings UI. This commit is structured to be easily included or excluded:

Decision Action
Keep fn key as manual-config only Keep the commit as-is
Add "Use fn" button to UI Revert or drop the commit

The UI additions that commit removes:

  • isFnBinding() helper function
  • "Use fn" button (shown on macOS when not already using fn)
  • "fn (Globe)" display text when fn is the current binding
  • Tooltip text explaining fn key usage

Reference PRs

  • PR #136 - Original fn key implementation (tekacs)
  • PR #224 - Cancel shortcut approach (jacksongoode)
  • PR #392 - Disable cancel on Linux (stability fix)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added dynamic shortcut bindings that activate only when needed, such as the cancel shortcut during recording
    • Added support for Function key bindings on macOS
    • Added Accessibility permission management for macOS
  • Bug Fixes

    • Improved cancellation flow to properly discard recordings without triggering unnecessary operations
  • Improvements

    • Added helpful tooltip hints for shortcut elements in settings

✏️ Tip: You can customize this high-level summary in your review settings.

Add support for using the fn/Globe key as a shortcut trigger on macOS.
The fn key is a modifier-only key that cannot be captured by standard
shortcut libraries like tauri-plugin-global-shortcut.

Implementation:
- Add fn_monitor.rs using NSEvent.addGlobalMonitorForEventsMatchingMask
  to detect fn key press/release via FlagsChanged events
- Add routing layer (register_binding/unregister_binding) to direct
  fn bindings to fn_monitor and regular bindings to global-shortcut
- Add dispatch_binding_event to share action dispatch logic between
  regular shortcuts and fn key
- Allow "fn" as valid binding in validate_shortcut_string (macOS only)
- Remove unused rdev dependency, add objc2 dependencies for macOS

The fn key requires Accessibility permission, which is already needed
for the enigo pasting functionality.

Limitations:
- fn key events stop when Secure Input is active (password fields)
- fn+key combinations conflict with system shortcuts; fn alone is safe
Cancel recording mid-transcription with Escape key.

- Dynamic bindings: only registered while recording is active
- Disabled on Linux due to global-shortcut plugin instability
- Idempotent registration avoids callback deadlocks
Add UI elements for the fn key shortcut on macOS:
- "Use fn" button to quickly set fn/Globe key as shortcut
- "fn (Globe)" display when fn key is active
- Tooltips explaining fn key usage
The fn key shortcut still works on macOS, but users must configure it
manually by editing settings.json and setting current_binding to "fn".

This commit can be reverted to restore the "Use fn" button in the UI.
@coderabbitai
Copy link

coderabbitai bot commented Dec 1, 2025

Walkthrough

This change introduces dynamic shortcut binding registration for macOS, enabling bindings (particularly the cancel shortcut) to be registered and unregistered at runtime rather than at startup. A new macOS-specific fn-key monitor module provides accessibility permission checks and fn-key event handling via Objective-C bindings. The binding lifecycle is refactored to support this dynamic registration flow.

Changes

Cohort / File(s) Summary
macOS fn-key monitoring infrastructure
src-tauri/src/shortcut/fn_monitor.rs
New module providing global fn-key event monitoring on macOS with accessibility permission checks (via AXIsProcessTrustedWithOptions), fn binding registration/unregistration, and FFI integration to NSEvent for event capture and dispatch.
Dynamic binding support & core routing
src-tauri/src/shortcut/mod.rs, src-tauri/src/settings.rs, src/bindings.ts
Added dynamic: bool field to ShortcutBinding type; introduced public APIs register_dynamic_binding and unregister_dynamic_binding in shortcut module; refactored init flow to skip dynamic bindings at startup; added binding-specific registration paths that delegate fn-key bindings to the monitor on macOS; centralised dispatch mechanism via dispatch_binding_event.
Binding lifecycle & action orchestration
src-tauri/src/actions.rs, src-tauri/src/utils.rs
Replaced hard unregister with asynchronous dynamic binding registration/unregistration on transcription start/stop; refactored cancel operation to discard recording without triggering transcription; improved logging consistency across action start/stop paths.
Dependencies & build configuration
src-tauri/Cargo.toml
Removed rdev dependency; added macOS-specific crates objc2, objc2-app-kit, objc2-foundation, and block2 to support Objective-C FFI for event monitoring.
UI enhancements
src/components/settings/HandyShortcut.tsx
Added title attribute to shortcut element for user-facing tooltip indicating clickable interaction.

Sequence Diagram

sequenceDiagram
    actor User
    participant TranscribeAction as TranscribeAction<br/>(start)
    participant MainThread as Main Thread<br/>Dispatcher
    participant FnMonitor as Fn-Key Monitor
    participant macOS as macOS/AX API
    participant ActionDispatch as Action<br/>Dispatcher

    User->>TranscribeAction: Start recording
    TranscribeAction->>MainThread: Schedule dynamic binding<br/>registration (cancel)
    MainThread->>FnMonitor: register_fn_binding(cancel)
    FnMonitor->>macOS: check_accessibility_permission()
    macOS-->>FnMonitor: Permission result
    FnMonitor->>macOS: addGlobalMonitorForEventsMatchingMask<br/>(FlagsChanged)
    FnMonitor-->>MainThread: Binding registered ✓
    MainThread-->>TranscribeAction: Dynamic binding active

    rect rgba(100, 200, 100, 0.2)
        Note over User,ActionDispatch: Recording in progress
        User->>macOS: Press Fn key
        macOS->>FnMonitor: FlagsChanged event
        FnMonitor->>FnMonitor: Detect Fn press/release
        FnMonitor->>ActionDispatch: dispatch_binding_event(cancel)
        ActionDispatch-->>User: Cancel signal sent
    end

    User->>TranscribeAction: Stop recording
    TranscribeAction->>MainThread: Schedule dynamic binding<br/>unregistration (cancel)
    MainThread->>FnMonitor: unregister_fn_binding(cancel)
    FnMonitor->>macOS: Remove event monitor
    FnMonitor-->>MainThread: Binding unregistered ✓
    MainThread-->>TranscribeAction: Dynamic binding inactive
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–60 minutes

Areas requiring particular attention:

  • src-tauri/src/shortcut/fn_monitor.rs — New FFI code with Objective-C integration; verify accessibility permission handling, event monitor lifecycle, thread-safety of shared state (Mutex guards), and correct Objective-C memory management patterns.
  • src-tauri/src/shortcut/mod.rs — Substantial refactoring of binding registration flow; check routing logic for fn-key bindings on macOS versus other platforms, dynamic binding skip logic during init, and dispatch mechanism thread-safety.
  • src-tauri/src/actions.rs — Cross-module binding with dynamic registration/unregistration on transcription lifecycle; verify main-thread orchestration correctness and async sequencing to avoid deadlocks.
  • Dynamic binding lifecycle — Trace end-to-end flow from registration (start transcription) through event dispatch to unregistration (stop transcription) to ensure proper cleanup and no resource leaks.

Poem

🐰 A monitor is born upon the macOS shore,
Fn keys dance with Accessibility's door,
Dynamic bindings spring to life when recording starts,
Then gracefully unwind when transcription departs—
Shortcuts now listen when needed, no more, no more! 🎙️✨

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Keyboard shortcuts clean' is vague and generic, using non-descriptive language that does not clearly convey the main changes (fn key support and dynamic cancel binding). Consider using a more specific title such as 'Add fn key support and dynamic Escape-to-cancel binding' to better reflect the primary changes.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch keyboard-shortcuts-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src-tauri/src/actions.rs (1)

263-270: Dynamic cancel binding registration on main thread is appropriate.

Using run_on_main_thread for shortcut registration is correct. However, there's a brief window between recording_started being true and the cancel binding being registered where pressing Escape won't work.

Consider whether the registration should happen synchronously before recording_started is set, or document this expected behaviour. For most use cases, this window is negligible.

src-tauri/src/shortcut/mod.rs (1)

108-113: Redundant clone in cancel binding return.

b is already a cloned value from line 104. The b.clone() on line 110 is unnecessary.

             return Ok(BindingResponse {
                 success: true,
-                binding: Some(b.clone()),
+                binding: Some(b),
                 error: None,
             });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 244a99e and be3091c.

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • src-tauri/Cargo.toml (1 hunks)
  • src-tauri/src/actions.rs (15 hunks)
  • src-tauri/src/settings.rs (5 hunks)
  • src-tauri/src/shortcut/fn_monitor.rs (1 hunks)
  • src-tauri/src/shortcut/mod.rs (22 hunks)
  • src-tauri/src/utils.rs (1 hunks)
  • src/bindings.ts (1 hunks)
  • src/components/settings/HandyShortcut.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src-tauri/src/settings.rs (2)
src/bindings.ts (1)
  • ShortcutBinding (621-627)
src-tauri/src/commands/mod.rs (1)
  • get_default_settings (36-38)
src-tauri/src/shortcut/fn_monitor.rs (2)
src/bindings.ts (1)
  • ShortcutBinding (621-627)
src-tauri/src/shortcut/mod.rs (4)
  • app (881-881)
  • binding (915-915)
  • binding (949-949)
  • dispatch_binding_event (854-908)
src-tauri/src/utils.rs (5)
src-tauri/src/actions.rs (5)
  • app (210-210)
  • app (217-217)
  • app (292-292)
  • app (293-293)
  • app (294-294)
src-tauri/src/shortcut/mod.rs (1)
  • app (881-881)
src-tauri/src/tray.rs (3)
  • app (59-59)
  • app (137-137)
  • change_tray_icon (58-75)
src-tauri/src/commands/audio.rs (2)
  • app (51-51)
  • app (102-102)
src-tauri/src/overlay.rs (1)
  • hide_recording_overlay (269-282)
src-tauri/src/actions.rs (2)
src-tauri/src/shortcut/mod.rs (3)
  • app (881-881)
  • register_dynamic_binding (789-824)
  • unregister_dynamic_binding (830-850)
src-tauri/src/utils.rs (3)
  • app (25-25)
  • app (34-34)
  • cancel_current_operation (18-48)
src-tauri/src/shortcut/mod.rs (4)
src-tauri/src/settings.rs (5)
  • get_settings (568-584)
  • get_default_settings (427-496)
  • load_or_create_app_settings (521-566)
  • serde_json (529-529)
  • serde_json (574-574)
src-tauri/src/actions.rs (5)
  • app (210-210)
  • app (217-217)
  • app (292-292)
  • app (293-293)
  • app (294-294)
src-tauri/src/utils.rs (2)
  • app (25-25)
  • app (34-34)
src-tauri/src/shortcut/fn_monitor.rs (2)
  • register_fn_binding (105-131)
  • unregister_fn_binding (134-155)
🔇 Additional comments (23)
src/components/settings/HandyShortcut.tsx (1)

305-305: Good UX improvement.

Adding a tooltip helps users understand that the shortcut is clickable for recording a new binding.

src-tauri/src/utils.rs (1)

13-48: Well-structured cancellation flow with clear documentation.

The ordering (cancel recording → remove mute → reset toggles → UI updates) is correct and the comments clearly explain why action.stop() is intentionally avoided to prevent triggering transcription. The sequencing ensures safe state transitions.

src/bindings.ts (1)

621-627: Type definition correctly mirrors the Rust struct.

The optional dynamic?: boolean field aligns with the Rust #[serde(default)] pub dynamic: bool in settings.rs. Since this file is auto-generated by tauri-specta, ensure the Rust source is the authoritative definition.

src-tauri/src/settings.rs (2)

83-88: Dynamic binding field correctly implemented with backwards compatibility.

The #[serde(default)] attribute ensures existing settings without this field will deserialize correctly, defaulting to false. The documentation clearly explains the purpose.


449-458: Cancel binding defaults look correct.

The cancel binding is properly configured with dynamic: true so it won't be registered at startup, and "Escape" is a sensible default key for cancellation.

src-tauri/src/actions.rs (3)

283-289: Unregistration on stop aligns with lifecycle.

The cancel binding is correctly unregistered when transcription completes. The error is logged at debug level which is appropriate since failure to unregister is non-critical (the binding becomes stale but will be cleaned up on next registration).


75-77: Log formatting improvement.

The braced variable formatting is cleaner and consistent with Rust 2021 edition style.


426-443: Deadlock avoidance pattern in CancelAction is correct.

The implementation properly avoids self-unregistration deadlock by relying on register_dynamic_binding's idempotent behavior. When register_dynamic_binding is called (during the next transcription start), it internally calls unregister_binding with errors explicitly ignored (line 820: let _ = unregister_binding(app, binding.clone());), which safely cleans up any stale cancel binding registration. This pragmatic approach eliminates the deadlock risk from within-callback unregistration while ensuring proper cleanup on the next binding registration.

src-tauri/Cargo.toml (1)

94-97: macOS Objective-C bindings are correctly configured and use current versions.

The dependencies are properly scoped to the macOS target and the NSEvent feature is correctly enabled for objc2-app-kit. The version constraints (0.6 for objc2 and block2, 0.3 for objc2-app-kit and objc2-foundation) use semantic versioning and will automatically include current patch releases (objc2 0.6.1, objc2-app-kit 0.3.2, objc2-foundation 0.3.2, and block2 0.6.2), ensuring compatibility with the fn-key monitoring functionality.

src-tauri/src/shortcut/fn_monitor.rs (7)

1-14: Well-documented module with clear limitations.

The architecture section and known limitations (Secure Input behaviour, fn+key conflicts) are valuable for future maintainers.


45-59: FFI accessibility check implementation looks correct.

The use of NSDictionary::from_slices for building the options dictionary and casting via Retained::as_ptr follows the objc2 idioms correctly.


86-92: Handler field retention is correct.

The handler field with #[allow(dead_code)] is necessary to keep the RcBlock alive for the duration of the monitor. Without it, the callback would be deallocated. Good use of the annotation to document intent.


103-131: Registration flow is correct.

The function properly ensures the monitor is started before adding the binding. The HashMap::insert semantics provide idempotent behaviour if the same binding is registered twice.


133-155: Idempotent unregistration with proper state cleanup.

Resetting fn_pressed when no bindings remain (line 148) prevents stale state from affecting future bindings. The unused _app parameter maintains API symmetry with register_fn_binding.


157-243: Main thread synchronisation is correctly implemented.

The potential race between checking MONITOR_STARTED.load() and the main thread installation is safely handled: the main thread closure performs a secondary check via handle.monitor_token.is_some() (line 187), ensuring only one monitor is installed. The channel-based result passing is the correct pattern for cross-thread communication with Tauri's run_on_main_thread.


245-293: Event processing with proper lock discipline.

The pattern of cloning bindings while holding the lock (line 269), then releasing it before dispatching (line 270), avoids holding the mutex during potentially slow action callbacks. This prevents deadlocks if actions need to acquire locks themselves.

src-tauri/src/shortcut/mod.rs (7)

15-21: Clean routing helper for fn binding detection.

The is_fn_binding function provides a clear abstraction for identifying fn-key-only bindings, keeping the routing logic in register_binding/unregister_binding readable.


23-46: Binding routing correctly separates fn and regular shortcuts.

The cfg-gated routing to fn_monitor on macOS with fallback to standard shortcut handling maintains clean separation of concerns.


53-68: Dynamic binding exclusion during init is correct.

Skipping dynamic bindings at startup (lines 61-64) and deferring their registration to runtime aligns with the documented lifecycle for bindings like the cancel shortcut.


728-735: Platform-specific fn validation is correctly implemented.

The cfg-gated validation returns success on macOS and a clear error message on other platforms, preventing accidental use of fn bindings where unsupported.


778-824: Idempotent dynamic registration prevents deadlocks.

The design decision to unregister before registering (line 820) allows safe re-registration from within action callbacks without explicit cleanup. The detailed doc comment explaining the deadlock avoidance rationale is helpful.


876-893: Lock discipline in toggle mode prevents deadlocks.

The explicit scoping to release the lock before calling action methods (comment at line 893) is crucial, as documented. This pattern correctly prevents deadlocks when actions need to acquire locks themselves.


910-945: Internal shortcut registration with proper validation.

The duplicate registration check (line 924-927) and human-friendly validation before parsing provide good error messages. The underscore prefix convention clearly marks this as an internal implementation detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants