From dcf02534a6af85611773c5f5312e23871d1e716d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 21:21:57 +0000 Subject: [PATCH 1/4] refactor: apply /simplify cleanups across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse / altitude: - Single dictation entry path: AppCoordinator.beginDictation/endDictation/ cancelDictation shared by the key tap closures and the UI-test harness, replacing the hand-mirrored uiTest* copies of the missing-key gate. - One 16 kHz definition: SyncSTTLimits.sampleRate, referenced by MicCapture's recorder geometry and DictationSession's declared rate (previously two independent private constants). - Stub mics (engine tests + UITestMic) compute their canned buffer from SyncSTTLimits.minSamples instead of hardcoding 1600/2000, so a raised floor can't silently start dropping test audio. - UITestKeys.validAPIKey centralizes the "uitest-valid-key" sentinel used by AppDelegate's leak exercise and the harness window. - FocusCapture: shared systemFocusedElement() helper replaces the duplicated system-wide AX read (and its force-cast justification) in captureFieldContext and hasEditableFocusedElement; dropped the normalized() alias of trimmedNonEmpty() and its duplicate tests. - APIKeyStepView uses the engine's trimmedNonEmpty() rule instead of an inline trim; shared freshDefaults() test helper replaces two verbatim copies. - scripts/release-lib.sh: one sourced definition of the logging trio and the version helpers (is_semver, version_gt, parse_short_version, parse_bundle_version, parse_build_info_git_sha) that five release scripts each inlined; release.test.sh covers the new parse_bundle_version. Simplification: - SoundStepView.displayLabel: dropped the DX-7/Juno-106 contains() chain that re-derived exactly what SoundPack.synth already returns. - StubTranscriber: Mode.transcript(String) replaces the streaming-era yieldChunks([String]) (every call site passed a single element). - TriggerKeyStore reads via TriggerKey.fromPersisted, the documented single decode-with-default rule, instead of re-encoding the right-cmd fallback. - WizardWindowController: inlined the single-caller gestureLine helper. Coverage honesty: - Moved the pure meter math (meterFloorDB, linearLevel) to MicCapture+Meter.swift so the coverage gate counts it; MicCapture.swift stays excluded as the hardware-bound actor. check.sh's exclusion comment now describes the real layout (AVAudioRecorder, no phantom AudioDSP.swift). Stale docs fixed: removed references to the deleted overlay auto-gain (MicCapture, AGENTS.md), DictateHotkey.keyCaps and refreshBinding() naming in AGENTS.md, and the "HARP 1" default in SoundPackStore's doc. Verified here: bash -n on all scripts and scripts/release.test.sh (all pass). Swift changes need scripts/check.sh on a Mac — this sandbox has no macOS toolchain; CI is the authority. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012XWdVUaLE9RXmEfbuL3Lk5 --- AGENTS.md | 4 +- .../Blurt/AppCoordinator+UITesting.swift | 31 +++--------- App/Blurt/Blurt/AppCoordinator.swift | 45 ++++++++++++----- App/Blurt/Blurt/AppDelegate.swift | 2 +- App/Blurt/Blurt/DictationComposition.swift | 1 + App/Blurt/Blurt/Hotkey/DictateHotkey.swift | 5 +- App/Blurt/Blurt/UITestSupport.swift | 17 ++++--- .../Blurt/Wizard/Steps/APIKeyStepView.swift | 10 ++-- .../Blurt/Wizard/Steps/SoundStepView.swift | 12 +---- .../Blurt/Wizard/WizardWindowController.swift | 11 +--- .../DictationPipelineUITests.swift | 2 +- App/Blurt/BlurtUITests/README.md | 5 +- .../BlurtEngine/Audio/MicCapture+Meter.swift | 19 +++++++ Sources/BlurtEngine/Audio/MicCapture.swift | 25 +++------- .../BlurtEngine/Audio/SoundPackStore.swift | 4 +- .../FocusCapture/FocusCapture.swift | 50 ++++++++----------- .../BlurtEngine/Hotkey/TriggerKeyStore.swift | 5 +- .../Pipeline/DictationSession.swift | 5 +- Sources/BlurtEngine/STT/SyncSTTLimits.swift | 6 +++ Tests/BlurtEngineTests/CancelRaceTests.swift | 4 +- .../DictationPerformanceTests.swift | 2 +- .../DictationSessionTests.swift | 24 ++++----- .../BlurtEngineTests/FocusCaptureTests.swift | 15 ------ Tests/BlurtEngineTests/HotkeyRaceTests.swift | 10 ++-- Tests/BlurtEngineTests/MemoryLeakTests.swift | 2 +- Tests/BlurtEngineTests/ReleaseRaceTests.swift | 8 +-- .../SoundPackStoreTests.swift | 7 --- .../Stubs/FreshDefaults.swift | 11 ++++ .../Stubs/StubMicCapture.swift | 8 +-- .../Stubs/StubTranscriber.swift | 6 +-- .../TriggerKeyStoreTests.swift | 8 --- scripts/check.sh | 13 ++--- scripts/release-build.sh | 7 ++- scripts/release-bump.sh | 19 +++---- scripts/release-install.sh | 10 ++-- scripts/release-lib.sh | 42 ++++++++++++++++ scripts/release-publish.sh | 7 ++- scripts/release.sh | 33 +++--------- scripts/release.test.sh | 9 +++- 39 files changed, 251 insertions(+), 253 deletions(-) create mode 100644 Sources/BlurtEngine/Audio/MicCapture+Meter.swift create mode 100644 Tests/BlurtEngineTests/Stubs/FreshDefaults.swift create mode 100644 scripts/release-lib.sh diff --git a/AGENTS.md b/AGENTS.md index 6c028a99..8099dd3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ DictationKeyTap (CGEventTap + DictationKeyGate) → AppCoordinator → Dictation KeyInjector → focused app (clipboard-paste via a synthesized Cmd-V CGEvent) ``` -**`MicCapture`** (`Sources/BlurtEngine/Audio/MicCapture.swift`) is an actor implementing `MicCaptureProtocol`. It captures with **`AVAudioRecorder`**, recording straight to a temp 16 kHz / mono / 16-bit PCM WAV — the exact geometry the Sync API wants, so `stop()` reads the file back as `[Float]` (via `AVAudioFile`) with no resampling pass. A **fresh recorder per session** resolves the current default input device at `record()` time; this is deliberate. A long-lived `AVAudioEngine` was used previously and its input graph bound to one device, going stale on a device switch (mic ↔ built-in) — raising `-10868` (`kAudioUnitErr_FormatNotSupported`) or capturing all-zero buffers. **Don't reintroduce an `AVAudioEngine`/`installTap` capture path.** The overlay meter (`levels`) comes from the recorder's dBFS power on a ~30 Hz timer, mapped to `0…1` by `linearLevel(fromPowerDB:)` (floored so room ambient reads as empty bars — the overlay's auto-gain inflates any steady nonzero level to full). +**`MicCapture`** (`Sources/BlurtEngine/Audio/MicCapture.swift`) is an actor implementing `MicCaptureProtocol`. It captures with **`AVAudioRecorder`**, recording straight to a temp 16 kHz / mono / 16-bit PCM WAV — the exact geometry the Sync API wants, so `stop()` reads the file back as `[Float]` (via `AVAudioFile`) with no resampling pass. A **fresh recorder per session** resolves the current default input device at `record()` time; this is deliberate. A long-lived `AVAudioEngine` was used previously and its input graph bound to one device, going stale on a device switch (mic ↔ built-in) — raising `-10868` (`kAudioUnitErr_FormatNotSupported`) or capturing all-zero buffers. **Don't reintroduce an `AVAudioEngine`/`installTap` capture path.** The overlay meter (`levels`) comes from the recorder's dBFS power on a ~30 Hz timer, mapped to `0…1` by `linearLevel(fromPowerDB:)` (floored so room ambient reads as empty bars rather than a meter that never rests). **`AssemblyAITranscriber`** (`Sources/BlurtEngine/STT/AssemblyAITranscriber.swift`) implements `TranscriberProtocol` against AssemblyAI's **Sync** STT API (the endpoint `assembly dictate` uses): a single `POST https://sync.assemblyai.com/transcribe` with the captured audio as a raw S16LE PCM blob (`PCMEncoder`) in the `audio` multipart part plus a JSON `config` part (`sample_rate`, `channels`, and a `prompt`), `X-AAI-Model: u3-sync-pro`. The `prompt` (built per utterance by `TranscriptionPrompt.build(context:)`) is prepended to the model's system prompt, so the transcript that comes back is **already polished** — there is no separate LLM post-processing step. The finished transcript comes back in the response body — no `/v2/upload`, no job submission, no polling. Truly synchronous: `TranscriberProtocol.transcribe(samples:sampleRate:)` is a single `async throws -> String` that returns the whole transcript at once (no streaming/deltas). The sync model handles audio from ~80 ms up to 120 s (server-side ~30 s inference deadline); those limits live in `SyncSTTLimits` and back `DictationSession`'s auto-release timeout (so a held hotkey stops before the cap). @@ -87,7 +87,7 @@ The dictation trigger is a **single lone modifier key** (tap-to-toggle or hold-t The app side, **`DictationKeyTap`** (`App/Blurt/Blurt/Hotkey/DictationKeyTap.swift`), bridges a `CGEventTap` (watching `flagsChanged` for the bound modifier and `keyDown` for any other key) into the gate. It **swallows nothing** — a lone modifier types nothing, and combos pass through so normal shortcuts keep working — unlike the old chord trigger. -The trigger is editable in the Shortcut section of the setup/settings window (`HotkeyStepView`), which is a `Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore` (then `AppCoordinator.refreshBinding()` re-reads it into the tap). Use `DictateHotkey.label` / `DictateHotkey.keyCaps` (`App/Blurt/Blurt/Hotkey/DictateHotkey.swift`) for display strings. +The trigger is editable in the Shortcut section of the setup/settings window (`HotkeyStepView`), which is a `Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore` (then `AppCoordinator.dictationBindingChanged()` re-reads it into the tap). For display strings, use `DictateHotkey.label` (`App/Blurt/Blurt/Hotkey/DictateHotkey.swift`) for one-shot reads, or `@AppStorage(TriggerKeyStore.defaultsKey)` + `TriggerKey.fromPersisted` in views that must re-render live on a Settings change. ## Transcription prompt diff --git a/App/Blurt/Blurt/AppCoordinator+UITesting.swift b/App/Blurt/Blurt/AppCoordinator+UITesting.swift index 61bd0cf3..c1b52d79 100644 --- a/App/Blurt/Blurt/AppCoordinator+UITesting.swift +++ b/App/Blurt/Blurt/AppCoordinator+UITesting.swift @@ -3,32 +3,13 @@ // // XCUITest can't synthesize the lone right-modifier `flagsChanged` event the // real trigger relies on — and the `CGEventTap` that reads it needs the - // process to be Accessibility-trusted, which the CI test host isn't. These - // mirror the key tap's `onStart`/`onStop`/`onCancel` closures so a UI test - // drives the *same* pipeline (`DictationSession`) the hotkey would, just via - // the test harness window instead of a key event. Compiled only in Debug, so - // they never ship in the notarized Release build. + // process to be Accessibility-trusted, which the CI test host isn't. The test + // harness window instead calls `beginDictation`/`endDictation`/ + // `cancelDictation` directly — the same methods the key tap's closures + // dispatch to — so a UI test drives the *same* pipeline (`DictationSession`) + // and missing-key gate the hotkey would. Compiled only in Debug, so nothing + // here ships in the notarized Release build. extension AppCoordinator { - /// Mirrors the key tap's `onStart`: honors the missing-key gate (so the - /// no-key path is testable), otherwise begins recording. - func uiTestBeginDictation() async { - guard hasAPIKey else { - onMissingAPIKey() - return - } - await session.press() - } - - /// Mirrors the key tap's `onStop` — stop recording and run transcribe→inject. - func uiTestEndDictation() async { - await session.release() - } - - /// Mirrors the key tap's `onCancel` — abandon the in-flight dictation. - func uiTestCancelDictation() async { - await session.cancel() - } - /// Fire a synthetic dictation-key press/release through the *real* key tap /// (gate + callbacks), not the session directly. Lets the leak exercise /// (`scripts/leaks.sh`) cover the DictationKeyTap object graph a real diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index dcb8c9da..3132ab4f 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -90,23 +90,13 @@ final class AppCoordinator { // auto-repeat key events into the focused app while held. let tap = DictationKeyTap( onStart: { [weak self] in - Task { @MainActor in - guard let self else { return } - guard self.hasAPIKey else { - // No key yet — take the user straight to the fix instead of a - // transient message. The overlay pill isn't even visible in this - // state (it's only revealed once setup is complete). - self.onMissingAPIKey() - return - } - await self.session.press() - } + Task { @MainActor in await self?.beginDictation() } }, onStop: { [weak self] in - Task { @MainActor in await self?.session.release() } + Task { @MainActor in await self?.endDictation() } }, onCancel: { [weak self] in - Task { @MainActor in await self?.session.cancel() } + Task { @MainActor in await self?.cancelDictation() } } ) self.keyTap = tap @@ -161,6 +151,35 @@ final class AppCoordinator { overlay?.hide() } + // MARK: - Dictation drivers + // + // The single begin/end/cancel path shared by the key tap's closures and the + // UI-test harness (`UITestSupport`), so the harness always exercises the same + // gate the hotkey does. + + /// Begins a dictation as the hotkey would: the missing-key gate first, then + /// `session.press()`. + func beginDictation() async { + guard hasAPIKey else { + // No key yet — take the user straight to the fix instead of a + // transient message. The overlay pill isn't even visible in this + // state (it's only revealed once setup is complete). + onMissingAPIKey() + return + } + await session.press() + } + + /// Stops recording and runs transcribe→inject. + func endDictation() async { + await session.release() + } + + /// Abandons the in-flight dictation. + func cancelDictation() async { + await session.cancel() + } + /// Called when the user rebinds (or clears) the dictation shortcut in the /// recorder, so the event tap starts matching the new chord. The shortcut no /// longer gates readiness (it has a default and lives in Settings), so there's diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index fa4827ad..2364667d 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -207,7 +207,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// the call site in `applicationDidFinishLaunching` and `scripts/leaks.sh`. @MainActor private func runLeakExercise(_ coord: AppCoordinator) async { - coord.saveAPIKey("uitest-valid-key") // clear the missing-key gate + coord.saveAPIKey(UITestKeys.validAPIKey) // clear the missing-key gate // Build/enable the key tap's object graph, then drive cycles *through the // tap* (gate + callbacks), so the leak run covers the real dictation-key // path — not just the session. (The tap can't create its CGEventTap without diff --git a/App/Blurt/Blurt/DictationComposition.swift b/App/Blurt/Blurt/DictationComposition.swift index f81ce859..bf7f7ebf 100644 --- a/App/Blurt/Blurt/DictationComposition.swift +++ b/App/Blurt/Blurt/DictationComposition.swift @@ -58,6 +58,7 @@ struct ProductionAPIKeyStore: APIKeyGateway { /// behind `#if DEBUG`) because the submit path that reads them is compiled in /// every configuration; the XCUITest target hard-codes the same string values. enum UITestKeys { + static let validAPIKey = "uitest-valid-key" static let invalidAPIKey = "uitest-invalid-key" static let unreachableAPIKey = "uitest-unreachable-key" } diff --git a/App/Blurt/Blurt/Hotkey/DictateHotkey.swift b/App/Blurt/Blurt/Hotkey/DictateHotkey.swift index 124c72bd..9d3d18a0 100644 --- a/App/Blurt/Blurt/Hotkey/DictateHotkey.swift +++ b/App/Blurt/Blurt/Hotkey/DictateHotkey.swift @@ -1,8 +1,9 @@ import BlurtEngine /// Display strings for the current dictation trigger key, read from the -/// persisted `TriggerKeyStore`. Used by the overlay pill, the ready-screen -/// keycaps, and onboarding footers. +/// persisted `TriggerKeyStore`. For one-shot reads only (e.g. the permissions +/// step's description); views that must re-render live on a Settings change +/// read the raw keycode via `@AppStorage` + `TriggerKey.fromPersisted` instead. @MainActor enum DictateHotkey { static var triggerKey: TriggerKey { TriggerKeyStore().triggerKey } diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 52af12fa..d9c79553 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -73,7 +73,9 @@ /// and proceeds to transcribe. struct UITestMic: MicCaptureProtocol { func start() async throws {} - func stop() async throws -> [Float] { Array(repeating: 0, count: 2_000) } + func stop() async throws -> [Float] { + Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) + } } /// Stub transcriber: returns the harness's canned transcript, so the "spoken" @@ -130,8 +132,9 @@ // MARK: - Harness window - /// The test harness window. It exposes buttons that drive the same pipeline the - /// hotkey would (via `AppCoordinator.uiTest*`), a field to set the canned + /// The test harness window. It exposes buttons that drive the same pipeline + /// the hotkey would (`AppCoordinator.beginDictation`/`…end`/`…cancel`), a + /// field to set the canned /// transcript, and read-outs for the live pipeline status and the last /// "pasted" text — everything the XCUITest suite needs to observe the /// record → transcribe → paste flow deterministically. @@ -154,13 +157,13 @@ .accessibilityIdentifier(UITestID.transcriptField) HStack(spacing: 8) { - Button("Set API Key") { coordinator?.saveAPIKey("uitest-valid-key") } + Button("Set API Key") { coordinator?.saveAPIKey(UITestKeys.validAPIKey) } .accessibilityIdentifier(UITestID.setKeyButton) - Button("Start") { Task { await coordinator?.uiTestBeginDictation() } } + Button("Start") { Task { await coordinator?.beginDictation() } } .accessibilityIdentifier(UITestID.startButton) - Button("Stop") { Task { await coordinator?.uiTestEndDictation() } } + Button("Stop") { Task { await coordinator?.endDictation() } } .accessibilityIdentifier(UITestID.stopButton) - Button("Cancel") { Task { await coordinator?.uiTestCancelDictation() } } + Button("Cancel") { Task { await coordinator?.cancelDictation() } } .accessibilityIdentifier(UITestID.cancelButton) } diff --git a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift index f2621178..8d5e2a7d 100644 --- a/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/APIKeyStepView.swift @@ -30,16 +30,15 @@ struct APIKeyStepView: View { /// the AppKit `presentError:` convention for genuine `NSError`-class faults. @State private var showSaveFault = false - private var trimmed: String { - draft.trimmingCharacters(in: .whitespacesAndNewlines) - } + /// The draft trimmed to usable text (the engine's shared rule), nil when blank. + private var trimmedKey: String? { draft.trimmedNonEmpty() } /// Enabled for any non-empty key while no validation is in flight. We don't /// gate on "differs from the saved key" — a disabled-until-changed button /// renders as dimmed caption text on the common return visit (field pre-filled /// with the saved key), so people don't see it at all. Re-submitting an /// unchanged key is a harmless re-validate against AssemblyAI. - private var canSubmit: Bool { !trimmed.isEmpty && !isValidating } + private var canSubmit: Bool { trimmedKey != nil && !isValidating } /// "Save" for the first key, "Update" once one exists. private var actionTitle: String { savedKey.isEmpty ? "Save" : "Update" } @@ -190,8 +189,7 @@ struct APIKeyStepView: View { } private func submit() { - guard canSubmit else { return } - let key = trimmed + guard canSubmit, let key = trimmedKey else { return } isValidating = true errorMessage = nil Task { diff --git a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift index a86b7dc4..024eeb51 100644 --- a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift @@ -42,16 +42,6 @@ struct SoundStepView: View { private func displayLabel(for pack: SoundPack) -> String { guard let synth = pack.synth else { return pack.label } - - let source: String - if synth.contains("DX-7") { - source = "Yamaha DX-7" - } else if synth.contains("Juno-106") { - source = "Roland Juno-106" - } else { - source = synth - } - - return "\(pack.label) · \(source)" + return "\(pack.label) · \(synth)" } } diff --git a/App/Blurt/Blurt/Wizard/WizardWindowController.swift b/App/Blurt/Blurt/Wizard/WizardWindowController.swift index d402d22e..39f4fc27 100644 --- a/App/Blurt/Blurt/Wizard/WizardWindowController.swift +++ b/App/Blurt/Blurt/Wizard/WizardWindowController.swift @@ -137,22 +137,15 @@ struct ReadyView: View { /// "Tap or hold ⌘ to blurt and paste", with the key drawn as a rounded keycap. private var shortcutReadout: some View { - gestureLine(prefix: "Tap or hold", suffix: "to blurt and paste") - } - - /// One gesture readout, e.g. "Tap or hold [⌘] to blurt and paste", with the trigger - /// key drawn as a rounded keycap between the prefix and suffix. - private func gestureLine(prefix: String, suffix: String) -> some View { HStack(spacing: 6) { - Text(prefix) + Text("Tap or hold") .foregroundStyle(.secondary) KeyCap(label: TriggerKey.fromPersisted(triggerKeyCode).label) - Text(suffix) + Text("to blurt and paste") .foregroundStyle(.secondary) } .font(.title3) } - } private struct ReadyBrandingView: View { diff --git a/App/Blurt/BlurtUITests/DictationPipelineUITests.swift b/App/Blurt/BlurtUITests/DictationPipelineUITests.swift index fe324736..2e6d59fd 100644 --- a/App/Blurt/BlurtUITests/DictationPipelineUITests.swift +++ b/App/Blurt/BlurtUITests/DictationPipelineUITests.swift @@ -46,7 +46,7 @@ final class DictationPipelineUITests: BlurtUITestCase { } /// The same happy path driven through the harness's Start/Stop buttons, which - /// call the session directly (`uiTestBeginDictation`/`…End`) rather than the + /// call the session directly (`beginDictation`/`endDictation`) rather than the /// key tap. Complements the hotkey test above: it covers the direct-session /// seam the overlay/menu-bar affordances use, independent of the tap wiring. func testStartStopButtonsDriveRecordTranscribePaste() { diff --git a/App/Blurt/BlurtUITests/README.md b/App/Blurt/BlurtUITests/README.md index 3dca000a..ed636e8c 100644 --- a/App/Blurt/BlurtUITests/README.md +++ b/App/Blurt/BlurtUITests/README.md @@ -29,8 +29,9 @@ the real Keychain. Launching with `-BlurtUITest` (set by `UITestInjector`) and an `InMemoryAPIKeyStore` via the `DictationComponents` / `APIKeyGateway` seams on `AppCoordinator`, and - presents a harness window with buttons that call the same pipeline entry points - the hotkey uses (`AppCoordinator.uiTestBeginDictation` / `…End` / `…Cancel`), - plus read-outs for the live pipeline phase and the injector's last "paste". + the hotkey uses (`AppCoordinator.beginDictation` / `endDictation` / + `cancelDictation`), plus read-outs for the live pipeline phase and the + injector's last "paste". The lone-modifier `CGEventTap` trigger can't be synthesized by XCUITest (and needs an Accessibility-trusted process), so the harness drives the pipeline diff --git a/Sources/BlurtEngine/Audio/MicCapture+Meter.swift b/Sources/BlurtEngine/Audio/MicCapture+Meter.swift new file mode 100644 index 00000000..969a1548 --- /dev/null +++ b/Sources/BlurtEngine/Audio/MicCapture+Meter.swift @@ -0,0 +1,19 @@ +// Pure meter math, kept out of `MicCapture.swift` so the coverage gate sees it: +// the capture actor itself needs a real audio device and is excluded from the +// gate (see `scripts/check.sh`), but this DSP is deterministic and exercised by +// `MicCaptureFormatTests`. +extension MicCapture { + /// Lowest dBFS meter reading treated as audible; anything quieter (room + /// ambient) maps to 0. Deliberately set above typical built-in-mic noise so + /// the overlay's voice bars read as empty at rest and only move for speech. + static let meterFloorDB: Float = -50 + + /// Convert `AVAudioRecorder`'s dBFS meter power into the `0...1` the overlay + /// expects, mapped linearly across `[meterFloorDB, 0]`. (A raw `pow(10, db/20)` + /// amplitude leaves ambient noise well above zero, so the bars never rested + /// at empty.) + static func linearLevel(fromPowerDB db: Float) -> Float { + guard db.isFinite, db > meterFloorDB else { return 0 } + return min(1, (db - meterFloorDB) / -meterFloorDB) + } +} diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 41e44163..e2ca24a0 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -20,10 +20,11 @@ public actor MicCapture: MicCaptureProtocol { public nonisolated let levels: AsyncStream private nonisolated let levelsContinuation: AsyncStream.Continuation - /// The geometry the recorder converts hardware audio to on the fly. Matches the - /// `sampleRate: 16_000` the pipeline hands the transcriber, so `stop()` returns - /// samples ready to encode with no resampling pass. - private static let targetSampleRate = 16_000.0 + /// The geometry the recorder converts hardware audio to on the fly. The Sync + /// API's rate (`SyncSTTLimits.sampleRate`) — the same one the pipeline hands + /// the transcriber — so `stop()` returns samples ready to encode with no + /// resampling pass. + private static let targetSampleRate = Double(SyncSTTLimits.sampleRate) /// Pre-prepared in `warmUp()` so the *first* dictation doesn't pay hardware /// route discovery on the hot path; consumed by the first `start()`. Every @@ -144,20 +145,8 @@ public actor MicCapture: MicCaptureProtocol { levelsContinuation.yield(Self.linearLevel(fromPowerDB: recorder.averagePower(forChannel: 0))) } - /// Lowest dBFS meter reading treated as audible; anything quieter (room - /// ambient) maps to 0. Deliberately set above typical built-in-mic noise: the - /// overlay's auto-gain stretches *any* steady nonzero level back to a full bar, - /// so silence must floor to exactly 0 or the meter reads full at rest. - static let meterFloorDB: Float = -50 - - /// Convert `AVAudioRecorder`'s dBFS meter power into the `0...1` the overlay - /// expects, mapped linearly across `[meterFloorDB, 0]`. (A raw `pow(10, db/20)` - /// amplitude leaves ambient noise well above zero, which the auto-gain then - /// inflates into a full bar at rest — the meter looked inverted.) - static func linearLevel(fromPowerDB db: Float) -> Float { - guard db.isFinite, db > meterFloorDB else { return 0 } - return min(1, (db - meterFloorDB) / -meterFloorDB) - } + // The dB→0...1 conversion `emitLevel` uses lives in `MicCapture+Meter.swift` + // — pure math the coverage gate counts, unlike this hardware-bound actor. // MARK: - File helpers diff --git a/Sources/BlurtEngine/Audio/SoundPackStore.swift b/Sources/BlurtEngine/Audio/SoundPackStore.swift index 0d5bdc93..5323afb2 100644 --- a/Sources/BlurtEngine/Audio/SoundPackStore.swift +++ b/Sources/BlurtEngine/Audio/SoundPackStore.swift @@ -1,8 +1,8 @@ import Foundation /// Persists the chosen `SoundPack` (by its `id`) in `UserDefaults`. Defaults to -/// `SoundPack.defaultPack` (HARP 1) when unset or when the stored id isn't a -/// known pack. Same shape as `TriggerKeyStore`. +/// `SoundPack.defaultPack` when unset or when the stored id isn't a known pack. +/// Same shape as `TriggerKeyStore`. public struct SoundPackStore { /// UserDefaults key holding the selected pack id. Public so SwiftUI views can /// observe it directly (e.g. `@AppStorage`) and re-render on change. diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift index 01f50f1b..345f301e 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift @@ -55,18 +55,7 @@ enum FocusCapture { -> FocusedFieldContext { guard AXIsProcessTrusted() else { return .empty } - - let system = AXUIElementCreateSystemWide() - var focusedRef: CFTypeRef? - guard - AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) - == .success, - let focusedRef - else { return .empty } - // CFTypeRef → AXUIElement is a guaranteed CF downcast; `as?` warns - // "always succeeds" (an error under -warnings-as-errors), so force-cast. - // swiftlint:disable:next force_cast - let element = focusedRef as! AXUIElement + guard let element = systemFocusedElement() else { return .empty } // Don't read the value of a password field into the prompt. let isSecure = (stringValue(element, kAXRoleAttribute) == "AXSecureTextField") @@ -81,6 +70,24 @@ enum FocusCapture { fieldLabel: clip(fieldLabel(of: element), to: 80)) } + /// The system-wide focused UI element, or `nil` when none is resolvable + /// (process not trusted, or nothing focused). The Accessibility *client* read + /// APIs are thread-safe, so this serves both the `@MainActor` context capture + /// and the injector's off-main editability check. + private nonisolated static func systemFocusedElement() -> AXUIElement? { + let system = AXUIElementCreateSystemWide() + var focusedRef: CFTypeRef? + guard + AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) + == .success, + let focusedRef + else { return nil } + // CFTypeRef → AXUIElement is a guaranteed CF downcast; `as?` warns + // "always succeeds" (an error under -warnings-as-errors), so force-cast. + // swiftlint:disable:next force_cast + return (focusedRef as! AXUIElement) + } + /// The insertion point (UTF-16 location of the selected range) of `element`, /// or `nil` when it exposes no readable selection. @MainActor @@ -150,7 +157,7 @@ enum FocusCapture { guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success, let value = ref as? String else { return nil } - return normalized(value) + return value.trimmedNonEmpty() } /// Reads an `AXUIElement`-valued AX attribute (e.g. the containing window). @@ -218,13 +225,7 @@ enum FocusCapture { nonisolated static func hasEditableFocusedElement() -> Bool { guard AXIsProcessTrusted() else { return true } - let system = AXUIElementCreateSystemWide() - var focusedRef: CFTypeRef? - guard - AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) - == .success, - let focusedRef - else { + guard let element = systemFocusedElement() else { // AX is trusted but reports no focused element — e.g. a native app frontmost // with nothing editable focused (Finder, the desktop, a button-only window). // Posting ⌘V there only beeps, so treat it as non-editable and copy instead. @@ -233,8 +234,6 @@ enum FocusCapture { // `KeyInjector.insert` / `isElectronApp`). return false } - // swiftlint:disable:next force_cast - let element = focusedRef as! AXUIElement var roleRef: CFTypeRef? let role = @@ -260,11 +259,6 @@ enum FocusCapture { // MARK: - Pure helpers (no Accessibility I/O — unit-testable in isolation) - /// Trims surrounding whitespace; returns `nil` for empty/blank input. - static func normalized(_ text: String?) -> String? { - text.trimmedNonEmpty() - } - /// Picks the most descriptive field label in priority order /// (placeholder → description → title → role description), skipping blanks. /// Placeholder/description tend to be the most meaningful; role description @@ -273,7 +267,7 @@ enum FocusCapture { placeholder: String?, description: String?, title: String?, roleDescription: String? ) -> String? { for candidate in [placeholder, description, title, roleDescription] { - if let value = normalized(candidate) { return value } + if let value = candidate.trimmedNonEmpty() { return value } } return nil } diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift index 268db843..5ab77615 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift @@ -15,8 +15,9 @@ public struct TriggerKeyStore { public var triggerKey: TriggerKey { get { - let code = defaults.object(forKey: Self.defaultsKey) as? Int - return code.map(TriggerKey.fromPersisted) ?? .rightCommand + // Unset reads as 0, which isn't a curated keycode, so `fromPersisted`'s + // right-⌘ fallback covers both "never set" and "unknown code". + TriggerKey.fromPersisted(defaults.integer(forKey: Self.defaultsKey)) } nonmutating set { defaults.set(newValue.rawValue, forKey: Self.defaultsKey) diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index 94bf5344..dbdcc7c6 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -39,8 +39,9 @@ public actor DictationSession { /// endpoint rejects, so we stop early and transcribe what we have. private let maxRecordingSeconds: Double - /// Sample rate the mic capture delivers and the Sync STT request declares. - private static let captureSampleRate = 16_000 + /// Sample rate the mic capture delivers and the Sync STT request declares — + /// the Sync API's geometry, defined once in `SyncSTTLimits`. + private static let captureSampleRate = SyncSTTLimits.sampleRate /// Context captured at `press()` (focused app + text before the cursor), /// passed to the transcriber so the Sync STT model has priming. Captured at diff --git a/Sources/BlurtEngine/STT/SyncSTTLimits.swift b/Sources/BlurtEngine/STT/SyncSTTLimits.swift index 16891d06..4ab3011f 100644 --- a/Sources/BlurtEngine/STT/SyncSTTLimits.swift +++ b/Sources/BlurtEngine/STT/SyncSTTLimits.swift @@ -2,6 +2,12 @@ /// a held hotkey just before the cap so a long press never records audio the /// endpoint would reject. public enum SyncSTTLimits { + /// Sample rate of the 16 kHz / mono / 16-bit PCM geometry the Sync API + /// expects. The single definition shared by `MicCapture` (which records at + /// this rate) and `DictationSession` (which declares it on the request), so + /// the recorded and declared rates can't drift apart. + public static let sampleRate = 16_000 + /// Maximum audio duration the Sync model accepts per request (seconds). public static let maxAudioSeconds: Double = 120 diff --git a/Tests/BlurtEngineTests/CancelRaceTests.swift b/Tests/BlurtEngineTests/CancelRaceTests.swift index 5104b00c..b249a771 100644 --- a/Tests/BlurtEngineTests/CancelRaceTests.swift +++ b/Tests/BlurtEngineTests/CancelRaceTests.swift @@ -50,7 +50,7 @@ struct CancelRaceTests { // than inferred from an empty array after the fact. await confirmation("cancelled injection records no paste", expectedCount: 0) { pasted in let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hello world."])) + let stt = StubTranscriber(mode: .transcript("Hello world.")) let injector = GatedInjector(onRecord: { pasted() }) let session = DictationSession(mic: mic, transcriber: stt, injector: injector) @@ -74,7 +74,7 @@ struct CancelRaceTests { func cancelTearsDownAutoRelease() async throws { let mic = StubMicCapture() // Would inject "Timed out text." if the auto-release timer ever fired release(). - let stt = StubTranscriber(mode: .yieldChunks(["Timed out text."])) + let stt = StubTranscriber(mode: .transcript("Timed out text.")) let injector = StubInjector() // A short cap so a *live* timer would fire well within the wait below — the // test proves cancel cancelled it, not that the timer simply hasn't elapsed. diff --git a/Tests/BlurtEngineTests/DictationPerformanceTests.swift b/Tests/BlurtEngineTests/DictationPerformanceTests.swift index a0f0511a..3704341e 100644 --- a/Tests/BlurtEngineTests/DictationPerformanceTests.swift +++ b/Tests/BlurtEngineTests/DictationPerformanceTests.swift @@ -30,7 +30,7 @@ final class DictationPerformanceTests: XCTestCase { private func makeSession() -> DictationSession { DictationSession( mic: StubMicCapture(), - transcriber: StubTranscriber(mode: .yieldChunks(["hello world"])), + transcriber: StubTranscriber(mode: .transcript("hello world")), injector: StubInjector()) } diff --git a/Tests/BlurtEngineTests/DictationSessionTests.swift b/Tests/BlurtEngineTests/DictationSessionTests.swift index 4cbf8018..c9ba04b9 100644 --- a/Tests/BlurtEngineTests/DictationSessionTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionTests.swift @@ -9,7 +9,7 @@ struct DictationSessionTests { @Test("press from idle transitions to recording") func pressIdleToRecording() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["hello"])) + let stt = StubTranscriber(mode: .transcript("hello")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -27,7 +27,7 @@ extension DictationSessionTests { func happyPath() async throws { let mic = StubMicCapture() // The Sync API returns the already-cleaned transcript in one response. - let stt = StubTranscriber(mode: .yieldChunks(["Hello world."])) + let stt = StubTranscriber(mode: .transcript("Hello world.")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -47,7 +47,7 @@ extension DictationSessionTests { // Below SyncSTTLimits.minSamples (1600 at 16 kHz) — an accidental brief tap // the Sync endpoint would reject with a 400. await mic.setSamples([0.0, 0.1, 0.2]) - let stt = StubTranscriber(mode: .yieldChunks(["should not be used"])) + let stt = StubTranscriber(mode: .transcript("should not be used")) let injector = StubInjector() let session = DictationSession(mic: mic, transcriber: stt, injector: injector) @@ -134,7 +134,7 @@ extension DictationSessionTests { struct Boom: Error {} let mic = StubMicCapture() await mic.setStartError(Boom()) - let stt = StubTranscriber(mode: .yieldChunks(["never"])) + let stt = StubTranscriber(mode: .transcript("never")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -153,7 +153,7 @@ extension DictationSessionTests { func emptyTranscriptReturnsToIdle() async throws { let mic = StubMicCapture() // Sync API yielded only whitespace (e.g. silence) — nothing to inject. - let stt = StubTranscriber(mode: .yieldChunks([" "])) + let stt = StubTranscriber(mode: .transcript(" ")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -171,7 +171,7 @@ extension DictationSessionTests { func injectorFailureSurfaces() async throws { struct Boom: Error {} let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hello world."])) + let stt = StubTranscriber(mode: .transcript("Hello world.")) let injector = StubInjector() await injector.setError(Boom()) let session = DictationSession( @@ -188,7 +188,7 @@ extension DictationSessionTests { @Test("injector BlurtError surfaces verbatim, not relabeled as targetAppLost") func injectorBlurtErrorPassesThrough() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hello world."])) + let stt = StubTranscriber(mode: .transcript("Hello world.")) let injector = StubInjector() // A typed BlurtError from the injector must reach the UI as-is rather // than being flattened to .targetAppLost. @@ -207,7 +207,7 @@ extension DictationSessionTests { @Test("no editable target surfaces the quiet .noTarget phase, not a failure") func noEditableTargetIsQuiet() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hello world."])) + let stt = StubTranscriber(mode: .transcript("Hello world.")) let injector = StubInjector() // The injector left the transcript on the clipboard and signalled there was // nowhere to type — the session should treat that as the quiet .noTarget @@ -227,7 +227,7 @@ extension DictationSessionTests { struct Boom: Error {} let mic = StubMicCapture() await mic.setStopError(Boom()) - let stt = StubTranscriber(mode: .yieldChunks(["never"])) + let stt = StubTranscriber(mode: .transcript("never")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -248,7 +248,7 @@ extension DictationSessionTests { @Test("auto-release after maxRecordingSeconds") func autoRelease() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Timed out text."])) + let stt = StubTranscriber(mode: .transcript("Timed out text.")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector, @@ -264,7 +264,7 @@ extension DictationSessionTests { @Test("phaseStream yields the current phase then transitions through to terminal") func phaseStreamObservesTransitions() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hi."])) + let stt = StubTranscriber(mode: .transcript("Hi.")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector @@ -289,7 +289,7 @@ extension DictationSessionTests { @Test("cancel during active recording stops mic, discards audio, and transitions to .cancelled") func cancelDuringRecording() async throws { let mic = StubMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["Hello"])) + let stt = StubTranscriber(mode: .transcript("Hello")) let injector = StubInjector() let session = DictationSession( mic: mic, transcriber: stt, injector: injector diff --git a/Tests/BlurtEngineTests/FocusCaptureTests.swift b/Tests/BlurtEngineTests/FocusCaptureTests.swift index 1ddb2d00..1f461e92 100644 --- a/Tests/BlurtEngineTests/FocusCaptureTests.swift +++ b/Tests/BlurtEngineTests/FocusCaptureTests.swift @@ -8,21 +8,6 @@ import Testing /// Accessibility trust, so they're exercised by running the app, not here. @Suite("FocusCapture helpers") struct FocusCaptureTests { - // MARK: normalized - - @Test("normalized trims surrounding whitespace") - func normalizedTrims() { - #expect(FocusCapture.normalized(" hello ") == "hello") - #expect(FocusCapture.normalized("line\n") == "line") - } - - @Test("normalized maps nil/empty/blank to nil") - func normalizedBlank() { - #expect(FocusCapture.normalized(nil) == nil) - #expect(FocusCapture.normalized("") == nil) - #expect(FocusCapture.normalized(" \n\t ") == nil) - } - // MARK: selectLabel @Test("selectLabel prefers placeholder when present") diff --git a/Tests/BlurtEngineTests/HotkeyRaceTests.swift b/Tests/BlurtEngineTests/HotkeyRaceTests.swift index 519aebb5..1425c175 100644 --- a/Tests/BlurtEngineTests/HotkeyRaceTests.swift +++ b/Tests/BlurtEngineTests/HotkeyRaceTests.swift @@ -17,7 +17,7 @@ struct HotkeyRaceTests { @Test("release during mic.start is honored, not dropped") func releaseDuringStartIsHonored() async throws { let mic = GatedMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["hi"])) + let stt = StubTranscriber(mode: .transcript("hi")) let injector = StubInjector() let session = DictationSession(mic: mic, transcriber: stt, injector: injector) @@ -40,7 +40,7 @@ struct HotkeyRaceTests { @Test("cancel during mic.start is honored, not dropped") func cancelDuringStartIsHonored() async throws { let mic = GatedMicCapture() - let stt = StubTranscriber(mode: .yieldChunks(["hi"])) + let stt = StubTranscriber(mode: .transcript("hi")) let injector = StubInjector() let session = DictationSession(mic: mic, transcriber: stt, injector: injector) @@ -95,9 +95,9 @@ private actor GatedMicCapture: MicCaptureProtocol { nonisolated func stop() async throws -> [Float] { await incStop() - // Above SyncSTTLimits.minSamples (1600) so the transcript isn't dropped by - // the too-short guard — this suite exercises the press/release race, not it. - return Array(repeating: 0, count: 1600) + // Above SyncSTTLimits.minSamples so the transcript isn't dropped by the + // too-short guard — this suite exercises the press/release race, not it. + return Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) } private func incStop() { stopCalls += 1 } diff --git a/Tests/BlurtEngineTests/MemoryLeakTests.swift b/Tests/BlurtEngineTests/MemoryLeakTests.swift index 311723cd..15871254 100644 --- a/Tests/BlurtEngineTests/MemoryLeakTests.swift +++ b/Tests/BlurtEngineTests/MemoryLeakTests.swift @@ -30,7 +30,7 @@ struct MemoryLeakTests { await expectNoLeak("DictationSession") { let session = DictationSession( mic: StubMicCapture(), - transcriber: StubTranscriber(mode: .yieldChunks(["Hello."])), + transcriber: StubTranscriber(mode: .transcript("Hello.")), injector: StubInjector() ) await session.press() diff --git a/Tests/BlurtEngineTests/ReleaseRaceTests.swift b/Tests/BlurtEngineTests/ReleaseRaceTests.swift index b98705f3..8278cf5d 100644 --- a/Tests/BlurtEngineTests/ReleaseRaceTests.swift +++ b/Tests/BlurtEngineTests/ReleaseRaceTests.swift @@ -16,7 +16,7 @@ struct ReleaseRaceTests { @Test("a second release during mic.stop is dropped, not run twice") func reentrantReleaseRunsPipelineOnce() async throws { let mic = GatedStopMic() - let stt = StubTranscriber(mode: .yieldChunks(["hi"])) + let stt = StubTranscriber(mode: .transcript("hi")) let injector = StubInjector() let session = DictationSession(mic: mic, transcriber: stt, injector: injector) @@ -68,9 +68,9 @@ private actor GatedStopMic: MicCaptureProtocol { finishWaiters.append(c) } } - // Above SyncSTTLimits.minSamples (1600) so the transcript isn't dropped by - // the too-short guard — this suite exercises the release race, not it. - return Array(repeating: 0, count: 1600) + // Above SyncSTTLimits.minSamples so the transcript isn't dropped by the + // too-short guard — this suite exercises the release race, not it. + return Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) } func waitUntilStopEntered() async { diff --git a/Tests/BlurtEngineTests/SoundPackStoreTests.swift b/Tests/BlurtEngineTests/SoundPackStoreTests.swift index a07440ea..a9020678 100644 --- a/Tests/BlurtEngineTests/SoundPackStoreTests.swift +++ b/Tests/BlurtEngineTests/SoundPackStoreTests.swift @@ -5,13 +5,6 @@ import Testing @Suite("SoundPackStore") struct SoundPackStoreTests { - private func freshDefaults() -> UserDefaults { - let name = "SoundPackStoreTests-\(UUID().uuidString)" - let d = UserDefaults(suiteName: name)! - d.removePersistentDomain(forName: name) - return d - } - @Test("defaults to ORCHESTRA when unset") func defaultsToOrchestra() { let store = SoundPackStore(defaults: freshDefaults()) diff --git a/Tests/BlurtEngineTests/Stubs/FreshDefaults.swift b/Tests/BlurtEngineTests/Stubs/FreshDefaults.swift new file mode 100644 index 00000000..f7145ef5 --- /dev/null +++ b/Tests/BlurtEngineTests/Stubs/FreshDefaults.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A throwaway `UserDefaults` suite that never touches the real app domain, for +/// suites exercising the UserDefaults-backed stores (`TriggerKeyStore`, +/// `SoundPackStore`). The UUID keeps parallel tests isolated from each other. +func freshDefaults(_ label: String = #fileID) -> UserDefaults { + let name = "\(label)-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults +} diff --git a/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift b/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift index eef080a5..9102759f 100644 --- a/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift +++ b/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift @@ -5,9 +5,11 @@ import Foundation actor StubMicCapture: MicCaptureProtocol { var startCalls = 0 var stopCalls = 0 - // Above `SyncSTTLimits.minSamples` (16 kHz × 0.1 s = 1600) so the default - // press→release flow clears the too-short guard and reaches transcribe. - var samplesToReturn: [Float] = Array(repeating: 0, count: 1600) + // Comfortably above `SyncSTTLimits.minSamples` so the default press→release + // flow clears the too-short guard and reaches transcribe, tracking the engine + // rule so a raised floor can't silently start dropping the canned audio. + var samplesToReturn: [Float] = Array( + repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) var startError: (any Error & Sendable)? var stopError: (any Error & Sendable)? diff --git a/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift b/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift index 4a66c1ed..bcd4501d 100644 --- a/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift +++ b/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift @@ -4,7 +4,7 @@ import Foundation actor StubTranscriber: TranscriberProtocol { enum Mode { - case yieldChunks([String]) + case transcript(String) case throwError(any Error & Sendable) } private var mode: Mode @@ -13,8 +13,8 @@ actor StubTranscriber: TranscriberProtocol { func transcribe(samples: [Float], sampleRate: Int, context: TranscriptionContext?) async throws -> String { switch mode { - case .yieldChunks(let chunks): - return chunks.joined() + case .transcript(let transcript): + return transcript case .throwError(let err): throw err } diff --git a/Tests/BlurtEngineTests/TriggerKeyStoreTests.swift b/Tests/BlurtEngineTests/TriggerKeyStoreTests.swift index 13bfb594..b540c9dd 100644 --- a/Tests/BlurtEngineTests/TriggerKeyStoreTests.swift +++ b/Tests/BlurtEngineTests/TriggerKeyStoreTests.swift @@ -5,14 +5,6 @@ import Testing @Suite("TriggerKeyStore") struct TriggerKeyStoreTests { - private func freshDefaults() -> UserDefaults { - // A throwaway suite keeps the test from touching the real app domain. - let name = "TriggerKeyStoreTests-\(UUID().uuidString)" - let d = UserDefaults(suiteName: name)! - d.removePersistentDomain(forName: name) - return d - } - @Test("defaults to right command when unset") func defaultsToRightCommand() { let store = TriggerKeyStore(defaults: freshDefaults()) diff --git a/scripts/check.sh b/scripts/check.sh index 50cbbf96..5f855c69 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -44,12 +44,13 @@ XCTEST_BIN="$XCTEST_BUNDLE/Contents/MacOS/$(basename "$XCTEST_BUNDLE" .xctest)" if command -v python3 >/dev/null 2>&1 && [ -f "$PROFDATA" ] && [ -f "$XCTEST_BIN" ]; then # Exclusions (so the figure reflects deterministically-testable engine code): # - Tests/ : test files themselves, not shipping code. - # - MicCapture.swift : the AVAudioEngine capture actor. It needs a real audio - # device, so it can't run in CI (its integration test, - # MicCaptureLevelsTests, is env-gated for the same - # reason). Its pure DSP lives in AudioDSP.swift, which IS - # covered. Keep this list tight — exclude only code that - # genuinely cannot be exercised without hardware. + # - MicCapture.swift : the AVAudioRecorder capture actor. It needs a real + # audio device, so it can't run in CI (its integration + # test, MicCaptureLevelsTests, is env-gated for the same + # reason). Its pure meter math lives in + # MicCapture+Meter.swift, which IS covered. Keep this + # list tight — exclude only code that genuinely cannot + # be exercised without hardware. COVERAGE="$(xcrun llvm-cov export -summary-only -instr-profile "$PROFDATA" "$XCTEST_BIN" \ -ignore-filename-regex='Tests/|Audio/MicCapture\.swift' \ | python3 -c 'import sys,json; print(round(json.load(sys.stdin)["data"][0]["totals"]["lines"]["percent"],2))')" diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 44882b6c..96182b71 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -30,9 +30,8 @@ for arg in "$@"; do esac done -info() { printf '\033[34m▸\033[0m %s\n' "$*"; } -step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } -die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" # Submit an artifact (app zip or DMG) to Apple's notary service, wait for the # result, and die on anything but Accepted. Writes per-artifact result + log @@ -85,7 +84,7 @@ xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ || die "working tree dirty — commit or stash before building a release artifact" step "Read version" -VERSION="$(awk '/CFBundleShortVersionString:/ {gsub(/"/,"",$2); print $2; exit}' "$APP_DIR/project.yml")" +VERSION="$(parse_short_version <"$APP_DIR/project.yml")" [ -n "$VERSION" ] || die "could not parse CFBundleShortVersionString from $APP_DIR/project.yml" info "version: $VERSION" diff --git a/scripts/release-bump.sh b/scripts/release-bump.sh index ee5dc74b..a797fb2e 100755 --- a/scripts/release-bump.sh +++ b/scripts/release-bump.sh @@ -11,13 +11,12 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" APP_DIR="$REPO_ROOT/App/Blurt" PROJECT_YML="$APP_DIR/project.yml" -info() { printf '\033[34m▸\033[0m %s\n' "$*"; } -step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } -die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" [ $# -eq 1 ] || die "usage: $(basename "$0") X.Y.Z" NEW_VERSION="$1" -[[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "version must be X.Y.Z (got: $NEW_VERSION)" +is_semver "$NEW_VERSION" || die "version must be X.Y.Z (got: $NEW_VERSION)" step "Preflight" command -v xcodegen >/dev/null 2>&1 || die "missing required tool: xcodegen" @@ -27,16 +26,14 @@ if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then die "working tree dirty — commit or stash before bumping" fi -CURRENT_VERSION="$(awk '/CFBundleShortVersionString:/ {gsub(/"/,"",$2); print $2; exit}' "$PROJECT_YML")" -CURRENT_BUILD="$(awk '/CFBundleVersion:/ {gsub(/"/,"",$2); print $2; exit}' "$PROJECT_YML")" +CURRENT_VERSION="$(parse_short_version <"$PROJECT_YML")" +CURRENT_BUILD="$(parse_bundle_version <"$PROJECT_YML")" [ -n "$CURRENT_VERSION" ] || die "could not parse CFBundleShortVersionString from $PROJECT_YML" [ -n "$CURRENT_BUILD" ] || die "could not parse CFBundleVersion from $PROJECT_YML" [[ "$CURRENT_BUILD" =~ ^[0-9]+$ ]] || die "CFBundleVersion is not an integer: $CURRENT_BUILD" [ "$NEW_VERSION" != "$CURRENT_VERSION" ] || die "version $NEW_VERSION is already current" - -LOWER="$(printf '%s\n%s\n' "$CURRENT_VERSION" "$NEW_VERSION" | sort -V | head -n1)" -[ "$LOWER" = "$CURRENT_VERSION" ] || die "$NEW_VERSION is not greater than current $CURRENT_VERSION" +version_gt "$NEW_VERSION" "$CURRENT_VERSION" || die "$NEW_VERSION is not greater than current $CURRENT_VERSION" if git -C "$REPO_ROOT" rev-parse "v$NEW_VERSION" >/dev/null 2>&1; then die "tag v$NEW_VERSION already exists locally" @@ -56,8 +53,8 @@ sed -i.bak -E \ "$PROJECT_YML" rm -f "$PROJECT_YML.bak" -VERIFY_VERSION="$(awk '/CFBundleShortVersionString:/ {gsub(/"/,"",$2); print $2; exit}' "$PROJECT_YML")" -VERIFY_BUILD="$(awk '/CFBundleVersion:/ {gsub(/"/,"",$2); print $2; exit}' "$PROJECT_YML")" +VERIFY_VERSION="$(parse_short_version <"$PROJECT_YML")" +VERIFY_BUILD="$(parse_bundle_version <"$PROJECT_YML")" [ "$VERIFY_VERSION" = "$NEW_VERSION" ] || die "edit failed: short version is $VERIFY_VERSION, expected $NEW_VERSION" [ "$VERIFY_BUILD" = "$NEW_BUILD" ] || die "edit failed: build is $VERIFY_BUILD, expected $NEW_BUILD" diff --git a/scripts/release-install.sh b/scripts/release-install.sh index b80aaa0c..0ef72015 100755 --- a/scripts/release-install.sh +++ b/scripts/release-install.sh @@ -15,12 +15,8 @@ APP_DIR="$REPO_ROOT/App/Blurt" BUILD_ROOT="$REPO_ROOT/build/release" DERIVED="$BUILD_ROOT/derived" -info() { printf '\033[34m▸\033[0m %s\n' "$*"; } -step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } -die() { - printf '\033[31m✗\033[0m %s\n' "$*" >&2 - exit 1 -} +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" @@ -29,7 +25,7 @@ for cmd in xcrun hdiutil codesign ditto awk; do command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd" done -VERSION="$(awk '/CFBundleShortVersionString:/ {gsub(/"/, "", $2); print $2; exit}' "$APP_DIR/project.yml")" +VERSION="$(parse_short_version <"$APP_DIR/project.yml")" [ -n "$VERSION" ] || die "could not parse CFBundleShortVersionString from $APP_DIR/project.yml" info "version: $VERSION" diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh new file mode 100644 index 00000000..737fd483 --- /dev/null +++ b/scripts/release-lib.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Shared helpers for the release scripts (release.sh, release-bump.sh, +# release-build.sh, release-install.sh, release-publish.sh). Sourced, never +# executed. Everything here must stay side-effect-free at source time — +# release.test.sh sources release.sh (which sources this) to unit-test the +# pure helpers. + +# --- logging --- + +info() { printf '\033[34m▸\033[0m %s\n' "$*"; } +step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } +die() { + printf '\033[31m✗\033[0m %s\n' "$*" >&2 + exit 1 +} + +# --- pure version helpers (unit-tested by scripts/release.test.sh) --- + +# True if $1 looks like X.Y.Z (digits only). +is_semver() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } + +# True if version $1 is strictly greater than version $2 (semver-ordered). +version_gt() { + [ "$1" != "$2" ] || return 1 + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] +} + +# Read CFBundleShortVersionString from project.yml content on stdin. The one +# definition of the version-read rule every release script gates on. +parse_short_version() { + awk '/CFBundleShortVersionString:/ {gsub(/"/, "", $2); print $2; exit}' +} + +# Read CFBundleVersion (the integer build number) from project.yml on stdin. +parse_bundle_version() { + awk '/CFBundleVersion:/ {gsub(/"/, "", $2); print $2; exit}' +} + +# Read the full commit SHA from build-info.txt content on stdin. +parse_build_info_git_sha() { + awk '/^git:[[:space:]]+/ {print $2; exit}' +} diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index f071c5a1..a322d787 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -18,16 +18,15 @@ for arg in "$@"; do esac done -info() { printf '\033[34m▸\033[0m %s\n' "$*"; } -step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } -die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } +# shellcheck source=scripts/release-lib.sh +source "$REPO_ROOT/scripts/release-lib.sh" step "Preflight" for cmd in gh git xcrun awk; do command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd" done -VERSION="$(awk '/CFBundleShortVersionString:/ {gsub(/"/,"",$2); print $2; exit}' "$APP_DIR/project.yml")" +VERSION="$(parse_short_version <"$APP_DIR/project.yml")" [ -n "$VERSION" ] || die "could not parse version" DMG="$BUILD_ROOT/Blurt-$VERSION.dmg" DSYM_ZIP="$BUILD_ROOT/Blurt-$VERSION.app.dSYM.zip" diff --git a/scripts/release.sh b/scripts/release.sh index 26f0edf6..e197a9e6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -15,26 +15,13 @@ # # Re-run the same command after merging the PR to resume. -# --- pure helpers (sourced by scripts/release.test.sh; keep side-effect-free) --- - -# True if $1 looks like X.Y.Z (digits only). -is_semver() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } - -# True if version $1 is strictly greater than version $2 (semver-ordered). -version_gt() { - [ "$1" != "$2" ] || return 1 - [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] -} - -# Read CFBundleShortVersionString from project.yml content on stdin. -parse_short_version() { - awk '/CFBundleShortVersionString:/ {gsub(/"/, "", $2); print $2; exit}' -} +# Shared logging + pure version helpers (is_semver, version_gt, +# parse_short_version, parse_build_info_git_sha) live in release-lib.sh so all +# the release scripts share one definition. +# shellcheck source=scripts/release-lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-lib.sh" -# Read the full commit SHA from build-info.txt content on stdin. -parse_build_info_git_sha() { - awk '/^git:[[:space:]]+/ {print $2; exit}' -} +# --- pure helpers (sourced by scripts/release.test.sh; keep side-effect-free) --- # Decide the run given main's current version ($1) and the target ($2). # Echoes "publish" (target already on main) or "bump" (target is ahead). @@ -77,14 +64,6 @@ default_target() { fi } -# --- logging (matches the sibling scripts) --- -info() { printf '\033[34m▸\033[0m %s\n' "$*"; } -step() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } -die() { - printf '\033[31m✗\033[0m %s\n' "$*" >&2 - exit 1 -} - # --- IO helpers (side-effecting; verified by a real release, not unit tests) --- # Current version on origin/main, without disturbing the working tree. diff --git a/scripts/release.test.sh b/scripts/release.test.sh index eb146fda..b542a141 100755 --- a/scripts/release.test.sh +++ b/scripts/release.test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Unit tests for the pure helpers in release.sh. Plain bash; no Mac/network -# dependencies. Run directly (scripts/release.test.sh) or via scripts/check.sh. +# Unit tests for the pure helpers in release.sh and release-lib.sh (pulled in +# transitively). Plain bash; no Mac/network dependencies. Run directly +# (scripts/release.test.sh) or via scripts/check.sh. set -uo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -48,6 +49,10 @@ check "parses version" "0.1.5" \ check "takes first match only" "0.1.5" \ "$(printf ' CFBundleShortVersionString: "0.1.5"\n CFBundleShortVersionString: "9.9.9"\n' | parse_short_version)" +echo "== parse_bundle_version ==" +check "parses build number" "6" \ + "$(printf ' CFBundleShortVersionString: "0.1.5"\n CFBundleVersion: "6"\n' | parse_bundle_version)" + echo "== parse_build_info_git_sha ==" check "parses build provenance sha" "0123456789abcdef0123456789abcdef01234567" \ "$(printf 'Blurt 0.1.7\nbuilt: today\ngit: 0123456789abcdef0123456789abcdef01234567 (0123456)\n' | parse_build_info_git_sha)" From d3c14d050ea605b851522a04c935a73f905eb146 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 22:04:44 +0000 Subject: [PATCH 2/4] fix: apply confirmed /code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (all confirmed by adversarial verification): - DictationSession: a cancel() during transcription no longer gets repainted as a red .failed (and falsely reported to Datadog) when the torn-down URLSession request throws URLError(.cancelled) — transcribe's catch now leaves a claimed .cancelled phase alone. Same guard for the post-insert .pasted overwrite and the short-clip guard's .idle overwrite, so cancel() reliably keeps the phase it claims. New CancelRaceTests case drives a transcriber that throws on cancellation like the real one. - AppDelegate.updatePromptHostWindow: removed the force-unwrap that crashed when the user closed the main window before the launch update check finished (the recreated scene's NSWindow only materializes a run-loop pass later, and no other window canBecomeMain). AutoUpdater now takes an optional host window, retries a beat later, and falls back to an app-modal alert instead of crashing. - TranscriptionPrompt: the key-terms Keywords clause — the one input with no upstream length cap — is now fitted (whole terms) to the Sync API's 4096- character prompt cap, so a huge Settings list can't make every dictation fail with a 400. Cap is now a named constant, asserted in tests. - FocusCapture.priorSlice: the caret is a UTF-16 offset (AX range domain) but was used in Character arithmetic, so emoji before the cursor made "prior text" include text after the caret. Now resolved through the UTF-16 view with a boundary-safe fallback; regression tests added. Confirmed cleanup: - Overlay: .pasted/.noTarget hover tooltips now read OverlayUIState.accessibilityLabel instead of drifted hard-coded copies; the triplicated dismiss sequence is one dismissPanel(); stale shadow-size comment fixed. - BlurtIdentity.subsystem: single definition of "dev.alex.blurt", adopted by all five Logger/OSSignposter subsystems, the Keychain service, the DictationLog queue label, and Monitoring's install-id key; reset-install.sh comments point at the constant. Stale XCTOSSignpostMetric claim in DictationSession's header corrected. - SyncSTTLimits.minSamples is a stored constant (every caller passed SyncSTTLimits.sampleRate to the old function parameter). - CueSoundPlayer: the cue-chime subsystem (players, volume, prime/preview, recording-edge detection) extracted out of AppCoordinator's body. - PersistedSettings.allDefaultsKeys: engine-owned roster of settings keys; AppDelegate's UI-test reset iterates it instead of a hand-kept list. - AppDelegate: surfaceMainWindow() returns the window it raised (drops the duplicated identifier lookup); openWindowByID is captured once by the launch-evaluated SettingsMenuButton (two redundant onAppear re-captures removed). - AssemblyAITranscriber: shared static JSONEncoder (mirrors the decoder's documented rationale); single-caller generic decode inlined; stale "peak-normalizes to 0.95" comment in PCMEncoder dropped. - release-lib.sh: require_clean_tree / require_project_version / tag_exists_locally / tag_exists_on_origin replace the guards hand-rolled across the four release scripts; dead run_xcodebuild() removed from check.sh. Verified here: bash -n on all scripts and scripts/release.test.sh (pass). Swift changes need scripts/check.sh on a Mac — no macOS toolchain in this sandbox; CI is the authority. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012XWdVUaLE9RXmEfbuL3Lk5 --- App/Blurt/Blurt/AppCoordinator.swift | 166 ++++++++++-------- App/Blurt/Blurt/AppDelegate.swift | 29 +-- App/Blurt/Blurt/Hotkey/DictationKeyTap.swift | 3 +- App/Blurt/Blurt/Monitoring/Monitoring.swift | 3 +- App/Blurt/Blurt/Overlay/OverlayView.swift | 9 +- .../Overlay/OverlayWindowController.swift | 24 ++- App/Blurt/Blurt/UITestSupport.swift | 2 +- App/Blurt/Blurt/Update/AutoUpdater.swift | 28 ++- .../Blurt/Wizard/WizardWindowController.swift | 12 +- Sources/BlurtEngine/Audio/MicCapture.swift | 2 +- Sources/BlurtEngine/BlurtIdentity.swift | 11 ++ Sources/BlurtEngine/Config/APIKeyStore.swift | 2 +- .../Config/PersistedSettings.swift | 14 ++ .../FocusCapture/FocusCapture.swift | 21 ++- .../BlurtEngine/Pipeline/DictationLog.swift | 2 +- .../Pipeline/DictationSession.swift | 73 +++++--- .../STT/AssemblyAITranscriber.swift | 19 +- Sources/BlurtEngine/STT/PCMEncoder.swift | 2 +- Sources/BlurtEngine/STT/SyncSTTLimits.swift | 11 +- .../BlurtEngine/STT/TranscriptionPrompt.swift | 34 +++- Tests/BlurtEngineTests/CancelRaceTests.swift | 33 +++- .../BlurtEngineTests/FocusCaptureTests.swift | 15 ++ Tests/BlurtEngineTests/HotkeyRaceTests.swift | 2 +- Tests/BlurtEngineTests/ReleaseRaceTests.swift | 2 +- .../Stubs/StubMicCapture.swift | 3 +- .../TranscriptionPromptTests.swift | 15 +- scripts/check.sh | 5 - scripts/release-build.sh | 6 +- scripts/release-bump.sh | 8 +- scripts/release-install.sh | 3 +- scripts/release-lib.sh | 29 +++ scripts/release-publish.sh | 11 +- scripts/release.sh | 3 +- scripts/reset-install.sh | 10 +- 34 files changed, 398 insertions(+), 214 deletions(-) create mode 100644 Sources/BlurtEngine/BlurtIdentity.swift create mode 100644 Sources/BlurtEngine/Config/PersistedSettings.swift diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index 3132ab4f..ca92470e 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -142,7 +142,7 @@ final class AppCoordinator { if overlay == nil { overlay = OverlayWindowController() } // Pre-roll the start/stop cues now that the app is ready, so the first // chime's audio-queue setup never stalls the recording pill. - primeSounds() + cues.prime() } /// Hides the overlay pill. Called by the wizard when the app stops being fully @@ -257,94 +257,28 @@ final class AppCoordinator { // MARK: - Dictation render - private var wasRecording = false - private var recordStartSound: AVAudioPlayer? - private var recordStopSound: AVAudioPlayer? - - /// The cues are deliberate UI accents, not music — they are normalized to a - /// hot peak, so play them well below full scale so they read as a soft chime - /// rather than blasting at the system output level. - private static let cueVolume: Float = 0.35 - - /// (Re)loads the start/stop cue players for the currently selected sound pack. - /// `.none` (or a missing file) leaves a player nil, which `play(_:)` skips, so - /// "no sound" needs no extra branching on the hot path. - private func loadSounds() { - let pack = SoundPackStore().soundPack - recordStartSound = pack.startFileName.flatMap(Self.bundledSound(named:)) - recordStopSound = pack.stopFileName.flatMap(Self.bundledSound(named:)) - recordStartSound?.volume = Self.cueVolume - recordStopSound?.volume = Self.cueVolume - } - - /// Loads a bundled chime (`Resources/Sounds/.m4a`) fully into memory. - /// `AVAudioPlayer(contentsOf:)` decodes the AAC up front — unlike - /// `NSSound(…byReference: true)`, which deferred the disk read to the first - /// `play()` and so stalled the recording pill the first time you dictated. The - /// players are then pre-rolled in `primeSounds()` (called from the ready - /// transition) so even the audio-queue setup is paid before the hot path. - private static func bundledSound(named name: String) -> AVAudioPlayer? { - guard let url = Bundle.main.url(forResource: name, withExtension: "m4a") else { return nil } - return try? AVAudioPlayer(contentsOf: url) - } - - /// Pre-rolls the cue players so the first start/stop chime adds no latency to - /// the pill. Called from `showOverlay()` — the "app is ready" transition — so - /// the audio queue is allocated and the buffers are filled well before the - /// first dictation. Idempotent: re-priming an already-prepared player is cheap. - private func primeSounds() { - loadSounds() - recordStartSound?.prepareToPlay() - recordStopSound?.prepareToPlay() - } + /// The record start/stop chimes. Owned by a dedicated player (not inlined + /// here) so the audio concern stays out of the session↔UI wiring. + private let cues = CueSoundPlayer() /// Called when the user changes the sound pack in Settings: reload the cue - /// players (and re-prime them) so the next start/stop uses the new voice, then - /// preview the new voice so the choice is audible immediately. + /// players so the next start/stop uses the new voice, and preview it so the + /// choice is audible immediately. func soundPackChanged() { - primeSounds() - previewCues() - } - - /// Plays the selected pack's start, then stop cues a beat apart, as a preview - /// when switching voices in Settings. Silent for the `.none` pack (all players - /// are nil, which `play(_:)` skips). - private func previewCues() { - play(recordStartSound) - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(380)) - play(recordStopSound) - } - } - - /// Plays a cue from the start, without ever blocking the caller. Rewinding - /// first means a cue replays cleanly even if the previous play hasn't been - /// reset, and keeping this off the visual path (callers `show` the pill first) - /// guarantees the sound never delays the overlay. - private func play(_ sound: AVAudioPlayer?) { - guard let sound else { return } - sound.currentTime = 0 - sound.play() + cues.packChanged() } private func render(_ phase: PipelinePhase) { - let isRecording = phase == .recording - - // Reveal the pill first, then fire the cue: `play()` must never sit in front - // of the visual state change. Pure phase→pill mapping lives in the engine - // (unit-tested there); .failed resolves to .error, which the pill flashes red - // then auto-reverts to idle. + // Reveal the pill first, then fire the cue: the sound must never sit in + // front of the visual state change. Pure phase→pill mapping lives in the + // engine (unit-tested there); .failed resolves to .error, which the pill + // flashes red then auto-reverts to idle. overlay?.show(state: phase.overlayState) // Mirror the phase onto the menu bar indicator (mapping lives in the engine, // unit-tested alongside `overlayState`). menuBarStatus = phase.menuBarStatus - if isRecording && !wasRecording { - play(recordStartSound) - } else if !isRecording && wasRecording { - play(recordStopSound) - } - wasRecording = isRecording + cues.transition(isRecording: phase == .recording) // A .failed phase is a handled error (it doesn't crash the app), so the // crash reporter never sees it — report the genuine faults here. @@ -390,3 +324,79 @@ final class AppCoordinator { } } } + +/// Owns the record start/stop cue chimes: loading the selected pack, pre-rolling +/// so the first chime never stalls the recording pill, previewing on a pack +/// change, and firing on the recording edge. Kept out of `AppCoordinator`'s body +/// so chime behavior can change without churning the session↔UI wiring. +@MainActor +final class CueSoundPlayer { + private var startSound: AVAudioPlayer? + private var stopSound: AVAudioPlayer? + private var wasRecording = false + + /// The cues are deliberate UI accents, not music — they are normalized to a + /// hot peak, so play them well below full scale so they read as a soft chime + /// rather than blasting at the system output level. + private static let cueVolume: Float = 0.35 + + /// (Re)loads and pre-rolls the cue players for the currently selected sound + /// pack, so the first start/stop chime adds no latency to the pill. Called + /// from the "app is ready" transition — the audio queue is allocated and the + /// buffers filled well before the hot path. `.none` (or a missing file) + /// leaves a player nil, which `play(_:)` skips, so "no sound" needs no extra + /// branching. Idempotent: re-priming an already-prepared player is cheap. + func prime() { + let pack = SoundPackStore().soundPack + startSound = pack.startFileName.flatMap(Self.bundledSound(named:)) + stopSound = pack.stopFileName.flatMap(Self.bundledSound(named:)) + startSound?.volume = Self.cueVolume + stopSound?.volume = Self.cueVolume + startSound?.prepareToPlay() + stopSound?.prepareToPlay() + } + + /// Reloads the players for a newly selected pack and previews the new voice + /// (start, then stop a beat apart) so the choice is audible immediately. + /// Silent for the `.none` pack (all players are nil, which `play(_:)` skips). + func packChanged() { + prime() + play(startSound) + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(380)) + play(stopSound) + } + } + + /// Fires the start/stop cue on the recording edge. Call once per rendered + /// phase; only the idle↔recording transitions make a sound. + func transition(isRecording: Bool) { + if isRecording && !wasRecording { + play(startSound) + } else if !isRecording && wasRecording { + play(stopSound) + } + wasRecording = isRecording + } + + /// Loads a bundled chime (`Resources/Sounds/.m4a`) fully into memory. + /// `AVAudioPlayer(contentsOf:)` decodes the AAC up front — unlike + /// `NSSound(…byReference: true)`, which deferred the disk read to the first + /// `play()` and so stalled the recording pill the first time you dictated. + /// The players are then pre-rolled in `prime()` so even the audio-queue setup + /// is paid before the hot path. + private static func bundledSound(named name: String) -> AVAudioPlayer? { + guard let url = Bundle.main.url(forResource: name, withExtension: "m4a") else { return nil } + return try? AVAudioPlayer(contentsOf: url) + } + + /// Plays a cue from the start, without ever blocking the caller. Rewinding + /// first means a cue replays cleanly even if the previous play hasn't been + /// reset, and keeping this off the visual path (callers reveal the pill + /// first) guarantees the sound never delays the overlay. + private func play(_ sound: AVAudioPlayer?) { + guard let sound else { return } + sound.currentTime = 0 + sound.play() + } +} diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 2364667d..9ef65510 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -38,25 +38,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// possibly minimized to the Dock), raise it directly: `openWindow(id:)` focuses /// the scene but won't deminiaturize a window the user sent to the Dock or /// reliably re-front an existing one. Only when no main window exists (the user - /// closed it) do we recreate it via the scene. - @MainActor func surfaceMainWindow() { + /// closed it) do we recreate it via the scene. Returns the window it raised, + /// or nil when it had to ask the scene to recreate one — the new NSWindow only + /// materializes on a later run-loop pass. + @MainActor @discardableResult func surfaceMainWindow() -> NSWindow? { NSApp.activate() if let main = NSApp.windows.first(where: { $0.identifier?.rawValue == MainWindow.id }) { main.deminiaturize(nil) main.makeKeyAndOrderFront(nil) - } else { - openMainWindow() + return main } + openMainWindow() + return nil } /// Surfaces the main window and returns it to host the update prompt's sheet - /// (see `AutoUpdater.runAlert`). The main window scene always exists by the - /// time an update check completes, so this resolves to it (with the generic - /// `canBecomeMain` window as a belt-and-suspenders fallback). - @MainActor func updatePromptHostWindow() -> NSWindow { - surfaceMainWindow() - return NSApp.windows.first { $0.identifier?.rawValue == MainWindow.id } - ?? NSApp.windows.first { $0.canBecomeMain }! + /// (see `AutoUpdater.runAlert`). Nil when the user closed the main window + /// before the update check finished: the recreated scene's NSWindow doesn't + /// exist yet on this run-loop pass, and no other window qualifies (the overlay + /// is a borderless panel, so `canBecomeMain` is false). The caller retries or + /// falls back rather than this force-unwrapping into a crash. + @MainActor func updatePromptHostWindow() -> NSWindow? { + surfaceMainWindow() ?? NSApp.windows.first { $0.canBecomeMain } } /// True once the launch-time activation has run. @@ -124,13 +127,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if UITestMode.isActive { // Reset to a clean, preinstall state so every UI-test launch is // deterministic regardless of prior runs: clear the persisted settings - // (trigger key, sound pack, key terms) back to their defaults. The API + // (the engine-owned `PersistedSettings` roster) back to their defaults. The API // key already uses an in-memory store, and window state is handled test- // side. Guarded by UITestMode (only the runner passes -BlurtUITest), so a // normal launch is untouched — running the UI tests locally does reset // these, by design. let defaults = UserDefaults.standard - for key in [TriggerKeyStore.defaultsKey, SoundPackStore.defaultsKey, KeyTermsStore.defaultsKey] { + for key in PersistedSettings.allDefaultsKeys { defaults.removeObject(forKey: key) } coord = AppCoordinator( diff --git a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift index 44b47e05..e0af1e84 100644 --- a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift +++ b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift @@ -13,7 +13,8 @@ import os /// types nothing into the focused app, and combos must pass through so normal /// shortcuts keep working. final class DictationKeyTap { - private static let logger = Logger(subsystem: "dev.alex.blurt", category: "DictationKeyTap") + private static let logger = Logger( + subsystem: BlurtIdentity.subsystem, category: "DictationKeyTap") private let onStart: @Sendable () -> Void private let onStop: @Sendable () -> Void diff --git a/App/Blurt/Blurt/Monitoring/Monitoring.swift b/App/Blurt/Blurt/Monitoring/Monitoring.swift index 8b641053..49c41d99 100644 --- a/App/Blurt/Blurt/Monitoring/Monitoring.swift +++ b/App/Blurt/Blurt/Monitoring/Monitoring.swift @@ -1,3 +1,4 @@ +import BlurtEngine import DatadogCore import DatadogCrashReporting import DatadogLogs @@ -35,7 +36,7 @@ enum Monitoring { private static let environment = "production" // UserDefaults key holding the anonymous, per-install id set as `@usr.id`. - private static let installIDKey = "dev.alex.blurt.anonymous-install-id" + private static let installIDKey = "\(BlurtIdentity.subsystem).anonymous-install-id" // Release-only gate. A runtime flag (not `#if` at the call site) so `start()` // and its config stay reachable for the dead-code scan while execution is still diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index b6e6c02d..88630e73 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -122,7 +122,7 @@ struct OverlayView: View { // `doc.on.clipboard`, so the two notices stay distinct. noticePill( symbol: "doc.on.clipboard.fill", tint: OverlayBrandPalette.magenta, label: "Pasted", - help: "Your dictation was pasted into the focused field.", + help: state.accessibilityLabel, labelTint: OverlayBrandPalette.cyan) case .noTarget: // Quiet, informational notice: there was no text field to type into, so the @@ -130,7 +130,7 @@ struct OverlayView: View { // "Transcribing…") so it reads as info, not the red error flash. noticePill( symbol: "doc.on.clipboard", tint: OverlayBrandPalette.cyan, label: "Copied", - help: "No text field was focused, so your dictation was copied to the clipboard.") + help: state.accessibilityLabel) } } @@ -139,8 +139,9 @@ struct OverlayView: View { /// color, label, and hover text. `tint` colors the glyph; `labelTint` colors /// the label and defaults to `tint` (so the error/copied notices stay one /// color, while "Pasted" can pair a magenta glyph with a cyan label). `help` - /// is the hover tooltip (the same string the window controller announces to - /// VoiceOver). + /// is the hover tooltip — pass `state.accessibilityLabel` so it stays the + /// same string the window controller announces to VoiceOver (the wording + /// lives in one place, `OverlayUIState`). private func noticePill( symbol: String, tint: Color, label: String, help: String, labelTint: Color? = nil ) -> some View { diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index c7e6e8ef..0b7c9e53 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -31,9 +31,10 @@ final class OverlayWindowController { private static let customOriginXKey = "BlurtOverlayCustomOriginX" private static let customOriginYKey = "BlurtOverlayCustomOriginY" // The panel is sized larger than the visible pill so SwiftUI's drop shadow - // (radius 12, y-offset 4) has room to render without being clipped by the - // window's contentRect — especially around the capsule's rounded ends, where - // the shadow extends furthest from the pill body. + // (see `OverlayView`'s `.shadow`, which documents staying within + // `shadowMargin`) has room to render without being clipped by the window's + // contentRect — especially around the capsule's rounded ends, where the + // shadow extends furthest from the pill body. static let pillSize = CGSize(width: 168, height: 28) static let shadowMargin: CGFloat = 16 private static let panelSize = CGSize( @@ -135,10 +136,19 @@ final class OverlayWindowController { func hide() { errorRevertTask?.cancel() errorRevertTask = nil + // Settle the content even when the panel is already off screen (the pill + // may have been hidden mid-notice). bridge.state = .idle guard panel.isVisible else { return } + dismissPanel() + } + + /// Shared final step of every dismiss path: order the panel out, restore full + /// alpha for the next show, and settle the pill content back to idle. + private func dismissPanel() { panel.orderOut(nil) panel.alphaValue = 1 + bridge.state = .idle } /// Drives the pill on/off screen, fading unless Reduce Motion is on. Idempotent @@ -164,9 +174,7 @@ final class OverlayWindowController { } else { guard panel.isVisible else { return } if reduceMotion { - panel.orderOut(nil) - panel.alphaValue = 1 - bridge.state = .idle + dismissPanel() return } NSAnimationContext.runAnimationGroup( @@ -178,9 +186,7 @@ final class OverlayWindowController { // NSAnimationContext completion handlers run on the main thread. MainActor.assumeIsolated { guard let self, self.panel.alphaValue < 0.01 else { return } - self.panel.orderOut(nil) - self.panel.alphaValue = 1 - self.bridge.state = .idle + self.dismissPanel() } }) } diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index d9c79553..ab6caa8e 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -74,7 +74,7 @@ struct UITestMic: MicCaptureProtocol { func start() async throws {} func stop() async throws -> [Float] { - Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) + Array(repeating: 0, count: SyncSTTLimits.minSamples * 2) } } diff --git a/App/Blurt/Blurt/Update/AutoUpdater.swift b/App/Blurt/Blurt/Update/AutoUpdater.swift index 2df3ee67..83adb118 100644 --- a/App/Blurt/Blurt/Update/AutoUpdater.swift +++ b/App/Blurt/Blurt/Update/AutoUpdater.swift @@ -1,5 +1,6 @@ import AppKit import AppUpdater +import BlurtEngine import OSLog /// Drives Blurt's self-update via mxcl/AppUpdater. @@ -17,17 +18,17 @@ import OSLog @MainActor final class AutoUpdater { private let updater = AppUpdater(owner: "AssemblyAI", repo: "blurt") - private let log = Logger(subsystem: "dev.alex.blurt", category: "update") + private let log = Logger(subsystem: BlurtIdentity.subsystem, category: "update") /// Supplies the window to host the update prompt as a sheet (see `runAlert`). /// Injected by `AppDelegate`, which surfaces the main window before returning - /// it so the prompt isn't stranded behind whatever the user was in. The main - /// window always exists by the time an update check completes, so this is - /// non-optional — the prompt is always a sheet, never a main-thread-blocking - /// app-modal alert (which the app-hang watchdog reports as a hang). - private let presentingWindow: @MainActor () -> NSWindow + /// it so the prompt isn't stranded behind whatever the user was in. Nil when + /// the user closed the main window and its recreated scene hasn't + /// materialized an NSWindow yet — `runAlert` retries a beat later, then falls + /// back to an app-modal alert rather than crashing. + private let presentingWindow: @MainActor () -> NSWindow? - init(presentingWindow: @escaping @MainActor () -> NSWindow) { + init(presentingWindow: @escaping @MainActor () -> NSWindow?) { self.presentingWindow = presentingWindow } @@ -76,8 +77,19 @@ final class AutoUpdater { /// `NSAlert.runModal()` — whose nested modal loop blocks the main thread for /// as long as the prompt is up and is reported as an app hang. Surfaces the /// window first so the sheet (and the app) come forward. + /// + /// When no host window exists (the user closed the main window; asking the + /// scene to recreate it only lands on a later run-loop pass), wait one beat + /// for that window to materialize, then — in the still-windowless worst case — + /// fall back to the app-modal `runModal()`: a rare spurious hang report beats + /// dropping the prompt or crashing. private func runAlert(_ alert: NSAlert) async -> NSApplication.ModalResponse { - let window = presentingWindow() + var window = presentingWindow() + if window == nil { + try? await Task.sleep(for: .milliseconds(100)) + window = presentingWindow() + } + guard let window else { return alert.runModal() } return await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { response in continuation.resume(returning: response) diff --git a/App/Blurt/Blurt/Wizard/WizardWindowController.swift b/App/Blurt/Blurt/Wizard/WizardWindowController.swift index 39f4fc27..bcfa5271 100644 --- a/App/Blurt/Blurt/Wizard/WizardWindowController.swift +++ b/App/Blurt/Blurt/Wizard/WizardWindowController.swift @@ -75,10 +75,11 @@ struct MainWindowRoot: View { } .onAppear { // Permission polling runs for the app's whole life (started in the - // controller's init), so the window only needs to capture the opener and - // refresh once on appear to reflect any change made while it was closed. + // controller's init), so the window only needs to refresh once on + // appear to reflect any change made while it was closed. (The + // `openWindowByID` opener is captured once, by `SettingsMenuButton` — + // the launch-evaluated command view — not re-assigned here.) controller.refreshPermissions() - appDelegate.openWindowByID = { openWindow(id: $0) } // Now that the window is actually on screen, pull the app frontmost — // see `activateAtLaunchIfNeeded`. Done here rather than at launch-finish // because the window doesn't exist yet then. @@ -258,7 +259,6 @@ struct SettingsWindowRoot: View { .scrollDisabled(true) .frame(width: 480) .fixedSize(horizontal: false, vertical: true) - .onAppear { appDelegate.openWindowByID = { openWindow(id: $0) } } } else { Color.clear.frame(width: 480, height: 240) } @@ -298,7 +298,9 @@ private struct SettingsMenuButton: View { // Capture the open action at launch so a Dock click can reopen a window // even on a configured launch where one is never shown. Command views are // evaluated at launch (that's how ⌘, registers before any menu is opened), - // so this is a reliable, idempotent capture point. + // so this is a reliable, idempotent capture point — and the ONLY one: + // `openWindow(id:)` opens any Window scene regardless of which scene's + // environment supplied the action, so the window roots don't re-capture it. appDelegate.openWindowByID = { openWindow(id: $0) } return Button("Settings…") { openWindow(id: SettingsWindow.id) diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index e2ca24a0..4a4e7a06 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -15,7 +15,7 @@ public actor MicCapture: MicCaptureProtocol { // log show --predicate 'subsystem == "dev.alex.blurt"' --last 1h // Stderr is unreachable for .app bundles launched via Finder/LaunchServices, // so go through the unified logging system instead. - private static let logger = Logger(subsystem: "dev.alex.blurt", category: "MicCapture") + private static let logger = Logger(subsystem: BlurtIdentity.subsystem, category: "MicCapture") public nonisolated let levels: AsyncStream private nonisolated let levelsContinuation: AsyncStream.Continuation diff --git a/Sources/BlurtEngine/BlurtIdentity.swift b/Sources/BlurtEngine/BlurtIdentity.swift new file mode 100644 index 00000000..6dd57f6c --- /dev/null +++ b/Sources/BlurtEngine/BlurtIdentity.swift @@ -0,0 +1,11 @@ +/// The single definition of Blurt's reverse-DNS identity string. Shared by +/// every unified-logging subsystem, the Keychain service, and derived keys so +/// the documented log-discovery predicates can't drift between components. +public enum BlurtIdentity { + /// Reverse-DNS identity ("dev.alex.blurt"): the logging subsystem, the + /// Keychain service, and the prefix for derived keys/labels. Must match + /// `BUNDLE_ID`/`KEYCHAIN_SERVICE` in `scripts/reset-install.sh`, which + /// hard-codes the same value for its `defaults`/`security` cleanup (bash + /// can't read this constant). + public static let subsystem = "dev.alex.blurt" +} diff --git a/Sources/BlurtEngine/Config/APIKeyStore.swift b/Sources/BlurtEngine/Config/APIKeyStore.swift index 33344e8b..67085441 100644 --- a/Sources/BlurtEngine/Config/APIKeyStore.swift +++ b/Sources/BlurtEngine/Config/APIKeyStore.swift @@ -13,7 +13,7 @@ public enum APIKeyStore { /// The production keychain item. The service is the (lowercase) bundle id, to /// match the macOS convention. Tests exercise `KeychainStore` directly with an /// isolated service/account so they never touch this real key. - static let store = KeychainStore(service: "dev.alex.blurt", account: "AssemblyAIAPIKey") + static let store = KeychainStore(service: BlurtIdentity.subsystem, account: "AssemblyAIAPIKey") /// The stored key, or `nil` if none has been saved (or it's empty). public static func get() -> String? { store.get() } diff --git a/Sources/BlurtEngine/Config/PersistedSettings.swift b/Sources/BlurtEngine/Config/PersistedSettings.swift new file mode 100644 index 00000000..559dc2db --- /dev/null +++ b/Sources/BlurtEngine/Config/PersistedSettings.swift @@ -0,0 +1,14 @@ +/// The roster of `UserDefaults` keys the engine's settings stores persist +/// (trigger key, sound pack, key terms). Owned here — next to the stores — +/// so adding a store and adding it to every "reset to a clean state" sweep +/// (e.g. the app's UI-test launch reset) are the same edit, instead of a +/// hand-maintained list in the app shell that silently goes stale. +public enum PersistedSettings { + /// Every defaults key an engine store writes. Keep in sync by adding the new + /// store's key here in the same change that introduces the store. + public static let allDefaultsKeys: [String] = [ + TriggerKeyStore.defaultsKey, + SoundPackStore.defaultsKey, + KeyTermsStore.defaultsKey, + ] +} diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift index 345f301e..321f2fc8 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift @@ -272,12 +272,25 @@ enum FocusCapture { return nil } - /// The up-to-`maxChars` slice of `full` ending at `caret`. A caret outside - /// `0...full.count` (e.g. unreadable selection) falls back to the tail of the - /// whole value. Returns `nil` when the resulting slice is empty. + /// The up-to-`maxChars` slice of `full` ending at `caret`. `caret` is a + /// **UTF-16 offset** (the domain AX selected-text ranges use — see + /// `caretLocation`), so it is resolved through the UTF-16 view rather than + /// counted in `Character`s, which diverge as soon as the text holds emoji or + /// other surrogate pairs. A caret outside the string — or one that doesn't + /// land on a character boundary — falls back to the tail of the whole value. + /// Returns `nil` when the resulting slice is empty. static func priorSlice(full: String, caret: Int, maxChars: Int) -> String? { guard !full.isEmpty else { return nil } - let upto = (caret >= 0 && caret <= full.count) ? full.prefix(caret) : full[...] + let upto: Substring + if caret >= 0, caret <= full.utf16.count, + let index = full.utf16.index( + full.utf16.startIndex, offsetBy: caret, limitedBy: full.utf16.endIndex)? + .samePosition(in: full) + { + upto = full[..= SyncSTTLimits.minSamples(sampleRate: Self.captureSampleRate) else { - setPhase(.idle) + guard samples.count >= SyncSTTLimits.minSamples else { + // A cancel() racing the freshly spawned pipeline task already claimed the + // phase — don't overwrite .cancelled with .idle. + if !Task.isCancelled { setPhase(.idle) } return } @@ -283,13 +285,19 @@ public actor DictationSession { do { return try await transcriber.transcribe( samples: samples, sampleRate: Self.captureSampleRate, context: capturedContext) - } catch let err as BlurtError { - // e.g. `.apiKeyMissing` — surface it directly rather than burying it - // inside `.sttFailed`. - setPhase(.failed(err)) - return nil } catch { - setPhase(.failed(.sttFailed(underlying: error))) + // A cancel() that landed mid-request already tore this task down and set + // .cancelled; the transport then surfaces a cancellation-shaped error + // (URLError(.cancelled) / CancellationError). Leave the claimed phase + // alone rather than repainting the user's cancel as a red failure. + if Task.isCancelled || error is CancellationError { return nil } + if let err = error as? BlurtError { + // e.g. `.apiKeyMissing` — surface it directly rather than burying it + // inside `.sttFailed`. + setPhase(.failed(err)) + } else { + setPhase(.failed(.sttFailed(underlying: error))) + } return nil } } @@ -298,25 +306,32 @@ public actor DictationSession { setPhase(.injecting) do { try await injector.insert(text, after: capturedContext?.priorText) + // A cancel() that landed in insert's final, non-cancellable stretch + // (after its last checkCancellation) already set .cancelled — leave the + // claimed phase alone rather than repainting it as .pasted. + if Task.isCancelled { return } // The paste landed — show the quiet "pasted" notice (the mirror of the // "copied" notice below) rather than snapping straight back to idle. setPhase(.pasted) - } catch is CancellationError { - // A cancel() landed mid-paste: it already set .cancelled and the injector - // bailed before typing. Leave the phase alone rather than relabeling this - // as a failure. - return - } catch BlurtError.noEditableTarget { - // Not a failure: transcription worked, there was just nowhere to type. The - // injector left the text on the clipboard — show the quiet "copied" notice - // rather than the red error flash (and don't report it). - setPhase(.noTarget) - } catch let err as BlurtError { - // Surface the injector's real error (e.g. `.targetAppLost`) rather than - // relabeling every failure as a lost target. - setPhase(.failed(err)) } catch { - setPhase(.failed(.targetAppLost)) + // A cancel() landed mid-paste: it already set .cancelled (the injector + // bails via its checkCancellation). Leave the claimed phase alone — + // including on a late non-cancellation error — rather than relabeling + // the user's cancel as a failure. + if error is CancellationError || Task.isCancelled { return } + switch error { + case BlurtError.noEditableTarget: + // Not a failure: transcription worked, there was just nowhere to type. + // The injector left the text on the clipboard — show the quiet "copied" + // notice rather than the red error flash (and don't report it). + setPhase(.noTarget) + case let err as BlurtError: + // Surface the injector's real error (e.g. `.targetAppLost`) rather than + // relabeling every failure as a lost target. + setPhase(.failed(err)) + default: + setPhase(.failed(.targetAppLost)) + } } } diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index accf0e49..cc8ad045 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -5,7 +5,7 @@ import os /// log show --predicate 'subsystem == "dev.alex.blurt" && category == "Transcriber"' --last 1h /// File-scoped so both `send(_:body:audioDurationMs:)` (wall-clock) and /// `MetricsLogger` (the DNS/TCP/TLS/TTFB split) can write to it. -private let transcriberLog = Logger(subsystem: "dev.alex.blurt", category: "Transcriber") +private let transcriberLog = Logger(subsystem: BlurtIdentity.subsystem, category: "Transcriber") /// `TranscriberProtocol` backed by AssemblyAI's **Sync** Speech-to-Text API. /// @@ -27,6 +27,9 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// one per decode is pure waste (mirrors `DictationLog.encoder`). private static let decoder = JSONDecoder() + /// Reused across requests, same rationale as `decoder`. + private static let encoder = JSONEncoder() + public init( apiKeyProvider: @escaping @Sendable () -> String? = { APIKeyStore.get() }, baseURL: URL = URL(string: "https://sync.assemblyai.com")!, @@ -59,7 +62,9 @@ public struct AssemblyAITranscriber: TranscriberProtocol { let body = multipartBody(pcm: pcm, config: config, boundary: boundary) let audioDurationMs = Int((Double(samples.count) / Double(sampleRate)) * 1000) let data = try await send(request, body: body, audioDurationMs: audioDurationMs) - let response = try decode(SyncTranscriptResponse.self, from: data) + guard let response = try? Self.decoder.decode(SyncTranscriptResponse.self, from: data) else { + throw AssemblyAIError.malformedResponse + } return response.text } @@ -88,7 +93,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// assert the prompt wiring without inspecting the multipart upload body /// (which `URLProtocol` mocks can't observe reliably for `upload(from:)`). func makeConfigData(sampleRate: Int, prompt: String?) throws -> Data { - try JSONEncoder().encode( + try Self.encoder.encode( SyncConfig( sampleRate: sampleRate, channels: 1, @@ -156,14 +161,6 @@ public struct AssemblyAITranscriber: TranscriberProtocol { return String(raw.prefix(500)) } - private func decode(_ type: T.Type, from data: Data) throws -> T { - do { - return try Self.decoder.decode(type, from: data) - } catch { - throw AssemblyAIError.malformedResponse - } - } - // MARK: - Wire types private struct SyncConfig: Encodable { diff --git a/Sources/BlurtEngine/STT/PCMEncoder.swift b/Sources/BlurtEngine/STT/PCMEncoder.swift index 92d80c31..a4f57c2c 100644 --- a/Sources/BlurtEngine/STT/PCMEncoder.swift +++ b/Sources/BlurtEngine/STT/PCMEncoder.swift @@ -6,7 +6,7 @@ import Foundation /// AssemblyAI's Sync STT API (`POST sync.assemblyai.com/transcribe`) takes the /// audio as a headerless PCM blob plus a JSON `config` describing its geometry /// (`sample_rate`, `channels`), so no WAV/RIFF container is needed. Samples are -/// expected in the range [-1, 1] (the mic capture peak-normalizes to 0.95). +/// expected in the range [-1, 1]; anything outside is clipped below. enum PCMEncoder { static func encodeS16LE(samples: [Float]) -> Data { guard !samples.isEmpty else { return Data() } diff --git a/Sources/BlurtEngine/STT/SyncSTTLimits.swift b/Sources/BlurtEngine/STT/SyncSTTLimits.swift index 4ab3011f..6ce07508 100644 --- a/Sources/BlurtEngine/STT/SyncSTTLimits.swift +++ b/Sources/BlurtEngine/STT/SyncSTTLimits.swift @@ -17,11 +17,12 @@ public enum SyncSTTLimits { /// Set a hair above the model's documented ~80 ms floor for margin. public static let minAudioSeconds: Double = 0.1 - /// The fewest samples worth sending at `sampleRate`; a buffer shorter than - /// this is below `minAudioSeconds` and would only earn a 400. - public static func minSamples(sampleRate: Int) -> Int { - Int(minAudioSeconds * Double(sampleRate)) - } + /// The fewest samples worth sending; a buffer shorter than this is below + /// `minAudioSeconds` and would only earn a 400. A stored constant (not a + /// function taking a rate) because the pipeline records at exactly + /// `sampleRate` — a parameter would just re-ask a question this type already + /// answers, and invite a floor inconsistent with what's actually recorded. + public static let minSamples = Int(minAudioSeconds * Double(sampleRate)) /// Safety margin subtracted from the cap for the auto-release timeout, so the /// session stops recording before it hits the hard limit. diff --git a/Sources/BlurtEngine/STT/TranscriptionPrompt.swift b/Sources/BlurtEngine/STT/TranscriptionPrompt.swift index c8bd888e..9ff61702 100644 --- a/Sources/BlurtEngine/STT/TranscriptionPrompt.swift +++ b/Sources/BlurtEngine/STT/TranscriptionPrompt.swift @@ -28,8 +28,9 @@ /// prior-chunk context, the topic hint, and the destination sentence precede it /// as the `{context}. {baseInstruction}` shape, and keyword boosting trails it /// inline as `Keywords: a, b, c.` (per the mid-training instruction-type -/// reference). It stays under the API's 4096-character cap (prior text is capped -/// upstream in `FocusCapture`). Exercised by +/// reference). It stays under the API's cap (`characterCap`): the contextual +/// blocks are clipped upstream in `FocusCapture`, and the key-terms clause is +/// fitted to the remaining budget here. Exercised by /// `Tests/BlurtEngineTests/TranscriptionPromptTests.swift`. enum TranscriptionPrompt { /// The standing dictation instruction prepended to the model's system prompt @@ -41,6 +42,12 @@ enum TranscriptionPrompt { static let baseInstruction = "Transcribe without speaker labels, audio event descriptions, or emotion markers." + /// Hard cap the Sync API places on `config.prompt`; a longer prompt fails + /// the whole request, so `build` must never exceed it. The contextual blocks + /// are all clipped upstream in `FocusCapture`; the user's key terms are the + /// one unbounded input, so `build` fits them to whatever budget remains. + static let characterCap = 4096 + /// Renders `context` into a Sync STT prompt, or `nil` when there is no usable /// context (the server then applies its own default prompt). static func build(context: TranscriptionContext?) -> String? { @@ -73,15 +80,30 @@ enum TranscriptionPrompt { let location = locationClause(app: app, window: window, field: field) // The topic hint and `baseInstruction` share one line as the trained // `{context}. {baseInstruction}` shape; with no topic it's the bare base. - var instruction = location.isEmpty ? baseInstruction : "\(location) \(baseInstruction)" + let instruction = location.isEmpty ? baseInstruction : "\(location) \(baseInstruction)" + blocks.append(instruction) + var prompt = blocks.joined(separator: "\n\n") if !keyTerms.isEmpty { // Spelling priming: the user's domain vocabulary, boosted via the trained // inline `Keywords: a, b, c.` form (Section 2.3) trailing the marker so the // model favors these exact spellings for names/jargon it would guess at. - instruction += " Keywords: \(keyTerms.joined(separator: ", "))." + // The terms list is the one input with no upstream length cap, so include + // only as many whole terms as `characterCap` leaves room for — a huge + // Settings list must not push the prompt over the cap and fail every + // dictation with a 400. + var included: [String] = [] + var remaining = characterCap - prompt.count - " Keywords: .".count + for term in keyTerms { + let cost = term.count + (included.isEmpty ? 0 : ", ".count) + guard cost <= remaining else { break } + included.append(term) + remaining -= cost + } + if !included.isEmpty { + prompt += " Keywords: \(included.joined(separator: ", "))." + } } - blocks.append(instruction) - return blocks.joined(separator: "\n\n") + return prompt } /// The "where am I typing" priming clause, assembled from whichever of the diff --git a/Tests/BlurtEngineTests/CancelRaceTests.swift b/Tests/BlurtEngineTests/CancelRaceTests.swift index b249a771..590b7cbb 100644 --- a/Tests/BlurtEngineTests/CancelRaceTests.swift +++ b/Tests/BlurtEngineTests/CancelRaceTests.swift @@ -43,6 +43,29 @@ struct CancelRaceTests { #expect(await session.phase == .cancelled) } + @Test("cancel during transcribing with a throwing transcriber stays .cancelled") + func cancelDuringTranscribingThrowingStaysCancelled() async throws { + let mic = StubMicCapture() + // Mimics the real transcriber: the cancelled URLSession request throws + // URLError(.cancelled), which must not be repainted as a red .failed (or + // reported as a fault) over the .cancelled the cancel() already claimed. + let stt = GatedTranscriber(text: "Hello world.", throwsWhenCancelled: true) + let injector = StubInjector() + let session = DictationSession(mic: mic, transcriber: stt, injector: injector) + + await session.press() + await session.release() + await stt.waitUntilStarted() + await session.cancel() + #expect(await session.phase == .cancelled) + + await stt.allowToFinish() + for _ in 0..<1000 { await Task.yield() } + + #expect(await session.phase == .cancelled) + #expect(await injector.inserted.isEmpty) + } + @Test("cancel during injecting discards the transcript and ends .cancelled") func cancelDuringInjectingDiscards() async throws { // `expectedCount: 0`: the paste must never be recorded. This is event-driven @@ -103,17 +126,25 @@ struct CancelRaceTests { /// session is suspended in `.transcribing`. private actor GatedTranscriber: TranscriberProtocol { private let text: String + /// When true, a release that arrives after the task was cancelled throws + /// `URLError(.cancelled)` — mimicking the real URLSession-backed transcriber, + /// whose in-flight request is torn down by task cancellation. + private let throwsWhenCancelled: Bool private var started = false private var startedWaiters: [CheckedContinuation] = [] private var finished = false private var finishWaiters: [CheckedContinuation] = [] - init(text: String) { self.text = text } + init(text: String, throwsWhenCancelled: Bool = false) { + self.text = text + self.throwsWhenCancelled = throwsWhenCancelled + } nonisolated func transcribe(samples: [Float], sampleRate: Int, context: TranscriptionContext?) async throws -> String { await enter() + if throwsWhenCancelled && Task.isCancelled { throw URLError(.cancelled) } return text } diff --git a/Tests/BlurtEngineTests/FocusCaptureTests.swift b/Tests/BlurtEngineTests/FocusCaptureTests.swift index 1f461e92..fefec88e 100644 --- a/Tests/BlurtEngineTests/FocusCaptureTests.swift +++ b/Tests/BlurtEngineTests/FocusCaptureTests.swift @@ -72,6 +72,21 @@ struct FocusCaptureTests { #expect(FocusCapture.priorSlice(full: "hello", caret: 0, maxChars: 320) == nil) } + @Test("priorSlice treats the caret as a UTF-16 offset, not a Character count") + func priorCaretIsUTF16() { + // AX selected-text ranges are UTF-16: each emoji below is 2 UTF-16 units but + // 1 Character, so a Character-counted prefix would over-reach past the caret. + // Caret after the two emoji = offset 4. + #expect(FocusCapture.priorSlice(full: "😀😀ab", caret: 4, maxChars: 320) == "😀😀") + // Caret between the emoji = offset 2. + #expect(FocusCapture.priorSlice(full: "😀😀ab", caret: 2, maxChars: 320) == "😀") + // A caret that splits a surrogate pair isn't a character boundary — fall + // back to the whole value's tail rather than slicing mid-character. + #expect(FocusCapture.priorSlice(full: "😀b", caret: 1, maxChars: 320) == "😀b") + // Offsets past the UTF-16 length keep the existing tail fallback. + #expect(FocusCapture.priorSlice(full: "😀b", caret: 99, maxChars: 320) == "😀b") + } + // MARK: clip @Test("clip caps overlong text and passes short text through") diff --git a/Tests/BlurtEngineTests/HotkeyRaceTests.swift b/Tests/BlurtEngineTests/HotkeyRaceTests.swift index 1425c175..7d534004 100644 --- a/Tests/BlurtEngineTests/HotkeyRaceTests.swift +++ b/Tests/BlurtEngineTests/HotkeyRaceTests.swift @@ -97,7 +97,7 @@ private actor GatedMicCapture: MicCaptureProtocol { await incStop() // Above SyncSTTLimits.minSamples so the transcript isn't dropped by the // too-short guard — this suite exercises the press/release race, not it. - return Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) + return Array(repeating: 0, count: SyncSTTLimits.minSamples * 2) } private func incStop() { stopCalls += 1 } diff --git a/Tests/BlurtEngineTests/ReleaseRaceTests.swift b/Tests/BlurtEngineTests/ReleaseRaceTests.swift index 8278cf5d..d7261b88 100644 --- a/Tests/BlurtEngineTests/ReleaseRaceTests.swift +++ b/Tests/BlurtEngineTests/ReleaseRaceTests.swift @@ -70,7 +70,7 @@ private actor GatedStopMic: MicCaptureProtocol { } // Above SyncSTTLimits.minSamples so the transcript isn't dropped by the // too-short guard — this suite exercises the release race, not it. - return Array(repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) + return Array(repeating: 0, count: SyncSTTLimits.minSamples * 2) } func waitUntilStopEntered() async { diff --git a/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift b/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift index 9102759f..5da31eff 100644 --- a/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift +++ b/Tests/BlurtEngineTests/Stubs/StubMicCapture.swift @@ -8,8 +8,7 @@ actor StubMicCapture: MicCaptureProtocol { // Comfortably above `SyncSTTLimits.minSamples` so the default press→release // flow clears the too-short guard and reaches transcribe, tracking the engine // rule so a raised floor can't silently start dropping the canned audio. - var samplesToReturn: [Float] = Array( - repeating: 0, count: SyncSTTLimits.minSamples(sampleRate: SyncSTTLimits.sampleRate) * 2) + var samplesToReturn: [Float] = Array(repeating: 0, count: SyncSTTLimits.minSamples * 2) var startError: (any Error & Sendable)? var stopError: (any Error & Sendable)? diff --git a/Tests/BlurtEngineTests/TranscriptionPromptTests.swift b/Tests/BlurtEngineTests/TranscriptionPromptTests.swift index 45f56b19..cd75719b 100644 --- a/Tests/BlurtEngineTests/TranscriptionPromptTests.swift +++ b/Tests/BlurtEngineTests/TranscriptionPromptTests.swift @@ -114,6 +114,19 @@ struct TranscriptionPromptTests { let longPrior = String(repeating: "word ", count: 200) let prompt = TranscriptionPrompt.build( context: TranscriptionContext(appName: "Xcode", priorText: longPrior)) - #expect((prompt?.count ?? 0) <= 4096) + #expect((prompt?.count ?? 0) <= TranscriptionPrompt.characterCap) + } + + @Test("an oversized key-terms list is fitted to the cap, keeping whole leading terms") + func keyTermsFittedToCap() throws { + // Key terms are the one input with no upstream length cap; a huge Settings + // list must not push the prompt over the API cap (which fails the request). + let terms = (0..<2000).map { "term\($0)" } + let prompt = TranscriptionPrompt.build( + context: TranscriptionContext(appName: "Xcode", priorText: nil, keyTerms: terms)) + let built = try #require(prompt) + #expect(built.count <= TranscriptionPrompt.characterCap) + #expect(built.contains(" Keywords: term0, term1")) + #expect(built.hasSuffix(".")) } } diff --git a/scripts/check.sh b/scripts/check.sh index 5f855c69..aea50d0c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -25,11 +25,6 @@ else echo "note: xcbeautify not installed; using raw output (brew install xcbeautify)" fi -run_xcodebuild() { - set -o pipefail - xcodebuild "$@" | "${PRETTY[@]}" -} - echo "==> swift test (BlurtEngine)" cd "$REPO_ROOT" # -warnings-as-errors: a warning fails the build, so deprecations / unused code diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 96182b71..56726c38 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -80,12 +80,10 @@ fi xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ || die "notarytool profile '$NOTARY_PROFILE' not found. Run: xcrun notarytool store-credentials $NOTARY_PROFILE --apple-id --team-id $TEAM_ID --password " -[ -z "$(git -C "$REPO_ROOT" status --porcelain)" ] \ - || die "working tree dirty — commit or stash before building a release artifact" +require_clean_tree "building a release artifact" step "Read version" -VERSION="$(parse_short_version <"$APP_DIR/project.yml")" -[ -n "$VERSION" ] || die "could not parse CFBundleShortVersionString from $APP_DIR/project.yml" +VERSION="$(require_project_version "$APP_DIR/project.yml")" info "version: $VERSION" step "Initial summary" diff --git a/scripts/release-bump.sh b/scripts/release-bump.sh index a797fb2e..d98a8283 100755 --- a/scripts/release-bump.sh +++ b/scripts/release-bump.sh @@ -22,9 +22,7 @@ step "Preflight" command -v xcodegen >/dev/null 2>&1 || die "missing required tool: xcodegen" [ -f "$PROJECT_YML" ] || die "not found: $PROJECT_YML" -if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - die "working tree dirty — commit or stash before bumping" -fi +require_clean_tree "bumping" CURRENT_VERSION="$(parse_short_version <"$PROJECT_YML")" CURRENT_BUILD="$(parse_bundle_version <"$PROJECT_YML")" @@ -35,10 +33,10 @@ CURRENT_BUILD="$(parse_bundle_version <"$PROJECT_YML")" [ "$NEW_VERSION" != "$CURRENT_VERSION" ] || die "version $NEW_VERSION is already current" version_gt "$NEW_VERSION" "$CURRENT_VERSION" || die "$NEW_VERSION is not greater than current $CURRENT_VERSION" -if git -C "$REPO_ROOT" rev-parse "v$NEW_VERSION" >/dev/null 2>&1; then +if tag_exists_locally "v$NEW_VERSION"; then die "tag v$NEW_VERSION already exists locally" fi -if git -C "$REPO_ROOT" ls-remote --tags origin "refs/tags/v$NEW_VERSION" 2>/dev/null | grep -q .; then +if tag_exists_on_origin "v$NEW_VERSION"; then die "tag v$NEW_VERSION already exists on origin" fi diff --git a/scripts/release-install.sh b/scripts/release-install.sh index 0ef72015..fc436db1 100755 --- a/scripts/release-install.sh +++ b/scripts/release-install.sh @@ -25,8 +25,7 @@ for cmd in xcrun hdiutil codesign ditto awk; do command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd" done -VERSION="$(parse_short_version <"$APP_DIR/project.yml")" -[ -n "$VERSION" ] || die "could not parse CFBundleShortVersionString from $APP_DIR/project.yml" +VERSION="$(require_project_version "$APP_DIR/project.yml")" info "version: $VERSION" DMG="$BUILD_ROOT/Blurt-$VERSION.dmg" diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index 737fd483..cfe4314c 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -14,6 +14,35 @@ die() { exit 1 } +# --- shared guards (need REPO_ROOT set by the sourcing script) --- + +# Die unless the git working tree is clean; $1 names the action for the message +# (e.g. "publishing" -> "… commit or stash before publishing"). +require_clean_tree() { + [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ] \ + || die "working tree dirty — commit or stash before ${1:-continuing}" +} + +# Echo CFBundleShortVersionString read from the project.yml at $1, dying when +# it can't be parsed. Call as: VERSION="$(require_project_version "$path")" — +# the die inside the substitution fails the assignment under `set -e`. +require_project_version() { + local version + version="$(parse_short_version <"$1")" + [ -n "$version" ] || die "could not parse CFBundleShortVersionString from $1" + printf '%s\n' "$version" +} + +# True if tag $1 (e.g. "v1.2.3") exists in the local repo. +tag_exists_locally() { + git -C "$REPO_ROOT" rev-parse "$1" >/dev/null 2>&1 +} + +# True if tag $1 exists on origin. +tag_exists_on_origin() { + git -C "$REPO_ROOT" ls-remote --tags origin "refs/tags/$1" 2>/dev/null | grep -q . +} + # --- pure version helpers (unit-tested by scripts/release.test.sh) --- # True if $1 looks like X.Y.Z (digits only). diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index a322d787..20669933 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -26,8 +26,7 @@ for cmd in gh git xcrun awk; do command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd" done -VERSION="$(parse_short_version <"$APP_DIR/project.yml")" -[ -n "$VERSION" ] || die "could not parse version" +VERSION="$(require_project_version "$APP_DIR/project.yml")" DMG="$BUILD_ROOT/Blurt-$VERSION.dmg" DSYM_ZIP="$BUILD_ROOT/Blurt-$VERSION.app.dSYM.zip" CHECKSUMS="$BUILD_ROOT/SHA256SUMS" @@ -45,8 +44,8 @@ step "Tag preflight" TAG="v$VERSION" LOCAL_TAG_EXISTS=0 REMOTE_TAG_EXISTS=0 -git -C "$REPO_ROOT" rev-parse "$TAG" >/dev/null 2>&1 && LOCAL_TAG_EXISTS=1 -git -C "$REPO_ROOT" ls-remote --tags origin "refs/tags/$TAG" | grep -q . && REMOTE_TAG_EXISTS=1 +tag_exists_locally "$TAG" && LOCAL_TAG_EXISTS=1 +tag_exists_on_origin "$TAG" && REMOTE_TAG_EXISTS=1 if [ "$REPUBLISH" -eq 0 ]; then [ "$LOCAL_TAG_EXISTS" -eq 0 ] || die "tag $TAG already exists locally (pass --republish to overwrite)" [ "$REMOTE_TAG_EXISTS" -eq 0 ] || die "tag $TAG already exists on origin (pass --republish to overwrite)" @@ -57,9 +56,7 @@ else fi step "Working tree" -if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - die "working tree dirty — commit or stash before publishing" -fi +require_clean_tree "publishing" step "Confirm" SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" diff --git a/scripts/release.sh b/scripts/release.sh index e197a9e6..e2a318b2 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -202,8 +202,7 @@ main() { command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd" done - [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ] \ - || die "working tree dirty — commit or stash first" + require_clean_tree "releasing" step "Fetch origin" git -C "$REPO_ROOT" fetch origin --tags --quiet diff --git a/scripts/reset-install.sh b/scripts/reset-install.sh index 58d8682a..b4bff069 100755 --- a/scripts/reset-install.sh +++ b/scripts/reset-install.sh @@ -2,7 +2,9 @@ set -euo pipefail # Lowercase to match how macOS records the Accessibility TCC client (see the -# PRODUCT_BUNDLE_IDENTIFIER note in App/Blurt/project.yml). +# PRODUCT_BUNDLE_IDENTIFIER note in App/Blurt/project.yml). Must match +# `BlurtIdentity.subsystem` (Sources/BlurtEngine/BlurtIdentity.swift) — the +# code's single definition of this string. BUNDLE_ID="dev.alex.blurt" # Quit Blurt first, or every step below is unreliable: a running instance @@ -44,9 +46,9 @@ echo "==> Clearing UserDefaults for $BUNDLE_ID" defaults delete "$BUNDLE_ID" 2>/dev/null || true # AssemblyAI API key lives in the login keychain as a generic password. The -# keychain service is a fixed string in APIKeyStore -# (Sources/BlurtEngine/Config/APIKeyStore.swift), the lowercase bundle id to -# match macOS convention. +# keychain service is `BlurtIdentity.subsystem` (used by APIKeyStore, +# Sources/BlurtEngine/Config/APIKeyStore.swift) — the lowercase bundle id to +# match macOS convention. Must match that constant. KEYCHAIN_SERVICE="dev.alex.blurt" KEYCHAIN_ACCOUNT="AssemblyAIAPIKey" echo "==> Deleting AssemblyAI API key from Keychain ($KEYCHAIN_SERVICE / $KEYCHAIN_ACCOUNT)" From 5b17e483fc6c1693037537f6d01b20c1647d2e0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 22:21:21 +0000 Subject: [PATCH 3/4] fix: bring AppCoordinator.swift back under swiftlint's 400-line file limit The CueSoundPlayer extraction pushed the file to 402 lines and CI's swiftlint --strict failed on file_length. Tighten the new doc comments (no content lost) to 397 lines. Moving the class to its own file is the better fix but needs xcodegen on a Mac to regenerate the pbxproj. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012XWdVUaLE9RXmEfbuL3Lk5 --- App/Blurt/Blurt/AppCoordinator.swift | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index ca92470e..a34af442 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -257,13 +257,11 @@ final class AppCoordinator { // MARK: - Dictation render - /// The record start/stop chimes. Owned by a dedicated player (not inlined - /// here) so the audio concern stays out of the session↔UI wiring. + /// The record start/stop chimes (see `CueSoundPlayer` below). private let cues = CueSoundPlayer() /// Called when the user changes the sound pack in Settings: reload the cue - /// players so the next start/stop uses the new voice, and preview it so the - /// choice is audible immediately. + /// players and preview the new voice so the choice is audible immediately. func soundPackChanged() { cues.packChanged() } @@ -340,12 +338,11 @@ final class CueSoundPlayer { /// rather than blasting at the system output level. private static let cueVolume: Float = 0.35 - /// (Re)loads and pre-rolls the cue players for the currently selected sound - /// pack, so the first start/stop chime adds no latency to the pill. Called - /// from the "app is ready" transition — the audio queue is allocated and the - /// buffers filled well before the hot path. `.none` (or a missing file) - /// leaves a player nil, which `play(_:)` skips, so "no sound" needs no extra - /// branching. Idempotent: re-priming an already-prepared player is cheap. + /// (Re)loads and pre-rolls the cue players for the selected sound pack so + /// the first start/stop chime adds no latency to the pill; called from the + /// "app is ready" transition, well before the hot path. `.none` (or a + /// missing file) leaves a player nil, which `play(_:)` skips. Idempotent: + /// re-priming an already-prepared player is cheap. func prime() { let pack = SoundPackStore().soundPack startSound = pack.startFileName.flatMap(Self.bundledSound(named:)) @@ -379,12 +376,10 @@ final class CueSoundPlayer { wasRecording = isRecording } - /// Loads a bundled chime (`Resources/Sounds/.m4a`) fully into memory. - /// `AVAudioPlayer(contentsOf:)` decodes the AAC up front — unlike - /// `NSSound(…byReference: true)`, which deferred the disk read to the first - /// `play()` and so stalled the recording pill the first time you dictated. - /// The players are then pre-rolled in `prime()` so even the audio-queue setup - /// is paid before the hot path. + /// Loads a bundled chime (`Resources/Sounds/.m4a`) fully into memory: + /// `AVAudioPlayer(contentsOf:)` decodes the AAC up front, unlike + /// `NSSound(…byReference: true)`, whose deferred disk read stalled the pill + /// on the first dictation. `prime()` then pre-rolls the audio queue too. private static func bundledSound(named name: String) -> AVAudioPlayer? { guard let url = Bundle.main.url(forResource: name, withExtension: "m4a") else { return nil } return try? AVAudioPlayer(contentsOf: url) From 1427c9bb88a1b05fa6201ee4ea305bf027ad47ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 22:30:23 +0000 Subject: [PATCH 4/4] fix: drop SettingsWindowRoot's now-unused openWindow environment property Periphery failed CI on it: the property's only consumer was the redundant openWindowByID re-capture removed in the previous commit (the capture lives solely in SettingsMenuButton now). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012XWdVUaLE9RXmEfbuL3Lk5 --- App/Blurt/Blurt/Wizard/WizardWindowController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/App/Blurt/Blurt/Wizard/WizardWindowController.swift b/App/Blurt/Blurt/Wizard/WizardWindowController.swift index bcfa5271..597b2f83 100644 --- a/App/Blurt/Blurt/Wizard/WizardWindowController.swift +++ b/App/Blurt/Blurt/Wizard/WizardWindowController.swift @@ -245,7 +245,6 @@ private struct ReadySettingsButton: View { /// uses, so the two stay in sync. struct SettingsWindowRoot: View { var appDelegate: AppDelegate - @Environment(\.openWindow) private var openWindow var body: some View { if let coordinator = appDelegate.coordinator {