diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 11a1630..952d8a7 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 d3d9c59..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. | -| `.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. 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. diff --git a/Sources/BlurtEngine/BlurtError.swift b/Sources/BlurtEngine/BlurtError.swift index a61551b..63b2181 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 underlying): "Transcription failed: \(underlying.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 192fc37..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 } } @@ -68,8 +68,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)) } }