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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions App/Blurt/Blurt/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSAppleEventsUsageDescription</key>
<string>Blurt inserts text into the app you're using.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Blurt uses your microphone to transcribe your dictation with AssemblyAI.</string>
</dict>
Expand Down
1 change: 0 additions & 1 deletion App/Blurt/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions BLURTENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 0 additions & 2 deletions Sources/BlurtEngine/BlurtError.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Foundation

public enum BlurtError: Error, Sendable {
case microphonePermissionDenied
case accessibilityPermissionMissing
case apiKeyMissing
case sttFailed(underlying: Error)
Expand All @@ -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)"
Expand Down
18 changes: 8 additions & 10 deletions Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
}
7 changes: 3 additions & 4 deletions Sources/BlurtEngine/Pipeline/PipelinePhase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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):
Expand Down
4 changes: 1 addition & 3 deletions Tests/BlurtEngineTests/BlurtErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,15 +38,14 @@ struct BlurtErrorTests {
func equalSingletons() {
#expect(BlurtError.apiKeyMissing == .apiKeyMissing)
#expect(BlurtError.targetAppLost == .targetAppLost)
#expect(BlurtError.microphonePermissionDenied == .microphonePermissionDenied)
#expect(BlurtError.accessibilityPermissionMissing == .accessibilityPermissionMissing)
#expect(BlurtError.noEditableTarget == .noEditableTarget)
}

@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)
}
Expand Down
22 changes: 7 additions & 15 deletions Tests/BlurtEngineTests/EditableTargetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)")
Expand All @@ -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))
}
}

Expand Down