From b8096dc4ca453df1d9f69b79911d2f8e98be09a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:46:23 +0000 Subject: [PATCH 1/2] feat: add Toggle Dictation App Intent for Siri and Shortcuts Expose the hotkey's tap-to-toggle behaviour as an App Intent so a dictation can be started or stopped by voice (Siri, e.g. from AirPods) or from the Shortcuts app. The intent runs without foregrounding Blurt and drives the same DictationSession command feed as the key tap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wd3oRQC3j1M4BC7vbT49NV --- App/Blurt/Blurt/AppCoordinator.swift | 12 ++++++++ App/Blurt/Blurt/AppDelegate.swift | 46 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index aadb6775..fff5a999 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 088ebc75..220a63fb 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 + +/// 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. +// periphery:ignore - instantiated reflectively by the AppIntents framework, never from app code. +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() + } +} + +/// 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. +// periphery:ignore - discovered reflectively by the AppIntents framework, never from app code. +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" + ) + } +} From f8ebe826e301d36727a3df7757d455c4a517b00e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:03:47 +0000 Subject: [PATCH 2/2] fix: move periphery:ignore comments above doc blocks to satisfy orphaned_doc_comment SwiftLint's orphaned_doc_comment rule fires when a doc comment is separated from its declaration by a plain comment, which is exactly where the two periphery:ignore commands sat. Above the doc block they stay in the declaration's leading trivia, so periphery still honors them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wd3oRQC3j1M4BC7vbT49NV --- App/Blurt/Blurt/AppDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 220a63fb..b4f8dc02 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -310,6 +310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // 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 @@ -320,7 +321,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// `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. -// periphery:ignore - instantiated reflectively by the AppIntents framework, never from app code. nonisolated struct ToggleDictationIntent: AppIntent { static let title: LocalizedStringResource = "Toggle Dictation" static let openAppWhenRun = false @@ -334,10 +334,10 @@ nonisolated struct ToggleDictationIntent: AppIntent { } } +// 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. -// periphery:ignore - discovered reflectively by the AppIntents framework, never from app code. nonisolated struct BlurtAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut(