Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 46 additions & 0 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppIntents
import AppKit
import ApplicationServices
import BlurtEngine
Expand Down Expand Up @@ -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"
)
}
}