diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index aadb677..fff5a99 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -211,6 +211,18 @@ final class AppCoordinator { keyTap?.refreshBinding() } + /// Starts a dictation when nothing is being captured, and releases the one in + /// flight otherwise — the hotkey's tap-to-toggle behaviour for programmatic + /// callers (see `ToggleDictationIntent`). Routed through the same synchronous + /// `submit(_:)` command feed the key tap drives, so commands stay in emit + /// order. Known benign desync: a programmatic start leaves the key-tap gate + /// idle, so the next physical tap follows the gate's own latch logic rather + /// than stopping this capture — and the gate is re-synced on every terminal + /// phase (see `render(_:)`). + func toggleDictation() { + session.submit(isCapturing ? .release : .press) + } + // MARK: - Dictation render /// The record start/stop chimes (see `CueSoundPlayer` below). diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 088ebc7..b4f8dc0 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -1,3 +1,4 @@ +import AppIntents import AppKit import ApplicationServices import BlurtEngine @@ -306,3 +307,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return true } } + +// MARK: - Siri / Shortcuts + +// periphery:ignore - instantiated reflectively by the AppIntents framework, never from app code. +/// The "Toggle Dictation" action for Shortcuts and Siri: a tap of the hotkey, by +/// voice. Starts a dictation when nothing is being captured and stops the one in +/// flight, through the same command feed the key tap drives (see +/// `AppCoordinator.toggleDictation`). `openAppWhenRun` stays false so Siri — +/// e.g. from AirPods — runs it without foregrounding Blurt, which would steal +/// focus from the very app the user is dictating into. +/// +/// `nonisolated`: the AppIntents framework reads the conformance from arbitrary +/// contexts, so the type opts out of the app target's MainActor default; only +/// `perform()` hops back to the main actor, which its async requirement allows. +nonisolated struct ToggleDictationIntent: AppIntent { + static let title: LocalizedStringResource = "Toggle Dictation" + static let openAppWhenRun = false + + @MainActor + func perform() async throws -> some IntentResult { + // Before the coordinator exists (mid-launch) there is nothing to toggle, so + // the intent quietly no-ops rather than erroring at the user. + (NSApp.delegate as? AppDelegate)?.coordinator?.toggleDictation() + return .result() + } +} + +// periphery:ignore - discovered reflectively by the AppIntents framework, never from app code. +/// Registers the built-in Siri/Spotlight phrases so the intent works with no +/// setup. Apple requires the app's name in every phrase; for a custom wake +/// phrase ("start blurting"), wrap the intent in a Shortcut named that. +nonisolated struct BlurtAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ToggleDictationIntent(), + phrases: [ + "Toggle \(.applicationName) dictation", + "Start \(.applicationName) dictation", + "Stop \(.applicationName) dictation", + ], + shortTitle: "Toggle Dictation", + systemImageName: "mic" + ) + } +}