From 22e87a416051b9b5c0516b8ebf75bbb94413b5a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:48:37 +0000 Subject: [PATCH 1/3] Remove dead and vestigial code across the engine, app, and bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four removals, each a symbol or branch that no code path can reach: - `BlurtError.microphonePermissionDenied` — never constructed anywhere. Microphone state is owned by `PermissionsChecker` and the wizard, and a capture failure surfaces as `.audioCaptureFailed`, so this case only ever appeared in its own `errorDescription`/`==` switches (which is why periphery could not see it as unused). Drops with its two switch arms and the `BLURTENGINE.md` error-table row. - `FocusCapture.isEditableTarget`'s `hasFocusedElement` parameter and its `guard` — the sole production caller, `hasEditableFocusedElement()`, already returns early when no element resolves, so it only ever passed `true`. The dead branch existed solely to be asserted by one test. - `AppDelegate.surfaceMainWindow()`'s `NSWindow?` return — all three call sites discard it, hence the `@discardableResult`. - `NSAppleEventsUsageDescription` — Blurt sends no Apple Events: the paste is a synthesized `CGEvent` (Accessibility) and focus reads go through the AX client APIs, so the string could never be shown. Deliberately kept: the availability fallbacks (`glassButtonStyleCompat`, `Observations`) that the macOS 15 deployment target requires, and the documented degradation paths in `SystemClipboard`, `KeyInjector`, `MemoizedKeyStore`, and `FocusCapture` — each encodes a real past bug and is covered by tests. Verification: `scripts/check.sh --portable` is green; the Swift build, tests, and lint gates need a Mac, so CI on macos-26 is the authority here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MyARxdyuq4QTjk5WcwTyXu --- App/Blurt/Blurt/AppDelegate.swift | 9 +++----- App/Blurt/Blurt/Info.plist | 2 -- App/Blurt/project.yml | 1 - BLURTENGINE.md | 14 ++++++------ Sources/BlurtEngine/BlurtError.swift | 2 -- .../FocusCapture+Editability.swift | 18 +++++++-------- .../BlurtEngine/Pipeline/PipelinePhase.swift | 3 +-- Tests/BlurtEngineTests/BlurtErrorTests.swift | 4 +--- .../EditableTargetTests.swift | 22 ++++++------------- 9 files changed, 27 insertions(+), 48 deletions(-) diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 2dcd47c..0f38d8d 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -41,18 +41,15 @@ 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. 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. - @discardableResult func surfaceMainWindow() -> NSWindow? { + /// closed it) do we recreate it via the scene. + func surfaceMainWindow() { NSApp.activate() if let main = NSApp.windows.first(where: { $0.identifier?.rawValue == MainWindow.id }) { main.deminiaturize(nil) main.makeKeyAndOrderFront(nil) - return main + return } openMainWindow() - return nil } /// True once the launch-time activation has run. diff --git a/App/Blurt/Blurt/Info.plist b/App/Blurt/Blurt/Info.plist index 0301db2..a3de55d 100644 --- a/App/Blurt/Blurt/Info.plist +++ b/App/Blurt/Blurt/Info.plist @@ -24,8 +24,6 @@ public.app-category.productivity LSMinimumSystemVersion 15.0 - NSAppleEventsUsageDescription - Blurt inserts text into the app you're using. NSMicrophoneUsageDescription Blurt uses your microphone to transcribe your dictation with AssemblyAI. diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index 8a7198c..04b47d1 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -96,7 +96,6 @@ targets: LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.productivity NSMicrophoneUsageDescription: Blurt uses your microphone to transcribe your dictation with AssemblyAI. - NSAppleEventsUsageDescription: Blurt inserts text into the app you're using. entitlements: path: Blurt/Blurt.entitlements properties: diff --git a/BLURTENGINE.md b/BLURTENGINE.md index 4a5923a..ad5475d 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -97,13 +97,13 @@ idle → recording → transcribing → injecting → pasted | noTarget Failures surface as `PipelinePhase.failed(BlurtError)`: -| Case | Meaning | -| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.apiKeyMissing` | No AssemblyAI key stored — point the user at your key-entry UI. With a key-presence `readinessCheck`, this surfaces at press time, before any recording. | -| `.microphonePermissionDenied` / `.accessibilityPermissionMissing` | Permission gaps; `PermissionsChecker` has openers for the right Settings panes. | -| `.audioCaptureFailed(underlying:)` | The mic couldn't start, or captured audio couldn't be processed. | -| `.sttFailed(underlying:)` | The Sync request failed; the underlying error carries the HTTP status and the server's message when available. | -| `.targetAppLost` / `.noEditableTarget` | Paste-side outcomes. When thrown by `KeyInjector` the transcript is already on the clipboard, and the session degrades them to `.noTarget` rather than a failure. | +| Case | Meaning | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.apiKeyMissing` | No AssemblyAI key stored — point the user at your key-entry UI. With a key-presence `readinessCheck`, this surfaces at press time, before any recording. | +| `.accessibilityPermissionMissing` | Accessibility isn't granted, so the paste keystroke can't be posted; `PermissionsChecker` has openers for the right Settings panes. | +| `.audioCaptureFailed(underlying:)` | The mic couldn't start, or captured audio couldn't be processed. | +| `.sttFailed(underlying:)` | The Sync request failed; the underlying error carries the HTTP status and the server's message when available. | +| `.targetAppLost` / `.noEditableTarget` | Paste-side outcomes. When thrown by `KeyInjector` the transcript is already on the clipboard, and the session degrades them to `.noTarget` rather than a failure. | All cases are `LocalizedError` with user-ready `errorDescription` strings, and `BlurtError` is `Equatable` (wrapped errors compare by NSError domain + code), so phase equality is test-friendly. diff --git a/Sources/BlurtEngine/BlurtError.swift b/Sources/BlurtEngine/BlurtError.swift index 3052da6..c0a5c0e 100644 --- a/Sources/BlurtEngine/BlurtError.swift +++ b/Sources/BlurtEngine/BlurtError.swift @@ -1,7 +1,6 @@ import Foundation public enum BlurtError: Error, Sendable { - case microphonePermissionDenied case accessibilityPermissionMissing case apiKeyMissing case sttFailed(underlying: Error) @@ -23,7 +22,6 @@ public enum BlurtError: Error, Sendable { extension BlurtError: LocalizedError { public var errorDescription: String? { switch self { - case .microphonePermissionDenied: "Microphone access is required." case .accessibilityPermissionMissing: "Accessibility access is required." case .apiKeyMissing: "Add your AssemblyAI API key in Settings to start dictating." case .sttFailed(let e): "Transcription failed: \(e.localizedDescription)" diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift index 9b97c1e..3af4b79 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift @@ -16,10 +16,12 @@ extension FocusCapture { "AXTextField", "AXTextArea", "AXComboBox", secureFieldRole, "AXSearchField", ] - /// Pure decision: does a focused element with these signals accept pasted text? - /// The injector calls this just before a synthesized ⌘V — if it returns false the - /// paste is skipped (so macOS doesn't beep into a non-editable target) and the - /// transcript is left on the clipboard with a quiet "Copied" notice. + /// Pure decision: do these signals, read off a focused element, mean it accepts + /// pasted text? The injector calls this just before a synthesized ⌘V — if it + /// returns false the paste is skipped (so macOS doesn't beep into a non-editable + /// target) and the transcript is left on the clipboard with a quiet "Copied" + /// notice. The "is anything focused at all?" question is answered by the caller, + /// which never gets this far without an element (see `hasEditableFocusedElement`). /// /// Requires a *positive* editability signal: a known text role, a settable value, /// or an insertion point. Anything else — a non-text control, an unknown role, or @@ -31,10 +33,7 @@ extension FocusCapture { /// injector still pastes into those via a separate Electron-app check (see /// `isElectronApp` / `KeyInjector.insert`), so the user's words aren't dropped /// to copy-only there. - static func isEditableTarget( - hasFocusedElement: Bool, role: String?, valueSettable: Bool, hasInsertionPoint: Bool - ) -> Bool { - guard hasFocusedElement else { return false } + static func isEditableTarget(role: String?, valueSettable: Bool, hasInsertionPoint: Bool) -> Bool { if let role, editableRoles.contains(role) { return true } return valueSettable || hasInsertionPoint } @@ -95,7 +94,6 @@ extension FocusCapture { && rangeRef.flatMap(axRange) != nil return isEditableTarget( - hasFocusedElement: true, role: role, valueSettable: valueSettable, - hasInsertionPoint: hasInsertionPoint) + role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) } } diff --git a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift index b5fd53f..6de6e8d 100644 --- a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift +++ b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift @@ -56,8 +56,7 @@ extension BlurtError { extension BlurtError: Equatable { public static func == (lhs: BlurtError, rhs: BlurtError) -> Bool { switch (lhs, rhs) { - case (.microphonePermissionDenied, .microphonePermissionDenied), - (.accessibilityPermissionMissing, .accessibilityPermissionMissing), + case (.accessibilityPermissionMissing, .accessibilityPermissionMissing), (.apiKeyMissing, .apiKeyMissing), (.targetAppLost, .targetAppLost), (.noEditableTarget, .noEditableTarget): diff --git a/Tests/BlurtEngineTests/BlurtErrorTests.swift b/Tests/BlurtEngineTests/BlurtErrorTests.swift index 4ba8552..15b9d30 100644 --- a/Tests/BlurtEngineTests/BlurtErrorTests.swift +++ b/Tests/BlurtEngineTests/BlurtErrorTests.swift @@ -8,7 +8,6 @@ struct BlurtErrorTests { @Test("each non-wrapping case has a non-empty errorDescription") func descriptionsExist() { let cases: [BlurtError] = [ - .microphonePermissionDenied, .accessibilityPermissionMissing, .apiKeyMissing, .targetAppLost, @@ -39,7 +38,6 @@ struct BlurtErrorTests { func equalSingletons() { #expect(BlurtError.apiKeyMissing == .apiKeyMissing) #expect(BlurtError.targetAppLost == .targetAppLost) - #expect(BlurtError.microphonePermissionDenied == .microphonePermissionDenied) #expect(BlurtError.accessibilityPermissionMissing == .accessibilityPermissionMissing) #expect(BlurtError.noEditableTarget == .noEditableTarget) } @@ -47,7 +45,7 @@ struct BlurtErrorTests { @Test("different singleton cases compare unequal") func unequalSingletons() { #expect(BlurtError.apiKeyMissing != .targetAppLost) - #expect(BlurtError.microphonePermissionDenied != .accessibilityPermissionMissing) + #expect(BlurtError.accessibilityPermissionMissing != .apiKeyMissing) // The two quiet copy-fallback errors are distinct cases, not aliases. #expect(BlurtError.noEditableTarget != .targetAppLost) } diff --git a/Tests/BlurtEngineTests/EditableTargetTests.swift b/Tests/BlurtEngineTests/EditableTargetTests.swift index ad11902..c61ad81 100644 --- a/Tests/BlurtEngineTests/EditableTargetTests.swift +++ b/Tests/BlurtEngineTests/EditableTargetTests.swift @@ -4,18 +4,11 @@ import Testing @Suite("FocusCapture.isEditableTarget") struct EditableTargetTests { - @Test("no focused element is never editable") - func noFocusedElement() { - #expect( - !FocusCapture.isEditableTarget( - hasFocusedElement: false, role: "AXTextField", valueSettable: true, hasInsertionPoint: true)) - } - @Test("a known text role is editable") func textRole() { #expect( FocusCapture.isEditableTarget( - hasFocusedElement: true, role: "AXTextArea", valueSettable: false, hasInsertionPoint: false)) + role: "AXTextArea", valueSettable: false, hasInsertionPoint: false)) } @Test("a secure (password) field is still a paste target despite prompt redaction") @@ -25,29 +18,28 @@ struct EditableTargetTests { // field must type, even though its contents are never captured. #expect( FocusCapture.isEditableTarget( - hasFocusedElement: true, role: FocusCapture.secureFieldRole, valueSettable: false, - hasInsertionPoint: false)) + role: FocusCapture.secureFieldRole, valueSettable: false, hasInsertionPoint: false)) } @Test("a settable value is editable even with an unknown role") func settableValue() { #expect( FocusCapture.isEditableTarget( - hasFocusedElement: true, role: "AXUnknown", valueSettable: true, hasInsertionPoint: false)) + role: "AXUnknown", valueSettable: true, hasInsertionPoint: false)) } @Test("an insertion point is editable even with an unknown role") func insertionPoint() { #expect( FocusCapture.isEditableTarget( - hasFocusedElement: true, role: nil, valueSettable: false, hasInsertionPoint: true)) + role: nil, valueSettable: false, hasInsertionPoint: true)) } @Test("a non-text control with no editable signal is not editable") func nonEditableControl() { #expect( !FocusCapture.isEditableTarget( - hasFocusedElement: true, role: "AXButton", valueSettable: false, hasInsertionPoint: false)) + role: "AXButton", valueSettable: false, hasInsertionPoint: false)) } @Test("an unknown role with no editable signal is not editable (copy, don't beep)") @@ -58,14 +50,14 @@ struct EditableTargetTests { // via the injector's separate Electron-app check, not this signal test.) #expect( !FocusCapture.isEditableTarget( - hasFocusedElement: true, role: "AXWebArea", valueSettable: false, hasInsertionPoint: false)) + role: "AXWebArea", valueSettable: false, hasInsertionPoint: false)) } @Test("a focused element with an unreadable role is not editable (copy, don't beep)") func nilRoleWithoutSignalCopies() { #expect( !FocusCapture.isEditableTarget( - hasFocusedElement: true, role: nil, valueSettable: false, hasInsertionPoint: false)) + role: nil, valueSettable: false, hasInsertionPoint: false)) } } From 86ccfe199c8b2b840323c3b0ae123de3bafffdac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:05:20 +0000 Subject: [PATCH 2/3] Drop the removed error case from main's new isSetupBlocker switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-queue run failed to compile even though both sides were green on their own: main gained an exhaustive `switch` over `BlurtError` in `isSetupBlocker` (#83) while this branch removed the never-constructed `.microphonePermissionDenied` case. The two edits touch different lines, so git merged them without a conflict and the compiler caught it instead — exactly the failure the exhaustive switch is there to produce. Merges origin/main and removes the stale case from that switch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MyARxdyuq4QTjk5WcwTyXu --- Sources/BlurtEngine/Pipeline/PipelinePhase.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift index cd2dc77..93d6808 100644 --- a/Sources/BlurtEngine/Pipeline/PipelinePhase.swift +++ b/Sources/BlurtEngine/Pipeline/PipelinePhase.swift @@ -58,8 +58,8 @@ extension BlurtError { var isSetupBlocker: Bool { switch self { case .apiKeyMissing: true - case .microphonePermissionDenied, .accessibilityPermissionMissing, .sttFailed, - .targetAppLost, .audioCaptureFailed, .noEditableTarget: + case .accessibilityPermissionMissing, .sttFailed, .targetAppLost, .audioCaptureFailed, + .noEditableTarget: false } } From ac1263e60a6756226e03aa85d87aadff38d60234 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 20:11:04 +0000 Subject: [PATCH 3/3] Document that a denied Microphone grant surfaces as .audioCaptureFailed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing `.microphonePermissionDenied` left the engine guide's error table with no mention of Microphone at all, so an integrator reading it had no way to know which case a denied grant arrives as. `MicCapture.start()` throws `.audioCaptureFailed` when `record()` refuses, so say that on the row — and point at `PermissionsChecker` as the up-front check that avoids it. Raised by Copilot's review on #87. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MyARxdyuq4QTjk5WcwTyXu --- BLURTENGINE.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/BLURTENGINE.md b/BLURTENGINE.md index 40d61bc..194c453 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -97,13 +97,13 @@ idle → recording → transcribing → injecting → pasted | noTarget Failures surface as `PipelinePhase.failed(BlurtError)`: -| Case | Meaning | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.apiKeyMissing` | No AssemblyAI key stored — point the user at your key-entry UI. With a key-presence `readinessCheck`, this surfaces at press time, before any recording. | -| `.accessibilityPermissionMissing` | Accessibility isn't granted, so the paste keystroke can't be posted; `PermissionsChecker` has openers for the right Settings panes. | -| `.audioCaptureFailed(underlying:)` | The mic couldn't start, or captured audio couldn't be processed. | -| `.sttFailed(underlying:)` | The Sync request failed; the underlying error carries the HTTP status and the server's message when available. | -| `.targetAppLost` / `.noEditableTarget` | Paste-side outcomes. When thrown by `KeyInjector` the transcript is already on the clipboard, and the session degrades them to `.noTarget` rather than a failure. | +| Case | Meaning | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.apiKeyMissing` | No AssemblyAI key stored — point the user at your key-entry UI. With a key-presence `readinessCheck`, this surfaces at press time, before any recording. | +| `.accessibilityPermissionMissing` | Accessibility isn't granted, so the paste keystroke can't be posted; `PermissionsChecker` has openers for the right Settings panes. | +| `.audioCaptureFailed(underlying:)` | The mic couldn't start, or captured audio couldn't be processed. There is no separate microphone-permission case: a denied or revoked Microphone grant surfaces here, so check `PermissionsChecker` up front to catch it before the user speaks. | +| `.sttFailed(underlying:)` | The Sync request failed; the underlying error carries the HTTP status and the server's message when available. | +| `.targetAppLost` / `.noEditableTarget` | Paste-side outcomes. When thrown by `KeyInjector` the transcript is already on the clipboard, and the session degrades them to `.noTarget` rather than a failure. | All cases are `LocalizedError` with user-ready `errorDescription` strings, and `BlurtError` is `Equatable` (wrapped errors compare by NSError domain + code), so phase equality is test-friendly.