Skip to content
Closed
14 changes: 11 additions & 3 deletions .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,17 @@ genuinely correct, and reaching for it means it's time to stop and ask.
menu bar, so nothing may depend on it being visible. A menu-bar-_only_ variant
(no Dock icon) was tried and reverted twice for that reason — don't drop the
Dock icon or add `LSUIElement`.
- The dictation trigger is a **single lone modifier** (right ⌘ default), home-
grown via `CGEventTap` + `DictationKeyGate`. No `KeyboardShortcuts` package, no
key+modifier chord.
- The dictation trigger is a **single control** — a lone right-side modifier
(right ⌘ default, and `fn` is no longer an option), or a Custom **chord**
(⌃⌥D) or extra mouse button — home-grown via `CGEventTap` +
`DictationKeyRouter`/`DictationKeyGate`. No `KeyboardShortcuts` package, and no
SPM dependency for key handling: that ban is unchanged. Chords used to be
banned here too and are now supported (Alex Kroman reversed it in the PR #144
thread). What stays refused: a **bare** key and the left/right mouse button
(the tap is listen-only and swallows nothing, so they'd type or click into the
focused app), plus a short list of system chords like ⌘Q and ⌘⇥. A bound chord
or wheel click still reaches the app that owns it — say so in the UI
(`TriggerBinding.passThroughNote`) rather than switching to an active tap.
- **Updates are download-only** — check → open the DMG in the browser → the user
installs it. The `mxcl/AppUpdater` dependency and its in-place self-updater
were removed; don't reintroduce a self-replacing install path, a timer-driven
Expand Down
144 changes: 99 additions & 45 deletions AGENTS.md

Large diffs are not rendered by default.

161 changes: 101 additions & 60 deletions App/Blurt/Blurt/Hotkey/DictationKeyTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,30 @@ import BlurtEngine
import CoreGraphics
import os

/// Drives the single lone-modifier dictation trigger from a `CGEventTap`.
/// Drives the single-key dictation trigger from a `CGEventTap`.
///
/// Watches `flagsChanged` for the bound modifier (e.g. right ⌘, keycode 54) to
/// detect down/up, and `keyDown` for any *other* key to spot a modifier combo
/// (⌘C, ⌘V…). The per-event decision lives in the engine — `DictationKeyRouter`
/// (keycode relevance + down/up edge dedup) over `DictationKeyGate` (tap/hold
/// semantics) — so this type only reduces each `CGEvent` to a router event and
/// owns the tap lifecycle.
/// The bound trigger is a `TriggerBinding`: a lone modifier (e.g. right ⌘,
/// keycode 54) watched via `flagsChanged`, a keyboard chord (⌃⌥D) watched via
/// `keyDown`/`keyUp` plus the modifier set each event reports, or an extra mouse
/// button watched via `otherMouseDown`/`otherMouseUp`. `keyDown` is also watched
/// for any *other* key to spot a modifier combo (⌘C, ⌘V…). The per-event decision lives in the
/// engine — `DictationKeyRouter` (binding relevance + down/up edge dedup) over
/// `DictationKeyGate` (tap/hold semantics) — so this type only reduces each
/// `CGEvent` to a router event and owns the tap lifecycle. The mask covers
/// every family the router can care about regardless of the current binding
/// (a tap's mask is fixed at creation, and the router ignores irrelevant
/// events), so rebinding never has to recreate the tap.
///
/// Unlike the old chord trigger, this **swallows nothing**: a lone modifier
/// types nothing into the focused app, and combos must pass through so normal
/// shortcuts keep working. The tap is therefore created `.listenOnly` — an
/// active (`.defaultTap`) tap would make macOS synchronously wait on this
/// process before delivering every keystroke system-wide, so any main-thread
/// stall in Blurt would add typing latency in *other* apps.
/// This **swallows nothing**, by design: a lone modifier or extra mouse button
/// types and clicks nothing into the focused app, and combos must pass through
/// so normal shortcuts keep working. The tap is
/// therefore created `.listenOnly` — an active (`.defaultTap`) tap would make
/// macOS synchronously wait on this process before delivering every keystroke
/// system-wide, so any main-thread stall in Blurt would add typing latency in
/// *other* apps. Two consequences the UI states rather than hides: the Custom
/// recorder refuses a **bare** key (it would type on every dictation), and a
/// bound **chord** still reaches an app that already owns it — see
/// `TriggerBinding.passThroughNote`.
///
/// Main-actor (via the app target's default isolation) because everything here
/// already runs on the main thread: the tap's run-loop source is added to the
Expand All @@ -40,12 +49,15 @@ final class DictationKeyTap {
/// a transcript already in flight — see `DictationSession.cancelRecording`.
private let onRecordingDiscarded: @Sendable () -> Void

/// The engine-side event router (keycode relevance, down/up edge dedup, and
/// The engine-side event router (binding relevance, down/up edge dedup, and
/// the gate's tap/hold state machine — all unit-tested in BlurtEngine).
/// Seeded from the persisted binding in `init` — see the note there.
private var router: DictationKeyRouter
/// The bound key's device-dependent `CGEventFlags` bit — the one CoreGraphics-
/// typed piece of the binding, so it stays here rather than in the router.
/// The bound modifier's device-dependent `CGEventFlags` bit — the one
/// CoreGraphics-typed piece of the binding, so it stays here rather than in
/// the router. Empty for chord and mouse bindings, whose down/up state rides
/// their own event types rather than one device bit (a chord reads the generic
/// modifier masks, which the decoder resolves per event).
private var triggerFlag: CGEventFlags

/// Monotonic reference; per-event timestamps are `reference.duration(to: now)`.
Expand All @@ -68,15 +80,15 @@ final class DictationKeyTap {
self.onCancel = onCancel
self.onRecordingDiscarded = onRecordingDiscarded
// Both halves of the binding come from the store, not a hard-coded
// `.rightCommand`: `TriggerKey.fromPersisted` owns the unset default, and
// restating it here is the same mistake `BoundTriggerKey` and `HotkeyStepView`
// `.rightCommand`: `TriggerBinding.fromPersisted` owns the unset default, and
// restating it here is the same mistake `BoundTriggerBinding` and `HotkeyStepView`
// were each corrected away from — the tap would name the old key while the
// picker, ready screen, and menu bar all named the new one. `refreshBinding()`
// re-reads this, but nothing enforces that it runs before the first read of
// either property (`simulatePressForTesting` reads `router.triggerKeyCode`).
let key = TriggerKeyStore().triggerKey
self.router = DictationKeyRouter(triggerKeyCode: key.keyCode)
self.triggerFlag = Self.flag(for: key)
// either property (`simulatePressForTesting` reads `router.binding`).
let binding = TriggerKeyStore().triggerBinding
self.router = DictationKeyRouter(binding: binding)
self.triggerFlag = Self.flag(for: binding)
}

deinit {
Expand All @@ -102,7 +114,10 @@ final class DictationKeyTap {
CGEvent.tapEnable(tap: tap, enable: true)
return true
}
let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
let mask =
(1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
| (1 << CGEventType.flagsChanged.rawValue)
| (1 << CGEventType.otherMouseDown.rawValue) | (1 << CGEventType.otherMouseUp.rawValue)
guard
let created = CGEvent.tapCreate(
tap: .cgSessionEventTap,
Expand Down Expand Up @@ -142,8 +157,8 @@ final class DictationKeyTap {
/// Resets unconditionally, unlike the disabled-tap recovery above. That guard
/// exists to preserve a *live* recording whose key events were dropped; here the
/// dictation is already over, so there is no state worth keeping even if the
/// trigger is still physically held — a later key-up just finds
/// `modifierIsDown == false` and routes to `.none`.
/// trigger is still physically held — a later key-up just finds the router's
/// down-tracker cleared and routes to `.none`.
///
/// Acts on `reset()`'s discarded-recording result the same way `refreshBinding`
/// does. In the stale case the session is already terminal, so the
Expand All @@ -160,14 +175,14 @@ final class DictationKeyTap {
if router.reset() { onRecordingDiscarded() }
}

/// Re-read the bound trigger key into the router. Call after the user
/// rebinds. The router's reset reports a discarded live recording: rebinding
/// mid-dictation means the old key's up-event will never match, so the capture
/// must be cancelled, not left to run out the auto-release cap.
/// Re-read the bound trigger into the router. Call after the user rebinds.
/// The router's reset reports a discarded live recording: rebinding
/// mid-dictation means the old trigger's up-event will never match, so the
/// capture must be cancelled, not left to run out the auto-release cap.
func refreshBinding() {
let key = TriggerKeyStore().triggerKey
triggerFlag = Self.flag(for: key)
if router.rebind(triggerKeyCode: key.keyCode) { onRecordingDiscarded() }
let binding = TriggerKeyStore().triggerBinding
triggerFlag = Self.flag(for: binding)
if router.rebind(binding: binding) { onRecordingDiscarded() }
}

/// Callback entry point (always on the main thread — the tap's source lives on
Expand All @@ -180,29 +195,43 @@ final class DictationKeyTap {
// state survives that is the router's call (and unit-tested there); the only
// part that has to happen here is the CoreGraphics read of whether the
// trigger is physically held right now.
let triggerStillHeld = CGEventSource.flagsState(.combinedSessionState).contains(triggerFlag)
if router.recoverFromDroppedEvents(triggerStillHeld: triggerStillHeld) {
if router.recoverFromDroppedEvents(triggerStillHeld: triggerStillHeld()) {
onRecordingDiscarded()
}
return
}

guard let routed = routerEvent(type: type, event: event) else { return }
// The CGEvent -> router-event reduction lives in the engine
// (`DictationEventDecoder`) so it can be exercised with real CGEvent
// fixtures; this shim only owns the tap lifecycle around it.
guard
let routed = DictationEventDecoder.routerEvent(
type: type, event: event, triggerFlag: triggerFlag)
else { return }
let now = reference.duration(to: ContinuousClock.now)
dispatch(router.handle(routed, at: now))
}

/// Reduces a `CGEvent` to the router's CoreGraphics-free event shape, or nil
/// for event types the trigger doesn't care about.
private func routerEvent(type: CGEventType, event: CGEvent) -> DictationKeyRouter.Event? {
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
switch type {
case .flagsChanged:
return .flagsChanged(keyCode: keyCode, triggerFlagIsOn: event.flags.contains(triggerFlag))
case .keyDown:
return .keyDown(keyCode: keyCode)
default:
return nil
/// The CoreGraphics read behind dropped-event recovery: is the bound trigger
/// physically down right now? Each binding family has its own state query.
private func triggerStillHeld() -> Bool {
switch router.binding {
case .modifier:
return CGEventSource.flagsState(.combinedSessionState).contains(triggerFlag)
case .chord(let keyCode, let modifiers):
// Both halves have to still be down for the press to be live: the key
// itself, and every modifier the chord requires.
let held = DictationEventDecoder.modifiers(
from: CGEventSource.flagsState(.combinedSessionState))
return CGEventSource.keyState(.combinedSessionState, key: CGKeyCode(keyCode))
&& held.isSuperset(of: modifiers)
case .mouseButton(let button):
// `CGMouseButton` is an open C enum, so any button number constructs; a
// nil (out-of-range) read degrades to "not held", which resets the gate —
// the conservative side, since keeping state with no up-event coming
// strands the session until the auto-release cap.
guard let cgButton = CGMouseButton(rawValue: UInt32(button)) else { return false }
return CGEventSource.buttonState(.combinedSessionState, button: cgButton)
}
}

Expand All @@ -215,36 +244,48 @@ final class DictationKeyTap {
}
}

/// The `CGEventFlags` bit the bound key toggles, so a `flagsChanged` event for
/// it reads as down (bit set) or up (bit clear). This is the *device-dependent*
/// per-side bit (e.g. right ⌘ only), not the generic `.maskCommand` shared by
/// both ⌘ keys — see `TriggerKey.deviceModifierMask` for why that distinction
/// keeps the down/up tracking from desyncing on keyboards with both keys held.
nonisolated static func flag(for key: TriggerKey) -> CGEventFlags {
CGEventFlags(rawValue: key.deviceModifierMask)
/// The `CGEventFlags` bit a bound modifier toggles, so a `flagsChanged` event
/// for it reads as down (bit set) or up (bit clear). This is the
/// *device-dependent* per-side bit (e.g. right ⌘ only), not the generic
/// `.maskCommand` shared by both ⌘ keys — see `TriggerKey.deviceModifierMask`
/// for why that distinction keeps the down/up tracking from desyncing on
/// keyboards with both keys held. Mouse bindings have no flag bit.
nonisolated static func flag(for binding: TriggerBinding) -> CGEventFlags {
guard case .modifier(let key) = binding else { return [] }
return CGEventFlags(rawValue: key.deviceModifierMask)
}

#if UITEST_HOOKS
/// Test seam: drive the real gate + callback dispatch for a synthetic
/// lone-modifier press, bypassing the `CGEventTap` (whose creation needs
/// trigger press, bypassing the `CGEventTap` (whose creation needs
/// Accessibility trust an automated run doesn't have). Pairs with
/// `simulateReleaseForTesting()` to run the same press→hold→release path a
/// real keypress would — used by the leak exercise (`scripts/leaks.sh`) so the
/// DictationKeyTap → DictationKeyGate → onStart/onStop object graph is
/// covered, not just the session the coordinator drives directly.
func simulatePressForTesting() {
_ = router.reset()
dispatch(
router.handle(
.flagsChanged(keyCode: router.triggerKeyCode, triggerFlagIsOn: true), at: .seconds(0)))
dispatch(router.handle(syntheticEvent(down: true), at: .seconds(0)))
}

/// Completes the synthetic cycle as a hold (past the threshold), so the gate
/// emits `.stop` and `onStop` fires.
func simulateReleaseForTesting() {
dispatch(
router.handle(
.flagsChanged(keyCode: router.triggerKeyCode, triggerFlagIsOn: false), at: .seconds(2)))
dispatch(router.handle(syntheticEvent(down: false), at: .seconds(2)))
}

/// The event a real down/up of the bound trigger would reduce to, whatever
/// family the current binding belongs to.
private func syntheticEvent(down: Bool) -> DictationKeyRouter.Event {
switch router.binding {
case .modifier(let key):
return .flagsChanged(keyCode: key.keyCode, triggerFlagIsOn: down)
case .chord(let keyCode, let modifiers):
return down
? .keyDown(keyCode: keyCode, modifiers: modifiers) : .keyUp(keyCode: keyCode)
case .mouseButton(let button):
return down ? .mouseDown(button: button) : .mouseUp(button: button)
}
}
#endif
}
Expand Down
8 changes: 4 additions & 4 deletions App/Blurt/Blurt/MenuBar/MenuBarScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ struct MenuBarContent: View {
@Environment(\.openSettings) private var openSettings

// Observed (as the ready screen does) so the reminder line updates live when
// the dictation key is rebound in Settings — see `BoundTriggerKey`.
@BoundTriggerKey private var triggerKey
// the dictation key is rebound in Settings — see `BoundTriggerBinding`.
@BoundTriggerBinding private var trigger

var body: some View {
// Disabled informational row: the dictation trigger is an invisible lone
// modifier, so spell it out here as the menu bar's discoverability anchor.
Text("Tap or hold \(triggerKey.label) to dictate and paste")
// key, so spell it out here as the menu bar's discoverability anchor.
Text("Tap or hold \(trigger.label) to dictate and paste")

Divider()

Expand Down
18 changes: 9 additions & 9 deletions App/Blurt/Blurt/Wizard/BoundTriggerKey.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import BlurtEngine
import SwiftUI

/// Live-updating read of the bound dictation trigger key, for the views that
/// Live-updating read of the bound dictation trigger, for the views that
/// only *display* it ("Tap or hold right ⌘ to blurt").
///
/// Wraps the `@AppStorage` + `TriggerKey.fromPersisted` pair that three views
/// the ready screen, the menu bar menu, and the permissions footer — each spelled
/// Wraps the `@AppStorage` + `TriggerBinding.fromPersisted` pair that three views
/// the ready screen, the menu bar menu, and the permissions footer — each spelled
/// out, along with a restated `TriggerKey.rightCommand.rawValue` default that
/// `fromPersisted` already owns (an unset default reads as 0, which it maps to
/// right ⌘). Reading the raw keycode through `@AppStorage`, rather than calling
/// `TriggerKeyStore()` once, is what makes these views re-render when the key is
/// rebound in the separate Settings window.
/// right ⌘). Reading the raw encoded slot through `@AppStorage`, rather than
/// calling `TriggerKeyStore()` once, is what makes these views re-render when the
/// trigger is rebound in the separate Settings window.
///
/// A `DynamicProperty` rather than a `View` because two of the three call sites
/// need the value inside a larger sentence, not a `Text` of its own.
@propertyWrapper
struct BoundTriggerKey: DynamicProperty {
@AppStorage(TriggerKeyStore.defaultsKey) private var keyCode = 0
struct BoundTriggerBinding: DynamicProperty {
@AppStorage(TriggerKeyStore.defaultsKey) private var code = 0

var wrappedValue: TriggerKey { TriggerKey.fromPersisted(keyCode) }
var wrappedValue: TriggerBinding { TriggerBinding.fromPersisted(code) }
}
3 changes: 2 additions & 1 deletion App/Blurt/Blurt/Wizard/DeveloperSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ struct DeveloperSection: View {
// the first path is followed by a plain space rather than a comma.
Text(
"Logs each dictation to \(DictationLog.defaultDisplayPath) "
+ "and each failure to \(DictationLog.defaultErrorDisplayPath)"
+ "each failure to \(DictationLog.defaultErrorDisplayPath) "
+ "and Custom-capture input events to \(DictationLog.defaultCaptureDisplayPath)"
)
.textSelection(.enabled)
}
Expand Down
7 changes: 4 additions & 3 deletions App/Blurt/Blurt/Wizard/ReadyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ struct ReadyView: View {
var coordinator: AppCoordinator
var openSettings: () -> Void
// Observed (not read once) so changing the dictation key in the separate
// Settings window re-renders this window's keycap live — see `BoundTriggerKey`.
@BoundTriggerKey private var triggerKey
// Settings window re-renders this window's keycap live — see
// `BoundTriggerBinding`.
@BoundTriggerBinding private var trigger

var body: some View {
// Sections sit 20 pt apart; the logo and shortcut readout are one idea,
Expand Down Expand Up @@ -53,7 +54,7 @@ struct ReadyView: View {
HStack(spacing: 6) {
Text("Tap or hold")
.foregroundStyle(.secondary)
KeyCap(label: triggerKey.label)
KeyCap(label: trigger.label)
Text("to blurt")
.foregroundStyle(.secondary)
}
Expand Down
Loading
Loading