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))
}
}