From 43d7d9faa640a776885a192f890692daa62cc76c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 29 Oct 2026 12:43:23 -0700 Subject: [PATCH 1/3] fix: address the review of the commits since v0.1.64, and simplify them Review fixes: - Paste on an unreadable focus only into the named custom-drawn editors (Sublime Text, Sublime Merge, Zed) again. A bare AXWindow or a cannotComplete query elsewhere left the user's words to a beep and a clipboard restore. - SelectionCopy never restores over Blurt's own clipboard writes: SystemClipboard counts them, and the late-copy watcher (and the 100 ms copy wait) give up on one instead of undoing a paste. - The token endpoint's 400/401 is a refusal only for invalid_grant or invalid_client; any other 400 no longer signs the user out. - signOut() starts no renewal while it talks to the server, and keeps a sign-in that lands meanwhile; a rolled-back sign-in doesn't count as one. - A sign-in whose key the Keychain refuses restores the earlier session and revokes the new grant; a renewal whose key it refuses stays due. - An unreadable session item is rechecked every 5 minutes, and the renewal loop re-reads the status each pass. - Turning another key on or off in Key Styles no longer resets the router, so a latched recording survives it. - Rebinding the trigger swaps each key's style with a stash instead of erasing it (new TriggerKeyStyleStash defaults key). - iPhone: delete-takes-back-the-dictation only in the same field with the text after the caret unchanged, and not after the keyboard left. Cleanups (/simplify): - OAuthClient logs refusals once in send(); one rotated-token refresh helper for renew and signOut; SignInStore.Load.session; one Keychain read per renewal pass; status written only when it changes. - AccountRowView keeps one failure state; makeConfigData takes enhanced; TriggerKeyStyleStore.offChoice; the Mac reads StyleProfileStore.limit; pillText and HomeStatus.label derived; a shared unreachable message. - iPhone: one mutex for the claimed press; Styles and Words decode once per redraw; the typing-test clock pauses before the first key. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 +- App/Blurt/Blurt/Overlay/OverlayView.swift | 2 +- .../Overlay/OverlayWindowController.swift | 4 +- App/Blurt/Blurt/Wizard/KeyStylesRow.swift | 6 +- .../Blurt/Wizard/SettingsWindowRoot.swift | 4 +- .../Blurt/Wizard/Steps/AccountRowView.swift | 42 ++-- .../Sources/DictationCoordinator.swift | 23 +- .../BlurtiOS/Sources/StylesView.swift | 9 +- .../BlurtiOS/Sources/TypingTestView.swift | 10 +- App/BlurtiOS/BlurtiOS/Sources/WordsView.swift | 15 +- App/BlurtiOS/BlurtiOSCore/HomeStatus.swift | 5 +- .../BlurtiOSCore/KeyboardModel+Phase.swift | 3 +- .../BlurtiOSCore/KeyboardModel+Typing.swift | 32 ++- App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift | 6 +- App/BlurtiOS/BlurtiOSCore/WordsEdit.swift | 5 +- .../BlurtiOSTests/KeyboardModelTests.swift | 2 +- .../BlurtiOSTests/ProtocolTests.swift | 12 + Sources/AssemblyAI/OAuth/OAuthClient.swift | 43 ++-- .../Account/AssemblyAIAccount.swift | 228 +++++++++-------- .../BlurtEngine/Account/SignInOutcome.swift | 62 +++++ .../BlurtEngine/Account/SignInSession.swift | 5 + .../BlurtEngine/Config/APIKeySubmission.swift | 5 +- Sources/BlurtEngine/Config/DefaultsKey.swift | 4 + .../Config/StyleProfileStore.swift | 1 - .../FocusCapture+Editability.swift | 117 ++++----- .../FocusCapture/FocusCapture.swift | 17 +- .../Hotkey/DictationKeyRouter.swift | 14 +- .../BlurtEngine/Hotkey/TriggerKeyStore.swift | 12 +- .../Hotkey/TriggerKeyStyleStore.swift | 54 +++- .../Injection/SystemClipboard.swift | 13 + .../BlurtEngine/Pipeline/OverlayUIState.swift | 3 - Sources/BlurtEngine/README.md | 2 +- .../STT/AssemblyAITranscriber.swift | 8 +- Sources/BlurtEngine/TTS/SelectionCopy.swift | 50 +++- .../APIKeySubmissionTests.swift | 2 +- .../AccountWriteFailureTests.swift | 231 ++++++++++++++++++ .../AssemblyAIAccountTests.swift | 5 +- .../AssemblyAITranscriberTests.swift | 4 +- .../BrowserBundleIDTests.swift | 39 +++ .../DictationKeyRouterTests.swift | 22 ++ .../EditableTargetTests.swift | 36 --- .../BlurtEngineTests/KeytermsWireTests.swift | 2 +- Tests/BlurtEngineTests/OAuthClientTests.swift | 12 +- .../OverlayUIStateTests.swift | 6 +- .../PersistedSettingsTests.swift | 5 +- Tests/BlurtEngineTests/RewriteModeTests.swift | 3 +- .../BlurtEngineTests/SelectionCopyTests.swift | 41 ++++ .../BlurtEngineTests/SignInSessionTests.swift | 2 +- .../TriggerKeyStyleTests.swift | 27 ++ 49 files changed, 896 insertions(+), 377 deletions(-) create mode 100644 Sources/BlurtEngine/Account/SignInOutcome.swift create mode 100644 Tests/BlurtEngineTests/AccountWriteFailureTests.swift diff --git a/AGENTS.md b/AGENTS.md index fdab15b7..b8883385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -952,7 +952,11 @@ transcripts are off) — or Off for keys other than the bound one. On Advanced r key picker because it's a power-user extra, so General stays short enough for a small display; a row in an existing section and a sheet, not a section or a row per key, because Advanced is already near that limit — a section of its own pushed Reset… off the bottom of CI's screen. `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → style id) holds -it; an absent value is today's single key with the current style. Each key is still a lone modifier +it; an absent value is today's single key with the current style. Rebinding swaps each moved key's +entry with a stash (`BlurtTriggerKeyStyleStash`): the key moved away from goes back to what it was +as an extra before it was bound (off, unless the user had set it up as one), and gets its bound +style back if it is bound again. Turning another key on or off doesn't reset the router, so a +recording latched while the sheet is open survives it. Each key is still a lone modifier from the same set, watched by the same listen-only tap. The press command carries its key (`DictationSession.Command.press(key:)`), and the session resolves that key's style once, on the press's own turn, into a `PressStyle` value: its instructions ride the request's context, its name @@ -1220,9 +1224,15 @@ refused utterance itself can't be retried, its audio having streamed as it was s early) defers the next one to the key's expiry, or a day out once it has expired, instead of asking every minute. Renewal keeps a rotated refresh token before anything else can fail; a refused refresh or exchange means the connection was ended elsewhere and drops key and session, an unreachable server -changes nothing. A renewal suspends twice on the network, so a `generation` counter makes one that -wakes after a sign-out, reset or new sign-in write nothing, and `signOut()` waits out a renewal in -flight first. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and +changes nothing. "Refused" at the token endpoint means an `invalid_grant` or `invalid_client` error, +not any 400: an `invalid_request` mid-deploy or a proxy's page says nothing about the grant. A +renewed key the Keychain won't take leaves the session's dates alone, so the renewal stays due. A +renewal suspends twice on the network, so a `generation` counter makes one that +wakes after a sign-out, reset or new sign-in write nothing; `signOut()` waits out a renewal in +flight first, starts none while it talks to the server, and keeps a sign-in that lands meanwhile. A +sign-in whose key the Keychain refuses puts the earlier session back and revokes the grant it just +minted. An unreadable session item (a locked Keychain) is looked at again every five minutes, and the +loop re-reads the status each pass. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and only then forgets it here; a revoke the server couldn't confirm (503) leaves everything in place and returns an inline report so the user can retry (`SignInFailure.failureReport` makes the same inline-vs-alert split as `APIKeySubmission`). `CredentialRows.resolve` decides which of the account diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index ae58efb0..45768ec3 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -35,7 +35,7 @@ struct OverlayView: View { var body: some View { content // The width is the bridge's: standard, or widened by the controller for a - // notice that writes its reason on the pill (`OverlayUIState.pillText`). + // notice that writes its reason on the pill (`OverlayUIState.pillNotice`). .frame(width: bridge.pillWidth, height: pillSize.height) // Solid, opaque capsule rather than Liquid Glass: a fixed fill never adapts // to the backdrop, so the pill fades in the same dark gray everywhere with diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index 46dc362b..d1303de8 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -63,7 +63,7 @@ final class OverlayWindowController { // `shadowMargin` is checked on both sides rather than only one. private static let panelSize = OverlayPlacement.panelSize( pillSize: pillSize, shadowMargin: shadowMargin) - // Ceiling for a pill that writes its reason out (`OverlayUIState.pillText`): + // Ceiling for a pill that writes its reason out (`OverlayUIState.pillNotice`): // room for the sentence with an ordinary device name; a longer one truncates // rather than stretching the pill across the screen. private static let maxPillWidth: CGFloat = 340 @@ -317,7 +317,7 @@ final class OverlayWindowController { /// type, capped at `maxPillWidth`. Measured with the same font and tracking /// `StatusLineText` draws with, so the text fits without scaling down. static func pillWidth(for state: OverlayUIState) -> CGFloat { - guard let text = state.pillText else { return pillSize.width } + guard let text = state.pillNotice?.text else { return pillSize.width } let measured = NSAttributedString( string: text.uppercased(), attributes: [ diff --git a/App/Blurt/Blurt/Wizard/KeyStylesRow.swift b/App/Blurt/Blurt/Wizard/KeyStylesRow.swift index d72afe90..7a5086af 100644 --- a/App/Blurt/Blurt/Wizard/KeyStylesRow.swift +++ b/App/Blurt/Blurt/Wizard/KeyStylesRow.swift @@ -83,7 +83,7 @@ private struct KeyStylesSheet: View { /// profile — `choice` is one `TriggerKeyStyleStore.choice` has normalized. static func name(of choice: String, profiles: [StyleProfile]) -> String { switch choice { - case "": offName + case TriggerKeyStyleStore.offChoice: offName case TriggerKeyStyleStore.currentStyleID: mainWindowName default: StyleProfileStore.active(in: profiles, id: choice)?.name ?? StyleProfileStore.defaultStyleName } @@ -102,7 +102,7 @@ private struct KeyStylesSheet: View { accessibilityID: UITestIdentifiers.keyStylePicker(key.keyCode), selection: choice(for: key, styles: styles, profiles: profiles) ) { - if key != bound { Text(Self.offName).tag("") } + if key != bound { Text(Self.offName).tag(TriggerKeyStyleStore.offChoice) } Text(Self.mainWindowName).tag(TriggerKeyStyleStore.currentStyleID) Group { Text(StyleProfileStore.defaultStyleName).tag(StyleProfileStore.defaultStyleID) @@ -151,7 +151,7 @@ private struct KeyStylesSheet: View { } } - /// One key's pop-up: `""` is Off, otherwise a style id (see `TriggerKeyStyleStore`). + /// One key's pop-up: `offChoice` is Off, otherwise a style id (see `TriggerKeyStyleStore`). private func choice( for key: TriggerKey, styles: [TriggerKey: String], profiles: [StyleProfile] ) -> Binding { diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 92f7319d..367e9ef5 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -196,7 +196,7 @@ private struct StyleProfilesSection: View { // Ellipsis for the same reason as the API-key row's "Connect…": the // action needs more input before it completes. Button("Add Style…") { editing = StyleProfile(name: "", instructions: "") } - .disabled(profiles.count >= StyleProfileStore.profileLimit) + .disabled(profiles.count >= StyleProfileStore.limit) .accessibilityIdentifier(UITestIdentifiers.styleProfileAdd) } header: { Text("Custom Styles") @@ -208,7 +208,7 @@ private struct StyleProfilesSection: View { enhancedTranscripts ? "A style tells Blurt how to polish your dictations — for example, “casual, all " + "lowercase” for chat. Switch between styles in the main window. Create up to " - + "\(StyleProfileStore.profileLimit) styles." + + "\(StyleProfileStore.limit) styles." : StylesInertNotice.settingsText) } .disabled(!enhancedTranscripts) diff --git a/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift b/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift index a6a6d9d1..fe575125 100644 --- a/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift @@ -15,19 +15,17 @@ struct AccountRowView: View { @State private var signIn = AccountSignIn() /// A sign-in or sign-out in flight; the button is disabled so they can't overlap. @State private var isWorking = false - /// A recoverable failure, shown in red under the row until the next attempt. - @State private var inlineError: String? - @State private var fault: Fault? + /// The last attempt's failure, until the next attempt: a recoverable one in + /// red under the row, a Keychain fault as an alert. The strings are the + /// engine's. + @State private var failure: APIKeySubmission.FailureReport? - /// The alert-shaped failure, carried as view state so the alert can present - /// it. The strings are the engine's. - private struct Fault { - let title: String - let message: String + private var alert: (title: String, message: String)? { + if case .alert(let title, let message) = failure { (title, message) } else { nil } } - private var isPresentingFault: Binding { - Binding(get: { fault != nil }, set: { if !$0 { fault = nil } }) + private var isPresentingAlert: Binding { + Binding(get: { alert != nil }, set: { if !$0 { failure = nil } }) } var body: some View { @@ -35,17 +33,17 @@ struct AccountRowView: View { // parent `Section` while sharing the alert and the teardown. Group { row - if let inlineError { + if case .inline(let message) = failure { // Red, no caution glyph: "retry this", not "something broke". - Text(inlineError) + Text(message) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } } - .alert(fault?.title ?? "", isPresented: isPresentingFault, presenting: fault) { _ in + .alert(alert?.title ?? "", isPresented: isPresentingAlert, presenting: alert) { _ in Button("OK", role: .cancel) {} - } message: { fault in - Text(fault.message) + } message: { alert in + Text(alert.message) } // The row goes away with its window or a Settings tab switch; either ends // the browser session too, since nothing would be left to finish it. @@ -85,28 +83,20 @@ struct AccountRowView: View { Task { let outcome = await signIn.run(account) isWorking = false - if case .failed(let failure) = outcome { report(failure.failureReport) } + if case .failed(let reason) = outcome { failure = reason.failureReport } } } private func signOut() { begin() Task { - let failure = await account.signOut() + failure = await account.signOut() isWorking = false - if let failure { report(failure) } } } private func begin() { isWorking = true - inlineError = nil - } - - private func report(_ report: APIKeySubmission.FailureReport) { - switch report { - case .inline(let message): inlineError = message - case .alert(let title, let message): fault = Fault(title: title, message: message) - } + failure = nil } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 37e52e7c..0a49c4f7 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -61,13 +61,11 @@ final class DictationCoordinator { var context = FocusedFieldContext.empty } nonisolated private static let pendingPress = Mutex(Press()) - /// What the keyboard saw around the cursor on the press that started the - /// dictation in flight, read by the engine's context seam — and cleared once - /// used, so nothing from another app's field primes a later request. - nonisolated private static let pressContext = Mutex(.empty) - /// The keyboard process whose press started the dictation in flight, for the - /// words to go back to it. - nonisolated private static let pressKeyboard = Mutex(nil) + /// The press that started the dictation in flight: its keyboard, for the + /// words to go back to, and what that keyboard saw around the cursor, read + /// by the engine's context seam — and cleared once used, so nothing from + /// another app's field primes a later request. + nonisolated private static let claimedPress = Mutex(Press()) /// Contact names, parsed once per lexicon refresh rather than on the press. nonisolated private static let lexiconNames = Mutex<[String]>([]) /// The words the session just delivered, waiting to be counted. The stats are @@ -89,7 +87,7 @@ final class DictationCoordinator { session = DictationSession( mic: window.source, transcriber: AssemblyAITranscriber(), - injector: KeyboardRelayInjector(presser: { Self.pressKeyboard.withLock { $0 } }), + injector: KeyboardRelayInjector(presser: { Self.claimedPress.withLock { $0.keyboard } }), keyTermsProvider: { Self.keyTerms() }, readinessCheck: apiKey.readinessCheck(), onTranscriptDelivered: { text, ring in @@ -278,16 +276,15 @@ final class DictationCoordinator { defer { pending = Press() } return pending } - pressKeyboard.withLock { $0 = press.keyboard } - pressContext.withLock { $0 = press.context } + claimedPress.withLock { $0 = press } return nil } /// What the keyboard saw around the cursor on the press in flight, once. nonisolated static func fieldContext() -> FocusedFieldContext { - pressContext.withLock { context in - defer { context = .empty } - return context + claimedPress.withLock { press in + defer { press.context = .empty } + return press.context } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift index 511b8191..84a85091 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift @@ -18,11 +18,12 @@ struct StylesView: View { @State private var adding = false private let store = StyleProfileStore() - private var profiles: [StyleProfile] { store.profiles(decoding: profilesRaw) } - private var active: StyleProfile? { StyleProfileStore.active(in: profiles, id: activeRaw) } - private var isFull: Bool { profiles.count >= StyleProfileStore.limit } - var body: some View { + // Decoded once per redraw: the list holds up to 100 profiles, and every + // tap on a style rewrites the active slot and redraws. + let profiles = store.profiles(decoding: profilesRaw) + let active = StyleProfileStore.active(in: profiles, id: activeRaw) + let isFull = profiles.count >= StyleProfileStore.limit List { Section { StyleRow( diff --git a/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift index b9a98e29..f11579f4 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift @@ -65,7 +65,10 @@ struct TypingTestView: View { private var sprint: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { - TimelineView(.periodic(from: .now, by: DesignTokens.Motion.typingTick)) { context in + // Ticks only once the clock runs: before the first key nothing it shows changes. + let schedule = AnimationTimelineSchedule( + minimumInterval: DesignTokens.Motion.typingTick, paused: phase.startDate == nil) + TimelineView(schedule) { context in let remaining = remainingSeconds(at: context.date) let isFinalStretch = phase.startDate != nil && remaining <= Double(TypingTest.finalStretch.components.seconds) @@ -212,6 +215,7 @@ struct TypingTestView: View { let rank = TypingTest.rank(for: wordsPerMinute) let rounded = Int(wordsPerMinute.rounded()) let speedup = TypingTest.speakingWordsPerMinute / max(wordsPerMinute, 1) + let talking = "Talking runs about \(Int(TypingTest.speakingWordsPerMinute)) words a minute." return VStack(alignment: .leading, spacing: DesignTokens.Metrics.typingResultGap) { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { Eyebrow( @@ -238,8 +242,8 @@ struct TypingTestView: View { Text( speedup >= 1.5 - ? "Talking runs about 150 words a minute. That's \(String(format: "%.1f", speedup))× you." - : "Talking runs about 150 words a minute. Honestly, you might not need us." + ? "\(talking) That's \(String(format: "%.1f", speedup))× you." + : "\(talking) Honestly, you might not need us." ) .font(BlurtType.body(DesignTokens.Typography.sizeBody)) .foregroundStyle(BlurtBrand.text) diff --git a/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift index 3545648c..2100e845 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift @@ -56,9 +56,9 @@ private struct KeyTermsCard: View { @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var raw = "" @State private var draft = "" - private var terms: [String] { KeyTerms.parse(raw) } - var body: some View { + // Parsed once per redraw, which every keystroke in the field causes. + let terms = KeyTerms.parse(raw) VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { Eyebrow("Key terms") if !terms.isEmpty { @@ -73,9 +73,9 @@ private struct KeyTermsCard: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.done) - .onSubmit(add) + .onSubmit { add(to: terms) } .brandInput() - Button("Add", action: add) + Button("Add") { add(to: terms) } .buttonStyle(BrandButtonStyle()) .fixedSize() .disabled(KeyTerms.parse(draft).isEmpty || terms.count >= KeyTerms.termCap) @@ -87,6 +87,8 @@ private struct KeyTermsCard: View { ) .brandFootnote() if !terms.isEmpty { + // The whole list, for a friend: a `.blurtterms` file plus the words as text. + let pack = TermPack(name: "Key terms", from: nil, terms: terms) ShareLink( item: pack, subject: Text("Blurt key terms"), message: Text(pack.plainText), preview: SharePreview(pack.name, icon: Image(systemName: "text.badge.plus")) @@ -102,10 +104,7 @@ private struct KeyTermsCard: View { .card() } - /// The whole list, for a friend: a `.blurtterms` file plus the words as text. - private var pack: TermPack { TermPack(name: "Key terms", from: nil, terms: terms) } - - private func add() { + private func add(to terms: [String]) { let updated = WordsEdit.addingKeyTerms(draft, to: terms) guard updated != terms else { return } SharedStore.keyTerms = updated diff --git a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift index 23042e56..af353bf5 100644 --- a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift +++ b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift @@ -5,10 +5,10 @@ import BlurtEngine /// (the keyboard shows those where the user is typing). Plus whether setup is /// complete. Pure, so the wording is tested and the view only lays it out. package nonisolated struct HomeStatus: Equatable { - /// The card's eyebrow: "Session on" or "Session off". - package let label: String /// The listening window is open, so the Blurt keyboard can dictate. package let isOn: Bool + /// The card's eyebrow. + package var label: String { isOn ? "Session on" : "Session off" } /// The line under it: what to do now, and how long the session lasts. package let message: String /// A dictation that failed, said plainly; nil when nothing did. @@ -25,7 +25,6 @@ package nonisolated struct HomeStatus: Equatable { keyboardSeen: Bool ) { isOn = windowOpen - label = windowOpen ? "Session on" : "Session off" if micDenied { message = "Blurt needs microphone access to dictate. Turn it on in Settings." } else if windowOpen { diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift index 68cec9cb..e2bf8174 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift @@ -89,7 +89,8 @@ extension KeyboardModel { // snapshot: the user may have typed since. let inserted = InsertionSeparator.withLeadingSeparator(result.text, after: proxy.documentContextBeforeInput) proxy.insertText(inserted) - lastInsertion = inserted + lastInsertion = Insertion( + text: inserted, document: proxy.documentIdentifier, textAfter: proxy.documentContextAfterInput ?? "") resultLandedAt = Date() // Not typing, so not the term field's to claim. if termDraft != nil { termHostBaseline = proxy.documentContextBeforeInput ?? "" } diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift index f84bbbea..6d44ff44 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift @@ -25,10 +25,11 @@ extension KeyboardModel { readSelection() } - /// Delete right after a dictation takes back all of it — but only while the - /// caret still sits at its end with nothing highlighted. A moved caret, a - /// selection, or a host that shows less context than the dictation's length - /// falls back to deleting one character. + /// Delete right after a dictation takes back all of it — but only in the + /// field it landed in, while the caret still sits at its end with nothing + /// highlighted. Another field, a moved caret, a selection, or a host that + /// shows less context than the dictation's length falls back to deleting one + /// character: text that merely ends the same way isn't the dictation. package func deleteBackward() { UIDevice.current.playInputClick() if termDraft != nil { @@ -36,10 +37,8 @@ extension KeyboardModel { updateShift() return } - if let inserted = lastInsertion, let proxy, (proxy.selectedText ?? "").isEmpty, - proxy.documentContextBeforeInput?.hasSuffix(inserted) == true - { - for _ in inserted { proxy.deleteBackward() } + if let inserted = lastInsertion, let proxy, inserted.isAtCaret(in: proxy) { + for _ in inserted.text { proxy.deleteBackward() } } else { proxy?.deleteBackward() } @@ -293,6 +292,23 @@ extension KeyboardModel { } } +/// A dictation's words as they landed (`KeyboardModel.lastInsertion`): the +/// text, the field it went into, and what followed the caret then. +package struct Insertion: Equatable { + package let text: String + package let document: UUID + package let textAfter: String + + /// Whether the caret still sits at the end of these words in the same + /// field, with nothing highlighted and the text on both sides as it was + /// left. + func isAtCaret(in proxy: any UITextDocumentProxy) -> Bool { + proxy.documentIdentifier == document && (proxy.selectedText ?? "").isEmpty + && (proxy.documentContextAfterInput ?? "") == textAfter + && proxy.documentContextBeforeInput?.hasSuffix(text) == true + } +} + /// The three letter rows, by the language the user types in most. package nonisolated enum LetterLayout { package static let qwerty = ["qwertyuiop", "asdfghjkl", "zxcvbnm"] diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift index 75c42b40..ae51274d 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift @@ -68,8 +68,9 @@ package final class KeyboardModel { @ObservationIgnored package let clockStart = ContinuousClock.now @ObservationIgnored package var lastResultID: UUID? /// What the last dictation put in the field, separator included, so one - /// delete takes the whole of it back. Any keystroke into the host clears it. - @ObservationIgnored package var lastInsertion: String? + /// delete takes the whole of it back. Any keystroke into the host clears it, + /// and so does the keyboard leaving the screen. + @ObservationIgnored package var lastInsertion: Insertion? @ObservationIgnored private var heartbeat: Task? @ObservationIgnored package var lastSpaceAt: ContinuousClock.Instant? @ObservationIgnored package var noticeDwell: Task? @@ -268,6 +269,7 @@ package final class KeyboardModel { gate.reset() } SharedStore.keyboardSeenAt = nil + lastInsertion = nil } deinit { diff --git a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift index ce11c99d..35613425 100644 --- a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift +++ b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift @@ -1,6 +1,5 @@ import AssemblyAI import BlurtEngine -import Foundation /// The Words tab's edits, apart from its views: what adding a key term or a /// text shortcut does to the list it joins. The lists' own rules — parsing and @@ -12,14 +11,14 @@ package nonisolated enum WordsEdit { /// case-insensitively, and the list stops at the request's term cap, so a /// term that couldn't be sent isn't kept either. package static func addingKeyTerms(_ draft: String, to terms: [String]) -> [String] { - Array(KeyTerms.parse(KeyTerms.join(terms + KeyTerms.parse(draft))).prefix(KeyTerms.termCap)) + Array(KeyTerms.normalized(terms + KeyTerms.parse(draft)).prefix(KeyTerms.termCap)) } /// Whether a shortcut from these fields could be saved: a phrase with a /// letter or digit in it, and a replacement that isn't blank. package static func canAddShortcut(trigger: String, expansion: String) -> Bool { !TextShortcut.matchKey(for: trigger).isEmpty - && !expansion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && expansion.trimmedNonEmpty() != nil } /// `shortcuts` with a new one at the top. A phrase already there — by match diff --git a/App/BlurtiOS/BlurtiOSTests/KeyboardModelTests.swift b/App/BlurtiOS/BlurtiOSTests/KeyboardModelTests.swift index c8e92912..830c448f 100644 --- a/App/BlurtiOS/BlurtiOSTests/KeyboardModelTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/KeyboardModelTests.swift @@ -17,7 +17,7 @@ final class FakeProxy: NSObject, UITextDocumentProxy { var documentContextAfterInput: String? { after } var selectedText: String? { selected } var documentInputMode: UITextInputMode? { nil } - var documentIdentifier: UUID { UUID() } + var documentIdentifier = UUID() var hasText: Bool { !before.isEmpty || !after.isEmpty } func insertText(_ text: String) { diff --git a/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift b/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift index 7570029b..0c7dcc5c 100644 --- a/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift @@ -247,6 +247,18 @@ struct KeyboardModelProtocolTests { proxy.selected = "chosen" model.deleteBackward() #expect(proxy.before == "H again moved chose") + proxy.selected = nil + + // Another field whose text happens to end the same way keeps its text. + deliver("OK.") + proxy.documentIdentifier = UUID() + model.deleteBackward() + #expect(proxy.before == "H again moved chose OK") + // Nor does it survive the keyboard leaving the screen. + deliver("bye") + model.disappeared() + model.deleteBackward() + #expect(proxy.before == "H again moved chose OK by") } @Test("the term field never claims a cursor move or a selection") diff --git a/Sources/AssemblyAI/OAuth/OAuthClient.swift b/Sources/AssemblyAI/OAuth/OAuthClient.swift index 82805a2d..b8a419a8 100644 --- a/Sources/AssemblyAI/OAuth/OAuthClient.swift +++ b/Sources/AssemblyAI/OAuth/OAuthClient.swift @@ -97,8 +97,7 @@ public struct OAuthClient: Sendable { /// The connection's API key for this access token. The same key comes back on /// every call until it expires; the first call after that mints the next one. public func apiKey(accessToken: String) async throws(Failure) -> ConnectionKey { - var request = authorizedRequest(configuration.apiKeyURL, accessToken: accessToken) - request.httpMethod = "POST" + let request = authorizedPost(configuration.apiKeyURL, accessToken: accessToken) let (status, data) = try await send(request) switch status { case 200..<300: @@ -109,13 +108,10 @@ public struct OAuthClient: Sendable { apiKey: apiKey, expiresAt: body.apiKeyExpiresAt.flatMap(Self.date(from:)), rotatesAt: body.apiKeyRotatesAt.flatMap(Self.date(from:))) case 401: - Self.logRefusal(request, status: status, data: data) throw .rejected case 403: - Self.logRefusal(request, status: status, data: data) throw .forbidden(Self.explanation(in: data)) default: - Self.logRefusal(request, status: status, data: data) throw .unreachable } } @@ -123,18 +119,15 @@ public struct OAuthClient: Sendable { /// Ends the connection server-side: the grant is revoked and the key disabled. /// Succeeds when there was nothing left to end. public func revoke(accessToken: String) async throws(Failure) { - var request = authorizedRequest(configuration.revokeURL, accessToken: accessToken) - request.httpMethod = "POST" - let (status, data) = try await send(request) + let request = authorizedPost(configuration.revokeURL, accessToken: accessToken) + let (status, _) = try await send(request) switch status { case 200..<300: return case 401: - Self.logRefusal(request, status: status, data: data) throw .rejected // 503 is the server saying the grant revoke failed: the grant is still // live, so this must not look like a sign-out. default: - Self.logRefusal(request, status: status, data: data) throw .unreachable } } @@ -156,33 +149,40 @@ public struct OAuthClient: Sendable { return Tokens( accessToken: accessToken, refreshToken: body.refreshToken?.trimmedNonEmpty(), idToken: body.idToken?.trimmedNonEmpty()) - // RFC 6749 §5.2: a refused grant (`invalid_grant`, an unknown client) is a - // 400 or 401. Anything else says nothing about the credential. - case 400, 401: - Self.logRefusal(request, status: status, data: data) + // RFC 6749 §5.2: a refused grant is a 400 or 401 whose `error` names it. + // Only `invalid_grant` and `invalid_client` say the credential is dead; any + // other 400 (`invalid_request` mid-deploy, a proxy's own page) says nothing + // about it, and reading it as a refusal would sign the user out. + case 400...401 where Self.isRefusedGrant(data): throw .rejected default: - Self.logRefusal(request, status: status, data: data) throw .unreachable } } - private func authorizedRequest(_ url: URL, accessToken: String) -> URLRequest { + private func authorizedPost(_ url: URL, accessToken: String) -> URLRequest { var request = URLRequest(url: url) + request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } return request } + /// The response's status and body. Every refused or failed call is logged + /// here, once, so the callers only map the status to a `Failure`. private func send(_ request: URLRequest) async throws(Failure) -> (Int, Data) { + let status: Int + let data: Data do { - let (data, response) = try await transport.data(for: request) + let (body, response) = try await transport.data(for: request) guard let http = response as? HTTPURLResponse else { throw Failure.unreachable } - return (http.statusCode, data) + (status, data) = (http.statusCode, body) } catch { throw .unreachable } + if !(200..<300).contains(status) { Self.logRefusal(request, status: status, data: data) } + return (status, data) } private struct TokenBody: Decodable { @@ -222,6 +222,13 @@ public struct OAuthClient: Sendable { SDKLog.oauth.error("\(path, privacy: .public) -> \(status, privacy: .public): \(reason, privacy: .public)") } + /// Whether a token-endpoint error body names a dead credential: the refresh + /// token or code (`invalid_grant`) or the client (`invalid_client`). + static func isRefusedGrant(_ data: Data) -> Bool { + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + return ["invalid_grant", "invalid_client"].contains(body?["error"] as? String) + } + /// The 403's reason: `error_description` from an `insufficient_scope` body, or /// FastAPI's `detail`. static func explanation(in data: Data) -> String? { diff --git a/Sources/BlurtEngine/Account/AssemblyAIAccount.swift b/Sources/BlurtEngine/Account/AssemblyAIAccount.swift index 7d1600fe..ddc625d9 100644 --- a/Sources/BlurtEngine/Account/AssemblyAIAccount.swift +++ b/Sources/BlurtEngine/Account/AssemblyAIAccount.swift @@ -40,6 +40,10 @@ public final class AssemblyAIAccount { public static let longestRenewalSleep: TimeInterval = 24 * 60 * 60 /// The shortest: a renewal that keeps failing (offline) retries no faster. public static let shortestRenewalSleep: TimeInterval = 60 + /// How soon `runRenewal` looks again at a session item it couldn't read (a + /// locked Keychain): soon enough that a key due in the meantime still + /// renews, and the status catches up once the Keychain unlocks. + public static let unreadableSessionRecheck: TimeInterval = 5 * 60 /// A rejected key renews at once, but no more often than this, so repeated /// dictation failures can't hammer the token endpoint. public static let rejectedKeyRenewalSpacing: TimeInterval = 15 * 60 @@ -66,6 +70,10 @@ public final class AssemblyAIAccount { /// rotated the refresh token, and refreshing with the old one would read as /// "already over". @ObservationIgnored private var renewal: Task? + /// While `signOut()` is talking to the server, no renewal may start: it + /// would present the same refresh token the sign-out is about to rotate, + /// which reads as reuse and can end the grant mid-sign-out. + @ObservationIgnored private var signingOut = false /// When a rejected key last forced a renewal (`keyWasRejected`). @ObservationIgnored private var lastForcedRenewal: Date? /// Bumped whenever the stored session is replaced or dropped (a sign-in, a @@ -92,11 +100,7 @@ public final class AssemblyAIAccount { self.now = now // An unreadable item (a locked Keychain at launch) starts as signed out; // `refreshStatus()` catches up once it reads. - if case .session(let session) = store.load { - self.status = .signedIn(email: session.email) - } else { - self.status = .signedOut - } + self.status = store.load.session.map { .signedIn(email: $0.email) } ?? .signedOut // `didSet` doesn't run for the initial value. syncDictationOrigin() } @@ -113,11 +117,18 @@ public final class AssemblyAIAccount { /// item can't be read. Called when the account UI appears, like /// `APIKeyModel.refreshStatus()`. public func refreshStatus() { - switch store.load { - case .session(let session): status = .signedIn(email: session.email) - case .none: status = .signedOut - case .unavailable: break + updateStatus(from: store.load) + } + + private func updateStatus(from load: SignInStore.Load) { + let read: Status + switch load { + case .session(let session): read = .signedIn(email: session.email) + case .none: read = .signedOut + case .unavailable: return } + // `@Observable` notifies on every write, unchanged or not. + if read != status { status = read } } /// A new sign-in attempt for the shell to open, or nil when sign-in isn't @@ -128,7 +139,8 @@ public final class AssemblyAIAccount { /// Finishes a sign-in from the consent page's redirect: code → tokens → key, /// then stores both. Nothing is stored unless every step succeeds, so a failed - /// attempt leaves whatever was there (a pasted key, or nothing) as it was. + /// attempt leaves whatever was there (a pasted key, an earlier sign-in, or + /// nothing) as it was. public func completeSignIn(_ authorization: OAuthAuthorization, callback: URL) async -> SignInOutcome { guard let client else { return .failed(.unavailable) } let code: String @@ -151,9 +163,13 @@ public final class AssemblyAIAccount { let session = SignInSession( refreshToken: refreshToken, email: tokens.email, keyExpiresAt: key.expiresAt, keyRotatesAt: key.rotatesAt, renewedAt: now()) - generation += 1 - guard store.save(session), apiKey.save(key.apiKey) else { - store.save(nil) + // A renewal of the session this replaces may be mid-flight with a rotated + // refresh token; let it land, so a failed write below can put it back. + if let renewal { await renewal.value } + guard storeSignIn(session, key: key.apiKey) else { + // Nothing on this Mac can renew or revoke the grant just minted, so end + // it now. Best effort: the user is told the sign-in failed either way. + try? await client.revoke(accessToken: tokens.accessToken) return .failed(.keychain) } status = .signedIn(email: session.email) @@ -163,22 +179,52 @@ public final class AssemblyAIAccount { } } + /// Writes a new sign-in's session and key, or neither: when the Keychain + /// refuses the key, the session item goes back to what it held before. + private func storeSignIn(_ session: SignInSession, key: String) -> Bool { + let previous = store.load + guard store.save(session) else { return false } + guard apiKey.save(key) else { + store.save(previous.session) + return false + } + // Only a sign-in that landed is a new generation: a rolled-back one left + // the session as it was, and a sign-out waiting on the server must still + // keep its rotated token and finish. Nothing here suspends, so no renewal + // or sign-out can observe the writes before the bump. + generation += 1 + return true + } + /// Renews when due and otherwise sleeps until the next renewal is, until /// cancelled. The shell starts it once at launch. The default clock keeps /// counting while the Mac sleeps, so a renewal that fell due during sleep /// runs on wake. public func runRenewal>(clock: C = ContinuousClock()) async { while !Task.isCancelled { - await renewIfDue() - do { try await clock.sleep(for: .seconds(nextRenewalCheck())) } catch { return } + // One read of the item per pass, and a second only after a renewal + // rewrote it. A session item that was unreadable at launch shows as + // signed out (and dictates to production) until something re-reads it. + let load = store.load + updateStatus(from: load) + let renewed = await renewIfDue(load: load) + let next = nextRenewalCheck(for: renewed ? store.load : load) + do { try await clock.sleep(for: .seconds(next)) } catch { return } } } /// Seconds until `runRenewal` should look again: until the session's - /// renewal is due, within the shortest and longest sleep. Signed out (or - /// unreadable), the longest. - func nextRenewalCheck() -> TimeInterval { - guard case .session(let session) = store.load else { return Self.longestRenewalSleep } + /// renewal is due, within the shortest and longest sleep. Signed out, or a + /// build that can't renew, the longest; unreadable, `unreadableSessionRecheck`. + /// `load` is a read of the item the caller already made; nil reads it now. + func nextRenewalCheck(for load: SignInStore.Load? = nil) -> TimeInterval { + guard client != nil else { return Self.longestRenewalSleep } + let session: SignInSession + switch load ?? store.load { + case .session(let stored): session = stored + case .none: return Self.longestRenewalSleep + case .unavailable: return Self.unreadableSessionRecheck + } let untilDue = session.renewalDue.timeIntervalSince(now()) return min(max(untilDue, Self.shortestRenewalSleep), Self.longestRenewalSleep) } @@ -193,9 +239,10 @@ public final class AssemblyAIAccount { if let lastForcedRenewal, now.timeIntervalSince(lastForcedRenewal) < Self.rejectedKeyRenewalSpacing { return } - guard case .session = store.load else { return } + let load = store.load + guard !signingOut, load.session != nil else { return } lastForcedRenewal = now - await renewIfDue(force: true) + await renewIfDue(force: true, load: load) } /// Refreshes and re-exchanges when `SignInSession.isRenewalDue` says so. A @@ -203,19 +250,30 @@ public final class AssemblyAIAccount { /// dashboard, the member removed, the refresh token lapsed), so the key and /// session go and the row offers sign-in again. An unreachable server, or an /// unreadable session item, changes nothing; the next check tries again. - public func renewIfDue() async { - await renewIfDue(force: false) + /// `load` is a read of the item the caller already made; nil reads it now. + /// Returns whether a renewal ran, so `runRenewal` knows its read is stale. + @discardableResult + func renewIfDue(load: SignInStore.Load? = nil) async -> Bool { + await renewIfDue(force: false, load: load ?? store.load) } - private func renewIfDue(force: Bool) async { - if let renewal { return await renewal.value } - guard case .session(let session) = store.load, + /// Whether a renewal ran (this one, or one already in flight), so the + /// caller knows `load` is stale. + @discardableResult + private func renewIfDue(force: Bool, load: SignInStore.Load) async -> Bool { + guard !signingOut else { return false } + if let renewal { + await renewal.value + return true + } + guard let session = load.session, force || session.isRenewalDue(now: now(), hasKey: apiKey.current != nil) - else { return } + else { return false } let task = Task { await renew(session) } renewal = task await task.value renewal = nil + return true } /// Ends the connection server-side, then forgets it here. Returns the failure @@ -224,6 +282,11 @@ public final class AssemblyAIAccount { /// sign-out would be wrong. A refresh token the server already refuses means /// the connection is already over, which counts as signed out. Nil on success. public func signOut() async -> APIKeySubmission.FailureReport? { + // A second click while the first is still talking to the server: that one + // reports. + guard !signingOut else { return nil } + signingOut = true + defer { signingOut = false } if let renewal { await renewal.value } let session: SignInSession switch store.load { @@ -242,22 +305,18 @@ public final class AssemblyAIAccount { } let started = generation do throws(OAuthClient.Failure) { - let tokens = try await client.refresh(session.refreshToken) - // Keep a rotated refresh token before revoking, as `renew` does: the old - // one is dead, and presenting it again reads as reuse, which ends the - // grant. A revoke that fails below must leave a token that still works. - if let rotated = tokens.refreshToken, generation == started { - var rotatedSession = session - rotatedSession.refreshToken = rotated - store.save(rotatedSession) - } + // The rotated token is kept before the revoke: one that fails must leave + // a token that still works. + let (tokens, _) = try await refresh(session, with: client, generation: started) try await client.revoke(accessToken: tokens.accessToken) } catch .rejected { // Nothing left server-side to end. } catch { return .inline(message: Self.signOutUnconfirmedMessage) } - forget() + // A sign-in that completed while this waited on the server is the user's + // newer choice; it stays. + if generation == started { forget() } return nil } @@ -275,25 +334,41 @@ public final class AssemblyAIAccount { status = .signedOut } + /// Refreshes `session`'s access token and stores a rotated refresh token + /// before anything else can fail: the old one is dead, and presenting it + /// again reads as reuse, which ends the grant. The session comes back nil, + /// with nothing written, when a sign-in, sign-out or reset replaced it + /// while the refresh was on the network (`generation` moved past `started`). + private func refresh( + _ session: SignInSession, with client: OAuthClient, generation started: Int + ) async throws(OAuthClient.Failure) -> (OAuthClient.Tokens, SignInSession?) { + let tokens = try await client.refresh(session.refreshToken) + guard generation == started else { return (tokens, nil) } + var session = session + if let rotated = tokens.refreshToken { session.refreshToken = rotated } + session.email = tokens.email ?? session.email + store.save(session) + return (tokens, session) + } + private func renew(_ session: SignInSession) async { guard let client else { return } let started = generation - var session = session do throws(OAuthClient.Failure) { - let tokens = try await client.refresh(session.refreshToken) - guard generation == started else { return } - // Keep a rotated refresh token before anything else can fail: the old one - // is already dead. - if let rotated = tokens.refreshToken { session.refreshToken = rotated } - session.email = tokens.email ?? session.email - store.save(session) + let (tokens, refreshed) = try await refresh(session, with: client, generation: started) + guard var session = refreshed else { return } let key = try await client.apiKey(accessToken: tokens.accessToken) guard generation == started else { return } session.keyExpiresAt = key.expiresAt session.keyRotatesAt = key.rotatesAt session.renewedAt = now() session.deferRenewalIfStillDue(now: session.renewedAt) - if apiKey.current != key.apiKey { apiKey.save(key.apiKey) } + // A key the Keychain refused isn't in place, so the stored dates must stay + // the old key's: the session stays due and the next check tries again. + if apiKey.current != key.apiKey, !apiKey.save(key.apiKey) { + Self.log.error("renewal couldn't store the connection's new key") + return + } store.save(session) status = .signedIn(email: session.email) } catch .unreachable { @@ -303,64 +378,3 @@ public final class AssemblyAIAccount { } } } - -/// How a sign-in attempt ended. -public enum SignInOutcome: Sendable, Equatable { - case signedIn - /// The member closed the window or declined on the consent page. Nothing to - /// report. - case cancelled - case failed(SignInFailure) -} - -/// Why a sign-in failed, with the wording the shell shows. Owned here, next to -/// the outcomes, so the shell renders one judgement instead of assembling text -/// at an alert call site no test reaches. -public enum SignInFailure: Sendable, Equatable { - /// This build has no Connected App configured. - case unavailable - /// The redirect didn't answer this attempt or reported an error. - case consent - /// The exchange refused this member; carries the server's reason. - case forbidden(String?) - /// A credential was refused mid-flow (an expired code, a revoked grant). - case rejected - /// Couldn't reach AssemblyAI, or it failed. - case server - /// The key or the session couldn't be written to the Keychain. - case keychain - - init(_ failure: OAuthClient.Failure) { - switch failure { - case .rejected: self = .rejected - case .forbidden(let reason): self = .forbidden(reason) - case .unreachable: self = .server - } - } - - /// How to report it: inline beside the row for anything a retry (or an admin) - /// can fix, an alert only for the Keychain fault retrying can't touch. The - /// same split `APIKeySubmission.Outcome.failureReport` makes for a pasted key. - public var failureReport: APIKeySubmission.FailureReport { - switch self { - case .keychain: .alert(title: "Couldn’t Save Your Sign-In", message: message) - default: .inline(message: message) - } - } - - var message: String { - switch self { - case .unavailable: - "Signing in isn’t available in this version of Blurt. Use an API key instead." - case .consent, .rejected: - "AssemblyAI didn’t complete the sign-in. Try again." - case .forbidden(let reason): - reason.map { "AssemblyAI didn’t allow it: \($0)" } - ?? "AssemblyAI didn’t allow Blurt to use your account. Ask an admin of your organization." - case .server: - "Couldn’t reach AssemblyAI. Check your connection and try again." - case .keychain: - "Blurt couldn’t write to your macOS Keychain. Check Keychain access and try again." - } - } -} diff --git a/Sources/BlurtEngine/Account/SignInOutcome.swift b/Sources/BlurtEngine/Account/SignInOutcome.swift new file mode 100644 index 00000000..f86ff5cf --- /dev/null +++ b/Sources/BlurtEngine/Account/SignInOutcome.swift @@ -0,0 +1,62 @@ +import AssemblyAI + +/// How a sign-in attempt ended. +public enum SignInOutcome: Sendable, Equatable { + case signedIn + /// The member closed the window or declined on the consent page. Nothing to + /// report. + case cancelled + case failed(SignInFailure) +} + +/// Why a sign-in failed, with the wording the shell shows. Owned here, next to +/// the outcomes, so the shell renders one judgement instead of assembling text +/// at an alert call site no test reaches. +public enum SignInFailure: Sendable, Equatable { + /// This build has no Connected App configured. + case unavailable + /// The redirect didn't answer this attempt or reported an error. + case consent + /// The exchange refused this member; carries the server's reason. + case forbidden(String?) + /// A credential was refused mid-flow (an expired code, a revoked grant). + case rejected + /// Couldn't reach AssemblyAI, or it failed. + case server + /// The key or the session couldn't be written to the Keychain. + case keychain + + init(_ failure: OAuthClient.Failure) { + switch failure { + case .rejected: self = .rejected + case .forbidden(let reason): self = .forbidden(reason) + case .unreachable: self = .server + } + } + + /// How to report it: inline beside the row for anything a retry (or an admin) + /// can fix, an alert only for the Keychain fault retrying can't touch. The + /// same split `APIKeySubmission.Outcome.failureReport` makes for a pasted key. + public var failureReport: APIKeySubmission.FailureReport { + switch self { + case .keychain: .alert(title: "Couldn’t Save Your Sign-In", message: message) + default: .inline(message: message) + } + } + + var message: String { + switch self { + case .unavailable: + "Signing in isn’t available in this version of Blurt. Use an API key instead." + case .consent, .rejected: + "AssemblyAI didn’t complete the sign-in. Try again." + case .forbidden(let reason): + reason.map { "AssemblyAI didn’t allow it: \($0)" } + ?? "AssemblyAI didn’t allow Blurt to use your account. Ask an admin of your organization." + case .server: + APIKeySubmission.unreachableMessage + case .keychain: + "Blurt couldn’t write to your macOS Keychain. Check Keychain access and try again." + } + } +} diff --git a/Sources/BlurtEngine/Account/SignInSession.swift b/Sources/BlurtEngine/Account/SignInSession.swift index 31709b6e..55ba0e40 100644 --- a/Sources/BlurtEngine/Account/SignInSession.swift +++ b/Sources/BlurtEngine/Account/SignInSession.swift @@ -80,6 +80,11 @@ public struct SignInStore: Sendable { case none case session(SignInSession) case unavailable + + /// The stored session, or nil when there is none or it can't be read. + public var session: SignInSession? { + if case .session(let session) = self { session } else { nil } + } } private let read: @Sendable () -> KeychainStore.ReadResult diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index f7c12fac..3da2ea1f 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -34,6 +34,9 @@ public struct APIKeySubmission: Sendable { case alert(title: String, message: String) } + /// AssemblyAI didn't answer — a pasted key's check, or a sign-in's exchange. + static let unreachableMessage = "Couldn’t reach AssemblyAI. Check your connection and try again." + private let keyStore: any APIKeyGateway private let validate: @Sendable (String) async -> APIKeyValidator.Result @@ -84,7 +87,7 @@ extension APIKeySubmission.Outcome { case .invalid: .inline(message: "AssemblyAI rejected that key. Double-check it and try again.") case .unreachable: - .inline(message: "Couldn't reach AssemblyAI. Check your connection and try again.") + .inline(message: APIKeySubmission.unreachableMessage) case .saveFailed: .alert( title: "Couldn’t Save Your Key", diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 43a9c6b5..4f3d6c46 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -30,6 +30,10 @@ enum DefaultsKey: String, CaseIterable { /// Which style each trigger key dictates with, and so which keys beyond the /// bound one dictate at all (`TriggerKeyStyleStore`). case triggerKeyStyles = "TriggerKeyStyles" + /// Each trigger key's entry for the role it isn't in right now — kept across + /// a rebind so moving the bound key away and back loses nothing + /// (`TriggerKeyStyleStore.rebound(from:to:)`). + case triggerKeyStyleStash = "TriggerKeyStyleStash" case soundPack = "SoundPack" case keyTerms = "KeyTerms" case developerMode = "DeveloperMode" diff --git a/Sources/BlurtEngine/Config/StyleProfileStore.swift b/Sources/BlurtEngine/Config/StyleProfileStore.swift index 3879a52a..90576463 100644 --- a/Sources/BlurtEngine/Config/StyleProfileStore.swift +++ b/Sources/BlurtEngine/Config/StyleProfileStore.swift @@ -114,7 +114,6 @@ public struct StyleProfileStore { /// click either way. public static let profileLimit = 4 - // periphery:ignore - public for hosts outside this repo (the iOS app); the Mac app reads profileLimit. /// How many profiles this host allows: `HostIdentity.styleProfileLimit`, /// `profileLimit` unless the host configured another. The cap every list is /// normalized to, on read and on write. diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift index 740ff0d6..f85262b9 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift @@ -101,78 +101,68 @@ isBrowserBundleID(app?.bundleIdentifier) } - /// Whether `app` is AX-opaque — an Electron editor or a web browser — where a - /// focused text field can expose no editable AX signal at all. These are the - /// one case the injector still pastes into on a definite no-signal answer: - /// dropping the user's words into a copy-only fallback there would be - /// the worse mistake. The accepted trade-off is a rare ⌘V beep when such an - /// app truly has nothing editable focused. - static func isAXOpaqueApp(_ app: NSRunningApplication?) -> Bool { - // String prefix checks first, whereas isElectronApp probes the disk - // (FileManager.fileExists) — skip that I/O for the common case. - isBrowserApp(app) || isElectronApp(app) - } + /// Bundle-identifier prefixes of native editors that draw their own text view + /// and expose no text element to Accessibility at all: with the caret in a + /// buffer, the focused element is the bare `AXWindow` — no value, no selection + /// range — or the system-wide query fails outright (`kAXErrorCannotComplete`). + /// Measured on Sublime Text 4 (build 4215), Sublime Merge (build 2132, the + /// commit-message box) and Zed 1.23.2; ⌘V lands in all three. Prefix-matched + /// so channel variants classify with their stable siblings. Named rather than + /// inferred from those AX answers: a native window with no focused control, or + /// a busy app whose query times out, answers the same way, and pasting there + /// beeps and then restores the clipboard over the user's words. + private static let customDrawnEditorBundleIDPrefixes: [String] = [ + "com.sublimetext.", // Sublime Text 3 + 4 + "com.sublimemerge", + "dev.zed.Zed", // Zed + Preview/Nightly + ] - /// What Accessibility says about pasting into the focused element. Three - /// answers, not two: an app can draw its own text view and show AX nothing at - /// all, and that is "can't tell", not "nothing to paste into". - enum FocusedEditability: Equatable { - /// A text role, a settable value, or an insertion point. - case editable - /// AX answered, and what it shows takes no text: ⌘V would only beep. - case notEditable - /// AX couldn't answer. Measured on editors that draw their own text view - /// (Sublime Text 4, Sublime Merge, Zed 1.23.2, where ⌘V lands): with the - /// caret in a buffer the system-wide focus query fails with - /// `kAXErrorCannotComplete`, or the focused element is the bare `AXWindow` - /// with no value and no selection range. - case cannotTell + /// Pure decision behind `isCustomDrawnEditorApp`, split out for the same reason + /// as `isBrowserBundleID`. + static func isCustomDrawnEditorBundleID(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return customDrawnEditorBundleIDPrefixes.contains { bundleID.hasPrefix($0) } } - /// Pure decision for a focus query that resolved no element: a query that - /// couldn't complete can't tell; one that answered "nothing focused" (a native - /// app with no focused control, the desktop) means nothing takes text. - static func editability(ofFailedFocusQuery error: AXError) -> FocusedEditability { - error == .cannotComplete ? .cannotTell : .notEditable + /// Whether `app` is a known custom-drawn editor (Sublime Text, Sublime Merge, + /// Zed). Neither a browser nor Electron, yet just as invisible to Accessibility + /// — so without this it would read as "nothing editable focused" and every + /// dictation would land on the clipboard instead of in the buffer. + static func isCustomDrawnEditorApp(_ app: NSRunningApplication?) -> Bool { + isCustomDrawnEditorBundleID(app?.bundleIdentifier) } - /// Pure decision for a focused element's signals: `isEditableTarget`'s - /// positive signals, else a bare window — the element a custom-drawn editor - /// reports — can't tell, and anything else takes no text. - static func editability( - role: String?, valueSettable: Bool, hasInsertionPoint: Bool - ) -> FocusedEditability { - if isEditableTarget(role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) { - return .editable - } - return role == windowRole ? .cannotTell : .notEditable + /// Whether `app` is AX-opaque — an Electron editor, a web browser, or a + /// custom-drawn editor — where a focused text field can expose no editable AX + /// signal at all. These are the one case the injector still pastes into on no + /// signal: dropping the user's words into a copy-only fallback there would be + /// the worse mistake. The accepted trade-off is a rare ⌘V beep when such an + /// app truly has nothing editable focused. + static func isAXOpaqueApp(_ app: NSRunningApplication?) -> Bool { + // String prefix checks first, whereas isElectronApp probes the disk + // (FileManager.fileExists) — skip that I/O for the common case. + isBrowserApp(app) || isCustomDrawnEditorApp(app) || isElectronApp(app) } - private static let windowRole = "AXWindow" - - /// Whether the system-wide focused element may accept pasted text right now: - /// anything but a definite `.notEditable`. Read by `KeyInjector` (off the main - /// actor, after it has activated the target app) just before pasting — the - /// Accessibility *client* read APIs are thread-safe. Returns `true` whenever AX - /// can't be consulted (process not trusted) or can't tell, so an unknowable - /// state still attempts the paste — the injector's own trust check then - /// handles the missing-permission case. + /// Whether the system-wide focused element can accept pasted text right now. + /// Read by `KeyInjector` (off the main actor, after it has activated the target + /// app) just before pasting — the Accessibility *client* read APIs are + /// thread-safe. Returns `true` whenever AX can't be consulted (process not + /// trusted) or can't resolve a focused element, so an unknowable state still + /// attempts the paste — the injector's own trust check then handles the + /// missing-permission case. nonisolated static func hasEditableFocusedElement() -> Bool { guard AXIsProcessTrusted() else { return true } - return focusedEditability() != .notEditable - } - nonisolated private static func focusedEditability() -> FocusedEditability { - let query = queryFocusedElement() - guard let element = query.element else { - // AX is trusted but resolved no focused element. Usually a native app with - // nothing editable focused (Finder, the desktop, a button-only window), - // where ⌘V only beeps; but a query that couldn't complete is AX failing, - // not an answer. AX-opaque apps (Electron editors like VS Code/Slack, and - // browsers before Chromium's lazy accessibility tree is built) also land - // here, and the injector's app-identity check still pastes into those (see - // `KeyInjector.insert` / `isAXOpaqueApp`). - return editability(ofFailedFocusQuery: query.error) + 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. + // AX-opaque apps (Electron editors like VS Code/Slack, and browsers before + // Chromium's lazy accessibility tree is built) also expose no focused + // element here, but the injector's app-identity check still pastes into + // those (see `KeyInjector.insert` / `isAXOpaqueApp`). + return false } // Same checked reader `captureFieldContext` uses for this attribute, so the @@ -196,7 +186,8 @@ element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success && rangeRef.flatMap(axRange) != nil - return editability(role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) + return isEditableTarget( + role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) } } #endif diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift index 85997f49..447e0b34 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift @@ -126,14 +126,6 @@ /// APIs are thread-safe, so this serves both the off-main context capture /// and the injector's off-main editability check. nonisolated static func systemFocusedElement() -> AXUIElement? { - queryFocusedElement().element - } - - /// `systemFocusedElement` with the query's own answer kept: the editability - /// check tells a query that couldn't complete (`.cannotComplete`) apart from - /// one that found nothing focused. `.success` with a nil element is a - /// misbehaving app's mistyped reply. - nonisolated static func queryFocusedElement() -> (element: AXUIElement?, error: AXError) { let system = AXUIElementCreateSystemWide() // Setting the timeout on the system-wide element applies it process-wide // (per AXUIElement.h), bounding this focused-element lookup AND every later @@ -141,9 +133,12 @@ // miss (the window element behind `windowTitle`, the editability probes). AXUIElementSetMessagingTimeout(system, axMessagingTimeoutSeconds) var focusedRef: CFTypeRef? - let error = AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) - guard error == .success, let focusedRef else { return (nil, error) } - return (axElement(focusedRef), .success) + guard + AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) + == .success, + let focusedRef + else { return nil } + return axElement(focusedRef) } // MARK: - Checked CF downcasts diff --git a/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift b/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift index 1bcfe12e..ee9dc5e4 100644 --- a/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift +++ b/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift @@ -83,14 +83,18 @@ public struct DictationKeyRouter: Sendable { } /// Rebinds the trigger — keys and activation mode, the two halves of the - /// binding Settings can change — and resets: events already tracked belong to - /// the old binding, whose up-event can no longer match (or would resolve under - /// the old mode). The gate is rebuilt because its `activation` is `let`, part - /// of its determinism contract. Returns whether the reset discarded a live - /// recording (see `reset()`). + /// binding Settings can change — and resets when the change touches what the + /// gate is tracking: the active key unbound (its up-event can no longer match) + /// or the mode changed (it would resolve under the old one). The gate is + /// rebuilt then because its `activation` is `let`, part of its determinism + /// contract. Turning some *other* key on or off in Key Styles keeps the state, + /// so a recording latched while Settings is open survives it. Returns whether + /// a reset discarded a live recording (see `reset()`). @discardableResult public mutating func rebind(triggerKeyCodes: Set, activation: TriggerActivation) -> Bool { self.triggerKeyCodes = triggerKeyCodes + let activeKeyStillBound = activeKeyCode.map(triggerKeyCodes.contains) ?? true + guard !activeKeyStillBound || activation != gate.activation else { return false } let discardedRecording = reset() gate = DictationKeyGate(holdThreshold: gate.holdThreshold, activation: activation) return discardedRecording diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift index e58d546a..de4aee61 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift @@ -22,12 +22,14 @@ public struct TriggerKeyStore { nonmutating set { let old = triggerKey defaults.set(newValue.rawValue, forKey: Self.defaultsKey) - // The key moved away from stops dictating. Its Key Styles row offered no - // Off while it was bound, so a style picked for it then says nothing about - // keeping it as an extra key — and left behind, it would silently stay one - // (and, for fn, keep `GlobeKeyOverride` switching 🌐 off). + // The key moved away from stops dictating unless the user had made it an + // extra key before binding it. Its Key Styles row offered no Off while it + // was bound, so a style picked for it then says nothing about keeping it + // as an extra — left behind, it would silently stay one (and, for fn, + // keep `GlobeKeyOverride` switching 🌐 off). The style is stashed, not + // dropped, and comes back if the key is bound again. if old != newValue { - _ = TriggerKeyStyleStore(defaults: defaults).setStyle("", for: old) + TriggerKeyStyleStore(defaults: defaults).rebound(from: old, to: newValue) } } } diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift index 05917313..c7b8a63e 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift @@ -3,9 +3,9 @@ import Foundation /// Persists which style each trigger key dictates with — and so which keys /// dictate at all: the bound key (`TriggerKeyStore`) always does, and any other /// `TriggerKey` does once the user gives it a style in the Advanced pane's Key -/// Styles sheet. Rebinding clears the entry of the key moved away from (see -/// `TriggerKeyStore.triggerKey`), so a former bound key never lingers as an -/// extra trigger. +/// Styles sheet. Rebinding swaps the two keys' entries with a stash +/// (`rebound(from:to:)`), so a former bound key never lingers as an extra +/// trigger, and nothing the user picked is lost by moving away and back. /// Every key is still a lone modifier from the same curated set, watched by the /// same listen-only tap; this only widens which of them the router matches. /// @@ -18,10 +18,14 @@ import Foundation public struct TriggerKeyStyleStore { /// Public so SwiftUI views can observe it directly (e.g. `@AppStorage`). public static var defaultsKey: String { DefaultsKey.triggerKeyStyles.key } + /// The stash `rebound(from:to:)` keeps; same encoding as `defaultsKey`. + public static var stashDefaultsKey: String { DefaultsKey.triggerKeyStyleStash.key } /// The style id for "use the main window's selection". Not a UUID, and not /// `StyleProfileStore.defaultStyleID`, so it can collide with neither. public static let currentStyleID = "current" + /// A Key Styles pop-up's Off: the key doesn't dictate. + public static let offChoice = "" private let defaults: UserDefaults @@ -32,10 +36,10 @@ public struct TriggerKeyStyleStore { /// Each key's chosen style id; a key with no entry is off, unless it is the /// bound key, which dictates with the current style. public var styles: [TriggerKey: String] { - Self.styles(decoding: defaults.string(forKey: Self.defaultsKey) ?? "") + read(Self.defaultsKey) } - /// Gives `key` the style `styleID`, or turns it off for `""` — the Key Styles + /// Gives `key` the style `styleID`, or turns it off for `offChoice` — the Key Styles /// sheet's write, through here because the value is encoded. A choice equal /// to the key's default (`defaultChoice`) is stored as no entry, so the bound /// key set back to the main window's style leaves nothing behind. Returns @@ -46,13 +50,43 @@ public struct TriggerKeyStyleStore { let bound = TriggerKeyStore(defaults: defaults).triggerKey var styles = styles let wasEnabled = Self.enabledKeys(bound: bound, styles: styles) - let isDefault = styleID.isEmpty || styleID == Self.defaultChoice(for: key, bound: bound) + let isDefault = styleID == Self.offChoice || styleID == Self.defaultChoice(for: key, bound: bound) styles[key] = isDefault ? nil : styleID + guard write(styles, to: Self.defaultsKey) else { return false } + return Self.enabledKeys(bound: bound, styles: styles) != wasEnabled + } + + /// Moves the entries when the bound key changes from `old` to `new` + /// (`TriggerKeyStore.triggerKey` calls it). A key's entry means something + /// different in each role — its style while bound, whether and how it dictates + /// as an extra otherwise — so each key keeps the role it's leaving in the + /// stash and takes back the one it's entering: + /// - `old` returns to whatever it was as an extra before it was bound (off, + /// unless the user had set it up as one), and its bound style is stashed. + /// - `new` dictates with the style it had when last bound, else with its + /// style as an extra, and that extra entry is stashed for when it's unbound. + func rebound(from old: TriggerKey, to new: TriggerKey) { + var styles = styles + var stash = read(Self.stashDefaultsKey) + (styles[old], stash[old]) = (stash[old], styles[old]) + let extra = styles[new] + styles[new] = stash[new] ?? extra + stash[new] = extra + write(styles, to: Self.defaultsKey) + write(stash, to: Self.stashDefaultsKey) + } + + private func read(_ key: String) -> [TriggerKey: String] { + Self.styles(decoding: defaults.string(forKey: key) ?? "") + } + + @discardableResult + private func write(_ styles: [TriggerKey: String], to key: String) -> Bool { let raw = Dictionary(uniqueKeysWithValues: styles.map { (String($0.key.rawValue), $0.value) }) guard let data = try? JSONEncoder().encode(raw), let json = String(data: data, encoding: .utf8) else { return false } - defaults.set(json, forKey: Self.defaultsKey) - return Self.enabledKeys(bound: bound, styles: styles) != wasEnabled + defaults.set(json, forKey: key) + return true } /// The mapping behind a raw slot value a view has already observed. Blank or @@ -79,7 +113,7 @@ public struct TriggerKeyStyleStore { Set(styles.keys).union([bound]) } - /// What `key`'s Key Styles pop-up shows: `""` (off), `currentStyleID`, or a style + /// What `key`'s Key Styles pop-up shows: `offChoice`, `currentStyleID`, or a style /// id that still exists. A style since deleted shows as the current style, /// which is what a press of that key then dictates with (see /// `StyleProfileStore.profile(for:)`). @@ -93,6 +127,6 @@ public struct TriggerKeyStyleStore { /// What `key`'s row shows with no entry: the bound key dictates with the main /// window's style, every other key is off. public static func defaultChoice(for key: TriggerKey, bound: TriggerKey) -> String { - key == bound ? currentStyleID : "" + key == bound ? currentStyleID : offChoice } } diff --git a/Sources/BlurtEngine/Injection/SystemClipboard.swift b/Sources/BlurtEngine/Injection/SystemClipboard.swift index eea2dacb..fb390b97 100644 --- a/Sources/BlurtEngine/Injection/SystemClipboard.swift +++ b/Sources/BlurtEngine/Injection/SystemClipboard.swift @@ -1,6 +1,7 @@ #if os(macOS) import AppKit import Foundation + import Synchronization /// A thread-safe, value-type representation of a pasteboard item containing its data /// keyed by pasteboard types, allowing it to cross concurrency boundaries safely. @@ -86,6 +87,16 @@ var changeCount: Int { NSPasteboard.general.changeCount } + /// Counts the writes Blurt itself makes to the pasteboard, bumped *before* + /// each one, so a reader that sees the pasteboard's change count move can + /// then tell whether Blurt made the change (`SelectionCopy`'s late-copy + /// watcher must never restore over a paste's transcript). + private static let ownWrites = Atomic(0) + + var ownWriteCount: Int { Self.ownWrites.load(ordering: .sequentiallyConsistent) } + + private func noteOwnWrite() { Self.ownWrites.add(1, ordering: .sequentiallyConsistent) } + /// Snapshots the pasteboard, or `nil` when it can't be read at all /// (`pasteboardItems` is documented to return nil on error). Callers must treat /// nil as "don't restore" — never as an empty clipboard. @@ -107,12 +118,14 @@ } func setString(_ text: String) { + noteOwnWrite() let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) } func restore(_ saved: PasteboardSnapshot) { + noteOwnWrite() let pasteboard = NSPasteboard.general // The pasteboard genuinely held nothing, so restoring it means emptying it. diff --git a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift index 02aacc4a..d508e1f2 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift @@ -91,9 +91,6 @@ public enum OverlayUIState: Equatable, Sendable { guard case .error(_, let notice) = self else { return nil } return notice } - - /// `pillNotice` as the one line it reads as — what the shell measures. - public var pillText: String? { pillNotice?.text } } extension PipelinePhase { diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 50d9d343..2f63540a 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -204,7 +204,7 @@ func insert(_ text: String, after priorText: String?, windowTitle: String?) asyn `CapturedFocus` is a pid and a display name rather than an `NSRunningApplication`, so the seam is the same on every platform the engine builds for: `KeyInjector` resolves the process handle from the pid on macOS, and a host with no notion of a target app — an iPhone keyboard inserts into the field it is already attached to — receives `nil` and ignores it. -`KeyInjector.insert` **always** pastes: it saves the current pasteboard, writes the transcript, activates the captured target app, posts a synthesized ⌘V, waits for the target to read the clipboard (`pasteSettleDuration`, default 400 ms, tunable in the initializer), then restores the prior pasteboard contents. That restore never destroys what it can't put back: an unreadable pasteboard snapshots as `nil` (not as empty) and is skipped, and the replacement items are built before `clearContents()`, with the plain-string flavor as a floor when promised representations can't be materialized. There is no keystroke-by-keystroke typing path and no length threshold. If the target app is gone or nothing editable is focused it leaves the text on the clipboard and throws `.targetAppLost` / `.noEditableTarget` — which the session turns into the quiet `.noTarget` outcome. Where Accessibility can't tell — the focus query fails with `cannotComplete`, or the focused element is a bare `AXWindow`, which is what editors that draw their own text view (Sublime Text, Zed) report — the paste goes ahead (`FocusCapture.FocusedEditability.cannotTell`). AX-opaque targets — Electron editors and web browsers, per `FocusCapture.isAXOpaqueApp` — are exempt from the editability gate and are pasted into even with no editable AX signal: web content is routinely invisible to Accessibility (Chromium builds its tree lazily; `contenteditable` composers expose no editable role), so "no signal" there usually means "AX can't see the field," not "no field," and the accepted trade-off is a rare beep over dropping the user's words to copy-only. `priorText` (the text before the caret, captured at press time) drives `InsertionSeparator.withLeadingSeparator`, which joins consecutive dictations with a space so they don't run together — a platform-free type, because a host that inserts text itself faces the same decision. When `priorText` is unreadable (an Accessibility-opaque editor, or a browser tab like Google Docs whose canvas-rendered body exposes no AX text), `InsertionSeparator.basis` falls back to what was last pasted — but only when both the target app **and** `windowTitle` match the last successful insert, so the fallback tracks "the same window," not just "the same process" (a browser hosts many unrelated tabs/documents under one PID). +`KeyInjector.insert` **always** pastes: it saves the current pasteboard, writes the transcript, activates the captured target app, posts a synthesized ⌘V, waits for the target to read the clipboard (`pasteSettleDuration`, default 400 ms, tunable in the initializer), then restores the prior pasteboard contents. That restore never destroys what it can't put back: an unreadable pasteboard snapshots as `nil` (not as empty) and is skipped, and the replacement items are built before `clearContents()`, with the plain-string flavor as a floor when promised representations can't be materialized. There is no keystroke-by-keystroke typing path and no length threshold. If the target app is gone or nothing editable is focused it leaves the text on the clipboard and throws `.targetAppLost` / `.noEditableTarget` — which the session turns into the quiet `.noTarget` outcome. AX-opaque targets — Electron editors, web browsers and custom-drawn editors like Sublime Text and Zed, per `FocusCapture.isAXOpaqueApp` — are exempt from the editability gate and are pasted into even with no editable AX signal: web content is routinely invisible to Accessibility (Chromium builds its tree lazily; `contenteditable` composers expose no editable role), so "no signal" there usually means "AX can't see the field," not "no field," and the accepted trade-off is a rare beep over dropping the user's words to copy-only. `priorText` (the text before the caret, captured at press time) drives `InsertionSeparator.withLeadingSeparator`, which joins consecutive dictations with a space so they don't run together — a platform-free type, because a host that inserts text itself faces the same decision. When `priorText` is unreadable (an Accessibility-opaque editor, or a browser tab like Google Docs whose canvas-rendered body exposes no AX text), `InsertionSeparator.basis` falls back to what was last pasted — but only when both the target app **and** `windowTitle` match the last successful insert, so the fallback tracks "the same window," not just "the same process" (a browser hosts many unrelated tabs/documents under one PID). The session calls `setTarget` at press time with the app that was frontmost when recording started — so the paste lands where the user was, even if focus moved during transcription. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index ce700bbe..d0c37ac7 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -279,10 +279,10 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// Neither steering field is defaulted: every caller states both, so what a /// given request does and does not steer with is readable at the call site /// rather than inferred from which argument was left off. - /// `enhanced` is the request's reading of the setting; nil reads it now. + /// `enhanced` is the request's one reading of the setting, which the paste + /// uses too. func makeConfigData( - sampleRate: Int, sttPrompt: String, keytermsPrompt: [String], customStyle: String?, - enhanced: Bool? = nil + sampleRate: Int, sttPrompt: String, keytermsPrompt: [String], customStyle: String?, enhanced: Bool ) throws -> Data { try JSONEncoder().encode( DictationConfig( @@ -292,7 +292,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { keytermsPrompt: keytermsPrompt, llmInstruction: cleanupInstruction(appending: customStyle), // Only while the rewrite is pasted: off, a slower model only adds latency. - rewriteMode: (enhanced ?? enhancedTranscriptsEnabled()) ? rewriteMode().wireValue : nil + rewriteMode: enhanced ? rewriteMode().wireValue : nil ) ) } diff --git a/Sources/BlurtEngine/TTS/SelectionCopy.swift b/Sources/BlurtEngine/TTS/SelectionCopy.swift index 3ffdc7a8..96be592a 100644 --- a/Sources/BlurtEngine/TTS/SelectionCopy.swift +++ b/Sources/BlurtEngine/TTS/SelectionCopy.swift @@ -49,53 +49,74 @@ /// nothing readable. The clipboard is restored whenever the copy changed it. func copySelection(maxCharacters: Int) async -> String? { let pasteboard = pasteboard - // An unreadable pasteboard can't be restored, so don't disturb it. - let (saved, before) = await BlockingIPC.run { (pasteboard.snapshot(), pasteboard.changeCount) } + // An unreadable pasteboard can't be restored, so don't disturb it. The + // own-write count is read first: a Blurt write racing this read can then + // only make the copy give up, never be taken for the app's answer. + let (ownWrites, saved, before) = await BlockingIPC.run { + let ownWrites = pasteboard.ownWriteCount + return (ownWrites, pasteboard.snapshot(), pasteboard.changeCount) + } guard let saved, postCopy() else { return nil } let deadline = ContinuousClock.now + timeout // Wait for a string, not just a change: an app may clear the clipboard and // write it a moment later, and the clear alone moves the change count. var seen = before var copied: String? + var blurtWrote = false repeat { try? await Task.sleep(for: .milliseconds(10)) - (seen, copied) = await BlockingIPC.run { + (seen, copied, blurtWrote) = await BlockingIPC.run { let count = pasteboard.changeCount - guard count != before else { return (count, nil) } + guard count != before else { return (count, nil, false) } + // After the change count, as in `restoreLateCopy`: a change that is + // Blurt's own (an earlier dictation's paste) is already counted. + guard pasteboard.ownWriteCount == ownWrites else { return (count, nil, true) } let string = pasteboard.currentString() // A write between the two reads would pair the string with a stale // count, and the restore would then skip. Poll again instead. - return pasteboard.changeCount == count ? (count, string) : (count, nil) + return pasteboard.changeCount == count ? (count, string, false) : (count, nil, false) } - } while copied == nil && ContinuousClock.now < deadline + } while copied == nil && !blurtWrote && ContinuousClock.now < deadline + // Blurt wrote the clipboard itself: whatever is there is a paste's, not + // the selection, and its own restore puts the user's clipboard back. + guard !blurtWrote else { return nil } let copyCount = seen - let settled = await BlockingIPC.run { + let (settled, writesAtSettle) = await BlockingIPC.run { if copyCount != before { pasteboard.restore(saved, ifChangeCountIs: copyCount) } - return pasteboard.changeCount + // The counter before the change count, so a Blurt write landing + // between them is in the baseline the watcher compares against. + let writes = pasteboard.ownWriteCount + return (pasteboard.changeCount, writes) } // Nothing readable in time, but the app may still answer. Nothing else // would put the user's clipboard back then. - if copied == nil { restoreLateCopy(saved, after: settled) } + if copied == nil { restoreLateCopy(saved, after: settled, ownWrites: writesAtSettle) } // The same cleanup as the AX read, so a zero-width-only copy isn't spoken. return FocusCapture.visibleTextOrNil(FocusCapture.clip(copied?.trimmedNonEmpty(), to: maxCharacters)) } /// Restores `saved` over the first change after `baseline`, if one comes /// within `lateCopyWindow`: the ⌘C's answer arriving after the press gave up - /// on it. - private func restoreLateCopy(_ saved: PasteboardSnapshot, after baseline: Int) { + /// on it. Blurt's own writes are not that answer — the dictation this press + /// starts can paste inside the window — so the first one ends the watch + /// without restoring. A copy the user makes themselves inside the window is + /// indistinguishable from the late answer and is still undone. + private func restoreLateCopy(_ saved: PasteboardSnapshot, after baseline: Int, ownWrites: Int) { let pasteboard = pasteboard let deadline = ContinuousClock.now + lateCopyWindow Task { while ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(20)) - let restored = await BlockingIPC.run { () -> Bool in + let done = await BlockingIPC.run { () -> Bool in let count = pasteboard.changeCount guard count != baseline else { return false } + // Read after the change count: Blurt counts a write before making + // it, so a change that is Blurt's is already counted here. + guard pasteboard.ownWriteCount == ownWrites else { return true } pasteboard.restore(saved, ifChangeCountIs: count) return true } - if restored { return } + if done { return } } } } @@ -105,6 +126,9 @@ /// clipboard. `SystemClipboard` is the production conformance. protocol CopyPasteboard: Sendable { var changeCount: Int { get } + /// How many writes Blurt itself has made to the pasteboard, counted before + /// each write lands. + var ownWriteCount: Int { get } func snapshot() -> PasteboardSnapshot? func currentString() -> String? func restore(_ saved: PasteboardSnapshot, ifChangeCountIs expected: Int) diff --git a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift index 9fa98177..61b5013b 100644 --- a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift +++ b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift @@ -105,7 +105,7 @@ struct APIKeySubmissionTests { func unreachableIsInline() { #expect( APIKeySubmission.Outcome.unreachable.failureReport - == .inline(message: "Couldn't reach AssemblyAI. Check your connection and try again.")) + == .inline(message: APIKeySubmission.unreachableMessage)) } @Test("a Keychain write fault is an alert, not inline text") diff --git a/Tests/BlurtEngineTests/AccountWriteFailureTests.swift b/Tests/BlurtEngineTests/AccountWriteFailureTests.swift new file mode 100644 index 00000000..49452b11 --- /dev/null +++ b/Tests/BlurtEngineTests/AccountWriteFailureTests.swift @@ -0,0 +1,231 @@ +import Foundation +import Security +import Synchronization +import Testing + +@testable import AssemblyAI +@testable import BlurtEngine + +/// An in-memory key store whose writes can be refused, standing in for a +/// Keychain that won't take the key (locked, an ACL prompt pending). +final class RefusingKeyStore: APIKeyGateway { + private let inner = InMemoryAPIKeyStore() + private let refusing = Mutex(false) + + var current: String? { inner.current } + func refuse(_ refuse: Bool) { refusing.withLock { $0 = refuse } } + + @discardableResult + func save(_ key: String?) -> Bool { + guard !refusing.withLock({ $0 }) else { return false } + return inner.save(key) + } +} + +/// Parks the first token request after `hold()` until `release()`, so a test +/// can act while a sign-out waits on the server. Later requests pass straight +/// through, so a request the code under test shouldn't make shows up at the +/// server rather than hanging the test. +final class HoldingTransport: HTTPTransport, Sendable { + private struct State { + var armed = false + var parked: CheckedContinuation? + } + + private let inner: FakeHTTPTransport + private let state = Mutex(State()) + + init(_ inner: FakeHTTPTransport) { self.inner = inner } + + func hold() { state.withLock { $0.armed = true } } + var isParked: Bool { state.withLock { $0.parked != nil } } + + func release() { + let parked = state.withLock { state -> CheckedContinuation? in + defer { state.parked = nil } + return state.parked + } + parked?.resume() + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if request.url == testOAuthConfiguration.tokenURL { + await withCheckedContinuation { continuation in + let park = state.withLock { state -> Bool in + guard state.armed else { return false } + state.armed = false + state.parked = continuation + return true + } + if !park { continuation.resume() } + } + } + return try await inner.data(for: request) + } + + func upload( + for request: URLRequest, streaming body: AsyncThrowingStream, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + try await inner.upload(for: request, streaming: body, delegate: delegate) + } +} + +// What the account does when the Keychain refuses a write, and when a sign-out +// overlaps a renewal or a new sign-in. +extension AssemblyAIAccountTests { + func account(keyStore: RefusingKeyStore, transport: any HTTPTransport) -> AssemblyAIAccount { + let clock = clock + return AssemblyAIAccount( + configuration: testOAuthConfiguration, apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: sessions, transport: transport, now: { clock.now }) + } + + func signIn(_ account: AssemblyAIAccount) async throws -> SignInOutcome { + let auth = try #require(account.beginSignIn()) + let callback = try #require(URL(string: "com.assemblyai.blurt://oauth/callback?code=c&state=\(auth.state)")) + return await account.completeSignIn(auth, callback: callback) + } + + @Test("a sign-in whose key the Keychain refuses keeps the earlier sign-in and revokes the new grant") + func signInKeyRefused() async throws { + let keys = RefusingKeyStore() + let account = account(keyStore: keys, transport: server.transport) + #expect(try await signIn(account) == .signedIn) + + server.state.withLock { + $0.codeGrantRefreshToken = "rt-new" + $0.apiKey = "second-key" + } + keys.refuse(true) + #expect(try await signIn(account) == .failed(.keychain)) + #expect(sessions.session?.refreshToken == "rt-1") + #expect(keys.current == "connection-key") + #expect(account.status == .signedIn(email: "member@example.com")) + #expect(server.state.withLock { $0.revokes } == 1) + } + + @Test("a renewal whose new key the Keychain refuses stays due instead of recording the new key's dates") + func renewalKeyRefused() async throws { + let keys = RefusingKeyStore() + let account = account(keyStore: keys, transport: server.transport) + #expect(try await signIn(account) == .signedIn) + let before = try #require(sessions.session) + + server.state.withLock { + $0.apiKey = "rotated-key" + $0.expiresAt = "2027-01-01T00:00:00Z" + } + keys.refuse(true) + advanceToRenewal() + await account.renewIfDue() + #expect(keys.current == "connection-key") + let after = try #require(sessions.session) + // The rotated refresh token is kept; the old key's dates stand. + #expect(after.refreshToken == "rt-2") + #expect(after.keyExpiresAt == before.keyExpiresAt) + #expect(account.nextRenewalCheck() == AssemblyAIAccount.shortestRenewalSleep) + } + + @Test("an unreadable session item is looked at again soon, and the status catches up once it reads") + func unreadableSessionRechecked() async throws { + let box = InMemoryAPIKeyStore() + let locked = Mutex(true) + let store = SignInStore( + read: { + locked.withLock { $0 } ? .unavailable(errSecInteractionNotAllowed) : box.current.map { .value($0) } ?? .absent + }, + write: { box.save($0) }) + locked.withLock { $0 = false } + store.save( + SignInSession( + refreshToken: "rt-1", email: "member@example.com", keyExpiresAt: nil, keyRotatesAt: nil, + renewedAt: clock.now)) + locked.withLock { $0 = true } + + keyStore.save("connection-key") + let account = AssemblyAIAccount( + configuration: testOAuthConfiguration, apiKey: APIKeyModel(keyStore: keyStore) { _ in .valid }, + store: store, transport: server.transport, now: { [clock] in clock.now }) + #expect(account.status == .signedOut) + #expect(account.nextRenewalCheck() == AssemblyAIAccount.unreadableSessionRecheck) + + locked.withLock { $0 = false } + let loop = Task { await account.runRenewal(clock: TestClock()) } + for _ in 0..<50 where account.status == .signedOut { await Task.yield() } + #expect(account.status == .signedIn(email: "member@example.com")) + loop.cancel() + await loop.value + } + + @Test("no renewal starts while a sign-out is talking to the server") + func noRenewalDuringSignOut() async throws { + let transport = HoldingTransport(server.transport) + let account = account(keyStore: RefusingKeyStore(), transport: transport) + #expect(try await signIn(account) == .signedIn) + + transport.hold() + defer { transport.release() } + let signOut = Task { await account.signOut() } + try await waitUntilParked(transport) + await account.keyWasRejected() + advanceToRenewal() + await account.renewIfDue() + transport.release() + #expect(await signOut.value == nil) + // Only the sign-out's own refresh reached the server. + #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) + #expect(account.status == .signedOut) + } + + @Test("a sign-in that lands while a sign-out waits on the server is kept") + func signInDuringSignOut() async throws { + let transport = HoldingTransport(server.transport) + let keys = RefusingKeyStore() + let account = account(keyStore: keys, transport: transport) + #expect(try await signIn(account) == .signedIn) + + transport.hold() + defer { transport.release() } + let signOut = Task { await account.signOut() } + try await waitUntilParked(transport) + server.state.withLock { + $0.codeGrantRefreshToken = "rt-new" + $0.apiKey = "second-key" + } + #expect(try await signIn(account) == .signedIn) + transport.release() + #expect(await signOut.value == nil) + #expect(account.status == .signedIn(email: "member@example.com")) + #expect(keys.current == "second-key") + #expect(sessions.session?.refreshToken == "rt-new") + } + + @Test("a sign-in that fails while a sign-out waits on the server doesn't stop the sign-out") + func failedSignInDuringSignOut() async throws { + let transport = HoldingTransport(server.transport) + let keys = RefusingKeyStore() + let account = account(keyStore: keys, transport: transport) + #expect(try await signIn(account) == .signedIn) + + transport.hold() + defer { transport.release() } + let signOut = Task { await account.signOut() } + try await waitUntilParked(transport) + keys.refuse(true) + #expect(try await signIn(account) == .failed(.keychain)) + keys.refuse(false) + transport.release() + #expect(await signOut.value == nil) + #expect(account.status == .signedOut) + #expect(sessions.session == nil) + } + + private func waitUntilParked(_ transport: HoldingTransport) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !transport.isParked, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + try #require(transport.isParked) + } +} diff --git a/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift b/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift index 63dffd11..56b568ec 100644 --- a/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAIAccountTests.swift @@ -305,8 +305,5 @@ struct AssemblyAIAccountTests { extension SignInStore { /// The stored session, or nil when there is none or it can't be read. - var session: SignInSession? { - if case .session(let session) = load { return session } - return nil - } + var session: SignInSession? { load.session } } diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index ec86f8c2..fe6e9cbc 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -385,7 +385,9 @@ struct HTTPClientTests { prompt: String = "", enhancedTranscripts: Bool = true, customStyle: String? = nil ) throws -> [String: Any] { let config = try makeTranscriber(apiKey: "test-key", enhancedTranscripts: enhancedTranscripts) - .makeConfigData(sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: [], customStyle: customStyle) + .makeConfigData( + sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: [], customStyle: customStyle, + enhanced: enhancedTranscripts) return try #require(JSONSerialization.jsonObject(with: config) as? [String: Any]) } diff --git a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift index 0e59e4e7..9ec172f5 100644 --- a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift +++ b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift @@ -64,6 +64,43 @@ } } + /// The custom-drawn-editor arm of the AX-opaque exemption: native editors like + /// Sublime Text and Zed that render their own text view and expose no AX text + /// element, so they paste on no signal just as browsers and Electron apps do. + @Suite("FocusCapture.isCustomDrawnEditorBundleID") + struct CustomDrawnEditorBundleIDTests { + @Test( + "known custom-drawn editors classify, channel variants included", + arguments: [ + "com.sublimetext.4", + "com.sublimetext.3", + "com.sublimemerge", + "dev.zed.Zed", + "dev.zed.Zed-Preview", + "dev.zed.Zed-Nightly", + ]) + func knownEditors(bundleID: String) { + #expect(FocusCapture.isCustomDrawnEditorBundleID(bundleID)) + } + + @Test( + "other apps keep the copy-don't-beep fallback", + arguments: [ + "com.apple.TextEdit", + "com.apple.finder", + "com.sublimetext", // the prefix carries the trailing dot + "com.microsoft.VSCode", + ]) + func others(bundleID: String) { + #expect(!FocusCapture.isCustomDrawnEditorBundleID(bundleID)) + } + + @Test("a nil bundle ID is not a custom-drawn editor") + func nilBundleID() { + #expect(!FocusCapture.isCustomDrawnEditorBundleID(nil)) + } + } + /// The other half of the AX-opaque exemption: Electron detection, and the /// `isAXOpaqueApp` disjunction the injector actually calls. /// @@ -150,6 +187,7 @@ let current = NSRunningApplication.current #expect(!FocusCapture.isBrowserApp(current)) #expect(!FocusCapture.isElectronApp(current)) + #expect(!FocusCapture.isCustomDrawnEditorApp(current)) #expect(!FocusCapture.isAXOpaqueApp(current)) } @@ -160,6 +198,7 @@ // target would be attempted anyway. #expect(!FocusCapture.isBrowserApp(nil)) #expect(!FocusCapture.isElectronApp(nil)) + #expect(!FocusCapture.isCustomDrawnEditorApp(nil)) #expect(!FocusCapture.isAXOpaqueApp(nil)) } } diff --git a/Tests/BlurtEngineTests/DictationKeyRouterTests.swift b/Tests/BlurtEngineTests/DictationKeyRouterTests.swift index a93c16c7..40d1a639 100644 --- a/Tests/BlurtEngineTests/DictationKeyRouterTests.swift +++ b/Tests/BlurtEngineTests/DictationKeyRouterTests.swift @@ -198,6 +198,28 @@ struct DictationKeyRouterTests { #expect(router.handle(downEvent(otherModifier), at: .seconds(2)) == .start) } + @Test("turning another key on or off mid-recording keeps the recording") + func rebindOtherKeyKeepsRecording() { + var router = DictationKeyRouter(triggerKeyCodes: [trigger]) + #expect(router.handle(downEvent(trigger), at: .zero) == .start) + #expect(router.handle(upEvent(trigger), at: .milliseconds(200)) == .latch) + let added = router.rebind(triggerKeyCodes: [trigger, otherModifier], activation: .tapOrHold) + #expect(!added) + let removed = router.rebind(triggerKeyCodes: [trigger], activation: .tapOrHold) + #expect(!removed) + // Still latched: the next tap of the active key stops it. + #expect(router.handle(downEvent(trigger), at: .seconds(2)) == .none) + #expect(router.handle(upEvent(trigger), at: .milliseconds(2100)) == .stop) + } + + @Test("changing the mode mid-recording discards it even with the same keys") + func rebindModeMidRecording() { + var router = DictationKeyRouter(triggerKeyCodes: [trigger]) + #expect(router.handle(downEvent(trigger), at: .zero) == .start) + let discarded = router.rebind(triggerKeyCodes: [trigger], activation: .hold) + #expect(discarded) + } + @Test("rebind while idle reports nothing discarded") func rebindWhileIdle() { var router = DictationKeyRouter(triggerKeyCodes: [trigger]) diff --git a/Tests/BlurtEngineTests/EditableTargetTests.swift b/Tests/BlurtEngineTests/EditableTargetTests.swift index 39a11199..48297d38 100644 --- a/Tests/BlurtEngineTests/EditableTargetTests.swift +++ b/Tests/BlurtEngineTests/EditableTargetTests.swift @@ -1,5 +1,4 @@ #if os(macOS) - import ApplicationServices import Testing @testable import BlurtEngine @@ -64,41 +63,6 @@ } } - /// The third answer: AX that can't tell pastes rather than copies, which is - /// what lets editors that draw their own text view (Sublime Text, Zed) take a - /// dictation without being named. - @Suite("FocusCapture.editability") - struct FocusedEditabilityTests { - @Test("a focus query that couldn't complete can't tell") - func cannotComplete() { - #expect(FocusCapture.editability(ofFailedFocusQuery: .cannotComplete) == .cannotTell) - } - - @Test("a focus query that found nothing focused means nothing takes text") - func nothingFocused() { - #expect(FocusCapture.editability(ofFailedFocusQuery: .noValue) == .notEditable) - #expect(FocusCapture.editability(ofFailedFocusQuery: .failure) == .notEditable) - } - - @Test("a bare window with no text signal can't tell") - func bareWindow() { - #expect( - FocusCapture.editability(role: "AXWindow", valueSettable: false, hasInsertionPoint: false) == .cannotTell) - } - - @Test("a text signal is editable, a window's included") - func editableSignals() { - #expect( - FocusCapture.editability(role: "AXTextArea", valueSettable: false, hasInsertionPoint: false) == .editable) - #expect(FocusCapture.editability(role: "AXWindow", valueSettable: false, hasInsertionPoint: true) == .editable) - } - - @Test("any other element without a text signal takes no text", arguments: ["AXButton", "AXGroup", nil]) - func otherElements(role: String?) { - #expect(FocusCapture.editability(role: role, valueSettable: false, hasInsertionPoint: false) == .notEditable) - } - } - @Suite("noTarget phase + overlay mapping") struct NoTargetPhaseTests { @Test("noTarget is terminal") diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index 936b48a2..767f68b3 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -144,7 +144,7 @@ extension HTTPClientTests { enhancedTranscripts: { true }, rewriteMode: { .fast } ) - .makeConfigData(sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: keyterms, customStyle: nil) + .makeConfigData(sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: keyterms, customStyle: nil, enhanced: true) return try #require(JSONSerialization.jsonObject(with: config) as? [String: Any]) } diff --git a/Tests/BlurtEngineTests/OAuthClientTests.swift b/Tests/BlurtEngineTests/OAuthClientTests.swift index 89f4f849..0b29eaca 100644 --- a/Tests/BlurtEngineTests/OAuthClientTests.swift +++ b/Tests/BlurtEngineTests/OAuthClientTests.swift @@ -141,7 +141,7 @@ struct OAuthClientTests { #expect(tokens == OAuthClient.Tokens(accessToken: "fresh", refreshToken: nil, idToken: nil)) } - @Test("the token endpoint's 400/401 is a refusal; 5xx, garbage and offline are unreachable") + @Test("the token endpoint's invalid_grant/invalid_client is a refusal; 5xx, garbage and offline are unreachable") func tokenFailures() async { for (status, expected) in [(400, OAuthClient.Failure.rejected), (401, .rejected), (500, .unreachable)] { let transport = FakeHTTPTransport { _ in (status, json(["error": "invalid_grant"])) } @@ -149,10 +149,20 @@ struct OAuthClientTests { } let garbage = FakeHTTPTransport { _ in (200, Data("nope".utf8)) } await #expect(throws: OAuthClient.Failure.unreachable) { try await client(garbage).refresh("rt") } + let invalidClient = FakeHTTPTransport { _ in (401, json(["error": "invalid_client"])) } + await #expect(throws: OAuthClient.Failure.rejected) { try await client(invalidClient).refresh("rt") } let offline = FakeHTTPTransport.failing(with: URLError(.notConnectedToInternet)) await #expect(throws: OAuthClient.Failure.unreachable) { try await client(offline).refresh("rt") } } + @Test("a 400 that doesn't name a dead credential is not a refusal") + func tokenOtherClientErrors() async { + for data in [json(["error": "invalid_request"]), json(["error": "unsupported_grant_type"]), Data("".utf8)] { + let transport = FakeHTTPTransport { _ in (400, data) } + await #expect(throws: OAuthClient.Failure.unreachable) { try await client(transport).refresh("rt") } + } + } + @Test("the exchange sends the bearer token and reads the key and its expiry") func exchange() async throws { let transport = FakeHTTPTransport { request in diff --git a/Tests/BlurtEngineTests/OverlayUIStateTests.swift b/Tests/BlurtEngineTests/OverlayUIStateTests.swift index f5c50e22..3821fa10 100644 --- a/Tests/BlurtEngineTests/OverlayUIStateTests.swift +++ b/Tests/BlurtEngineTests/OverlayUIStateTests.swift @@ -67,7 +67,7 @@ struct OverlayUIStateTests { == .error( message: "Aggregate Device isn't sending any audio.", notice: PillNotice(subject: "Aggregate Device", predicate: "isn't sending any audio"))) - #expect(state.pillText == "Aggregate Device isn't sending any audio") + #expect(state.pillNotice?.text == "Aggregate Device isn't sending any audio") #expect(state.pillNotice?.subject == "Aggregate Device") #expect(state.pillNotice?.predicate == "isn't sending any audio") #expect(state.accessibilityLabel == "Aggregate Device isn't sending any audio.") @@ -77,7 +77,7 @@ struct OverlayUIStateTests { @Test func silentMicFailureWithoutANameStillSaysWhatHappened() { let error = MicCaptureError.inputNeverDelivered(deviceName: nil) let state = PipelinePhase.failed(.audioCaptureFailed(underlying: error)).overlayState - #expect(state.pillText == "The microphone isn't sending any audio") + #expect(state.pillNotice?.text == "The microphone isn't sending any audio") #expect(state.accessibilityLabel == "The microphone isn't sending any audio.") } @@ -87,7 +87,7 @@ struct OverlayUIStateTests { let others: [OverlayUIState] = [ .idle, .connecting, .recording, .processing, .error(message: "x"), .pasted, .noTarget, ] - #expect(others.allSatisfy { $0.pillText == nil && $0.pillNotice == nil }) + #expect(others.allSatisfy { $0.pillNotice == nil }) } @Test func foreignCaptureErrorsKeepTheirContext() { diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 766db01c..1a9cca1f 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -48,7 +48,7 @@ struct PersistedSettingsTests { // ReadAloudStyleStore contribute two each, StyleProfileStore three): a removed store // must leave the roster in the same change, and a key listed twice would hint // at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 19) + #expect(PersistedSettings.allDefaultsKeys.count == 20) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -64,6 +64,7 @@ struct PersistedSettingsTests { TriggerKeyStore.defaultsKey, TriggerActivationStore.defaultsKey, TriggerKeyStyleStore.defaultsKey, + TriggerKeyStyleStore.stashDefaultsKey, SoundPackStore.defaultsKey, KeyTermsStore.defaultsKey, DeveloperModeStore.defaultsKey, @@ -84,7 +85,7 @@ struct PersistedSettingsTests { #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 19) + #expect(storeKeys.count == 20) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/RewriteModeTests.swift b/Tests/BlurtEngineTests/RewriteModeTests.swift index ac286584..c2a65318 100644 --- a/Tests/BlurtEngineTests/RewriteModeTests.swift +++ b/Tests/BlurtEngineTests/RewriteModeTests.swift @@ -99,7 +99,8 @@ struct RewriteModeTests { let data = try makeTranscriber( apiKey: "test-key", enhancedTranscripts: enhancedTranscripts, rewriteMode: mode ) - .makeConfigData(sampleRate: 16_000, sttPrompt: "", keytermsPrompt: [], customStyle: nil) + .makeConfigData( + sampleRate: 16_000, sttPrompt: "", keytermsPrompt: [], customStyle: nil, enhanced: enhancedTranscripts) return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } } diff --git a/Tests/BlurtEngineTests/SelectionCopyTests.swift b/Tests/BlurtEngineTests/SelectionCopyTests.swift index 1d88fa4c..bb56843c 100644 --- a/Tests/BlurtEngineTests/SelectionCopyTests.swift +++ b/Tests/BlurtEngineTests/SelectionCopyTests.swift @@ -11,6 +11,7 @@ final class FakeCopyPasteboard: CopyPasteboard { var changeCount = 0 var string: String? = "user's clipboard" var restoreCount = 0 + var ownWriteCount = 0 /// A write the app makes just after its clear: applied the moment the /// cleared clipboard is first read, so the copier is guaranteed to see the /// clear on one poll and the write on a later one — without racing a timer. @@ -23,6 +24,7 @@ final class FakeCopyPasteboard: CopyPasteboard { init(readable: Bool = true) { self.readable = readable } var changeCount: Int { state.withLock(\.changeCount) } + var ownWriteCount: Int { state.withLock(\.ownWriteCount) } func snapshot() -> PasteboardSnapshot? { guard readable else { return nil } return PasteboardSnapshot(items: [], plainText: state.withLock(\.string)) @@ -47,6 +49,15 @@ final class FakeCopyPasteboard: CopyPasteboard { } } + /// Blurt writing the clipboard itself, as a paste does. + func ownWrite(_ text: String) { + state.withLock { + $0.ownWriteCount += 1 + $0.string = text + $0.changeCount += 1 + } + } + /// What the target app does on ⌘C. func answerCopy(with copied: String?) -> @Sendable () -> Bool { { [self] in @@ -140,6 +151,36 @@ struct SelectionCopyTests { #expect(pasteboard.string == "user's clipboard") } + @Test("a Blurt paste during the copy wait isn't read as the selection or restored over") + func ownWriteDuringCopyWait() async { + let pasteboard = FakeCopyPasteboard() + let copy = SelectionCopy( + pasteboard: pasteboard, + postCopy: { + pasteboard.ownWrite("an earlier dictation") + return true + }, + timeout: .milliseconds(200)) + #expect(await copy.copySelection(maxCharacters: 100) == nil) + #expect(pasteboard.restoreCount == 0) + #expect(pasteboard.string == "an earlier dictation") + } + + @Test("a paste inside the late-copy window is left alone and ends the watch") + func lateWatchSparesOwnWrite() async throws { + let pasteboard = FakeCopyPasteboard() + let copy = SelectionCopy( + pasteboard: pasteboard, postCopy: { true }, timeout: .milliseconds(20), lateCopyWindow: .milliseconds(300)) + #expect(await copy.copySelection(maxCharacters: 100) == nil) + pasteboard.ownWrite("the dictation") + // A copy after Blurt's write must not be restored either: the watch is over. + try await Task.sleep(for: .milliseconds(60)) + _ = pasteboard.answerCopy(with: "the highlighted text")() + try await Task.sleep(for: .milliseconds(400)) + #expect(pasteboard.restoreCount == 0) + #expect(pasteboard.string == "the highlighted text") + } + @Test("the fallback runs only when AX was unreadable, never on a bare caret") func resolveRule() async { let pasteboard = FakeCopyPasteboard() diff --git a/Tests/BlurtEngineTests/SignInSessionTests.swift b/Tests/BlurtEngineTests/SignInSessionTests.swift index 7a30e552..b6299530 100644 --- a/Tests/BlurtEngineTests/SignInSessionTests.swift +++ b/Tests/BlurtEngineTests/SignInSessionTests.swift @@ -75,7 +75,7 @@ extension AssemblyAIAccountTests { advanceToRenewal() gated.armed.withLock { $0 = true } - let renewal = Task { await account.renewIfDue() } + let renewal = Task { _ = await account.renewIfDue() } await gate.waitUntilEntered() return ParkedRenewal(account: account, gate: gate, renewal: renewal) } diff --git a/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift b/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift index 5d909b31..a6e56c8d 100644 --- a/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift +++ b/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift @@ -68,6 +68,33 @@ struct TriggerKeyStyleTests { #expect(keyStyles.styles == [.function: formal.id.uuidString]) } + @Test("a key bound away and back gets its style back") + func rebindBackRestoresTheStyle() { + let keys = TriggerKeyStore(defaults: defaults) + _ = keyStyles.setStyle(casual.id.uuidString, for: .rightCommand) + keys.triggerKey = .rightOption + #expect(keyStyles.enabledKeys == [.rightOption]) + keys.triggerKey = .rightCommand + #expect(keyStyles.enabledKeys == [.rightCommand]) + #expect(keyStyles.styles == [.rightCommand: casual.id.uuidString]) + } + + @Test("a key set up as an extra before it was bound is an extra again once unbound") + func extraKeySurvivesBeingBound() { + let keys = TriggerKeyStore(defaults: defaults) + _ = keyStyles.setStyle(casual.id.uuidString, for: .rightOption) + keys.triggerKey = .rightOption + // Bound, it keeps dictating with the style it had as an extra. + #expect(keyStyles.styles[.rightOption] == casual.id.uuidString) + _ = keyStyles.setStyle(formal.id.uuidString, for: .rightOption) + keys.triggerKey = .rightCommand + #expect(keyStyles.enabledKeys == [.rightCommand, .rightOption]) + #expect(keyStyles.styles[.rightOption] == casual.id.uuidString) + // And bound again, the style it was given while bound. + keys.triggerKey = .rightOption + #expect(keyStyles.styles[.rightOption] == formal.id.uuidString) + } + @Test("a corrupt value or an unknown keycode binds nothing new") func corruptOrUnknownDecodesToNothing() { #expect(TriggerKeyStyleStore.styles(decoding: "not json").isEmpty) From 9856c1e3eac1446b0bc6302b8ebb5350a5d8ef22 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 29 Oct 2026 13:13:25 -0700 Subject: [PATCH 2/3] test: reuse GatedTransport for the sign-out races (jscpd) The new sign-out tests carried their own HoldingTransport, a copy of SignInSessionTests' GatedTransport, and repeated the park-a-sign-out steps three times. GatedTransport moves to the shared stubs, and one signOutParkedOnTheNetwork helper sets up all three tests. Co-Authored-By: Claude Opus 5.5 --- .../AccountWriteFailureTests.swift | 132 +++++------------- .../BlurtEngineTests/SignInSessionTests.swift | 29 ---- .../Stubs/GatedTransport.swift | 36 +++++ 3 files changed, 74 insertions(+), 123 deletions(-) create mode 100644 Tests/BlurtEngineTests/Stubs/GatedTransport.swift diff --git a/Tests/BlurtEngineTests/AccountWriteFailureTests.swift b/Tests/BlurtEngineTests/AccountWriteFailureTests.swift index 49452b11..f4d35ce2 100644 --- a/Tests/BlurtEngineTests/AccountWriteFailureTests.swift +++ b/Tests/BlurtEngineTests/AccountWriteFailureTests.swift @@ -22,55 +22,6 @@ final class RefusingKeyStore: APIKeyGateway { } } -/// Parks the first token request after `hold()` until `release()`, so a test -/// can act while a sign-out waits on the server. Later requests pass straight -/// through, so a request the code under test shouldn't make shows up at the -/// server rather than hanging the test. -final class HoldingTransport: HTTPTransport, Sendable { - private struct State { - var armed = false - var parked: CheckedContinuation? - } - - private let inner: FakeHTTPTransport - private let state = Mutex(State()) - - init(_ inner: FakeHTTPTransport) { self.inner = inner } - - func hold() { state.withLock { $0.armed = true } } - var isParked: Bool { state.withLock { $0.parked != nil } } - - func release() { - let parked = state.withLock { state -> CheckedContinuation? in - defer { state.parked = nil } - return state.parked - } - parked?.resume() - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - if request.url == testOAuthConfiguration.tokenURL { - await withCheckedContinuation { continuation in - let park = state.withLock { state -> Bool in - guard state.armed else { return false } - state.armed = false - state.parked = continuation - return true - } - if !park { continuation.resume() } - } - } - return try await inner.data(for: request) - } - - func upload( - for request: URLRequest, streaming body: AsyncThrowingStream, - delegate: (any URLSessionTaskDelegate)? - ) async throws -> (Data, URLResponse) { - try await inner.upload(for: request, streaming: body, delegate: delegate) - } -} - // What the account does when the Keychain refuses a write, and when a sign-out // overlaps a renewal or a new sign-in. extension AssemblyAIAccountTests { @@ -158,74 +109,67 @@ extension AssemblyAIAccountTests { await loop.value } - @Test("no renewal starts while a sign-out is talking to the server") - func noRenewalDuringSignOut() async throws { - let transport = HoldingTransport(server.transport) - let account = account(keyStore: RefusingKeyStore(), transport: transport) - #expect(try await signIn(account) == .signedIn) + /// A signed-in account whose sign-out is parked at the token endpoint until + /// the test releases `gate`. + private struct ParkedSignOut { + let account: AssemblyAIAccount + let gate: Gate + let signOut: Task + } - transport.hold() - defer { transport.release() } + private func signOutParkedOnTheNetwork(keys: RefusingKeyStore) async throws -> ParkedSignOut { + let gate = Gate() + let transport = GatedTransport(inner: server.transport, gate: gate) + let account = account(keyStore: keys, transport: transport) + #expect(try await signIn(account) == .signedIn) + transport.armed.withLock { $0 = true } let signOut = Task { await account.signOut() } - try await waitUntilParked(transport) - await account.keyWasRejected() + await gate.waitUntilEntered() + return ParkedSignOut(account: account, gate: gate, signOut: signOut) + } + + @Test("no renewal starts while a sign-out is talking to the server") + func noRenewalDuringSignOut() async throws { + let parked = try await signOutParkedOnTheNetwork(keys: RefusingKeyStore()) + defer { parked.gate.allowToFinish() } + await parked.account.keyWasRejected() advanceToRenewal() - await account.renewIfDue() - transport.release() - #expect(await signOut.value == nil) + await parked.account.renewIfDue() + parked.gate.allowToFinish() + #expect(await parked.signOut.value == nil) // Only the sign-out's own refresh reached the server. #expect(server.state.withLock { $0.refreshTokensSeen } == ["rt-1"]) - #expect(account.status == .signedOut) + #expect(parked.account.status == .signedOut) } @Test("a sign-in that lands while a sign-out waits on the server is kept") func signInDuringSignOut() async throws { - let transport = HoldingTransport(server.transport) let keys = RefusingKeyStore() - let account = account(keyStore: keys, transport: transport) - #expect(try await signIn(account) == .signedIn) - - transport.hold() - defer { transport.release() } - let signOut = Task { await account.signOut() } - try await waitUntilParked(transport) + let parked = try await signOutParkedOnTheNetwork(keys: keys) + defer { parked.gate.allowToFinish() } server.state.withLock { $0.codeGrantRefreshToken = "rt-new" $0.apiKey = "second-key" } - #expect(try await signIn(account) == .signedIn) - transport.release() - #expect(await signOut.value == nil) - #expect(account.status == .signedIn(email: "member@example.com")) + #expect(try await signIn(parked.account) == .signedIn) + parked.gate.allowToFinish() + #expect(await parked.signOut.value == nil) + #expect(parked.account.status == .signedIn(email: "member@example.com")) #expect(keys.current == "second-key") #expect(sessions.session?.refreshToken == "rt-new") } @Test("a sign-in that fails while a sign-out waits on the server doesn't stop the sign-out") func failedSignInDuringSignOut() async throws { - let transport = HoldingTransport(server.transport) let keys = RefusingKeyStore() - let account = account(keyStore: keys, transport: transport) - #expect(try await signIn(account) == .signedIn) - - transport.hold() - defer { transport.release() } - let signOut = Task { await account.signOut() } - try await waitUntilParked(transport) + let parked = try await signOutParkedOnTheNetwork(keys: keys) + defer { parked.gate.allowToFinish() } keys.refuse(true) - #expect(try await signIn(account) == .failed(.keychain)) + #expect(try await signIn(parked.account) == .failed(.keychain)) keys.refuse(false) - transport.release() - #expect(await signOut.value == nil) - #expect(account.status == .signedOut) + parked.gate.allowToFinish() + #expect(await parked.signOut.value == nil) + #expect(parked.account.status == .signedOut) #expect(sessions.session == nil) } - - private func waitUntilParked(_ transport: HoldingTransport) async throws { - let deadline = ContinuousClock.now + .seconds(5) - while !transport.isParked, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(5)) - } - try #require(transport.isParked) - } } diff --git a/Tests/BlurtEngineTests/SignInSessionTests.swift b/Tests/BlurtEngineTests/SignInSessionTests.swift index b6299530..293816d5 100644 --- a/Tests/BlurtEngineTests/SignInSessionTests.swift +++ b/Tests/BlurtEngineTests/SignInSessionTests.swift @@ -6,35 +6,6 @@ import Testing @testable import AssemblyAI @testable import BlurtEngine -/// Parks token-endpoint requests on `gate` while `armed`, to hold a renewal -/// mid-flight; everything else goes straight to `inner`. -private final class GatedTransport: HTTPTransport, Sendable { - let inner: FakeHTTPTransport - let gate: Gate - let armed = Mutex(false) - - init(inner: FakeHTTPTransport, gate: Gate) { - self.inner = inner - self.gate = gate - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - let hold = armed.withLock { armed in - defer { armed = false } - return armed && request.url == testOAuthConfiguration.tokenURL - } - if hold { await gate.enter() } - return try await inner.data(for: request) - } - - func upload( - for request: URLRequest, streaming body: AsyncThrowingStream, - delegate: (any URLSessionTaskDelegate)? - ) async throws -> (Data, URLResponse) { - try await inner.upload(for: request, streaming: body, delegate: delegate) - } -} - // The renewal races: a sign-out or a forget landing while a renewal is parked // on the network must win. extension AssemblyAIAccountTests { diff --git a/Tests/BlurtEngineTests/Stubs/GatedTransport.swift b/Tests/BlurtEngineTests/Stubs/GatedTransport.swift new file mode 100644 index 00000000..fbee733e --- /dev/null +++ b/Tests/BlurtEngineTests/Stubs/GatedTransport.swift @@ -0,0 +1,36 @@ +import Foundation +import Synchronization + +@testable import AssemblyAI +@testable import BlurtEngine + +/// Parks token-endpoint requests on `gate` while `armed`, to hold a renewal +/// or a sign-out mid-flight. Disarms after the first one it parks, so a +/// request the code under test shouldn't make reaches the server instead of +/// hanging the test. Everything else goes straight to `inner`. +final class GatedTransport: HTTPTransport, Sendable { + let inner: FakeHTTPTransport + let gate: Gate + let armed = Mutex(false) + + init(inner: FakeHTTPTransport, gate: Gate) { + self.inner = inner + self.gate = gate + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let hold = armed.withLock { armed in + defer { armed = false } + return armed && request.url == testOAuthConfiguration.tokenURL + } + if hold { await gate.enter() } + return try await inner.data(for: request) + } + + func upload( + for request: URLRequest, streaming body: AsyncThrowingStream, + delegate: (any URLSessionTaskDelegate)? + ) async throws -> (Data, URLResponse) { + try await inner.upload(for: request, streaming: body, delegate: delegate) + } +} From e89b6f73eb86e975f0b55210fa45b246ce6d0748 Mon Sep 17 00:00:00 2001 From: Alex Kroman <12372+alexkroman@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:40:47 -0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(settings):=20move=20Key=20Styles=20fro?= =?UTF-8?q?m=20Advanced=20=E2=80=BA=20Experimental=20to=20the=20Styles=20p?= =?UTF-8?q?ane=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key Styles gets its own section on the Styles pane, under Custom Styles — the styles it picks from — rather than a row of Advanced's Experimental section. Its own section, not a row of Custom Styles, because that one is disabled while enhanced transcripts are off and turning a key on or off still works then. The footer says what the feature is for; the sheet's header now says how it works instead of repeating it. The row's icon is the keyboard, like the sheet's per-key rows, not the style glyph. ExperimentalSection no longer takes the coordinator it only needed for the row. The Key Styles UI tests open the Styles pane. Co-authored-by: Alex Kroman Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 11 ++++---- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 7 ++--- App/Blurt/Blurt/Wizard/KeyStylesRow.swift | 27 ++++++++++++++----- .../Blurt/Wizard/SettingsWindowRoot.swift | 18 ++++++++----- .../Blurt/Wizard/Steps/HotkeyStepView.swift | 2 +- App/Blurt/BlurtUITests/SettingsUITests.swift | 14 +++++----- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8883385..ab44c52d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -945,13 +945,14 @@ the `@AppStorage(TriggerKeyStore.defaultsKey)` + `TriggerKey.fromPersisted` pair restating that pairing per view. The unset default belongs to `fromPersisted` (an absent keycode maps to right ⌘), so views must not re-declare `TriggerKey.rightCommand.rawValue` themselves. -**Per-key styles.** A Key Styles row in the Advanced pane's Experimental section (`KeyStylesRow`) — a status naming the +**Per-key styles.** A Key Styles row in its own section on the Styles pane (`KeyStylesSection`, `KeyStylesRow`) — a status naming the keys set up, and an Edit… button — opens a sheet with one pop-up per `TriggerKey`: the style a press of that key dictates with — Same as main window, Default, or a profile (disabled while enhanced -transcripts are off) — or Off for keys other than the bound one. On Advanced rather than under the -key picker because it's a power-user extra, so General stays short enough for a small display; a -row in an existing section and a sheet, not a section or a row per key, because Advanced is already -near that limit — a section of its own pushed Reset… off the bottom of CI's screen. `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → style id) holds +transcripts are off) — or Off for keys other than the bound one. On Styles, under the profiles it +picks from, rather than under the key picker, so General stays short enough for a small display; +its own section rather than a row of Custom Styles, because that section is disabled while +enhanced transcripts are off and turning a key on or off still works then; one row and a sheet, +not a row per key, to keep the pane short. `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → style id) holds it; an absent value is today's single key with the current style. Rebinding swaps each moved key's entry with a stash (`BlurtTriggerKeyStyleStash`): the key moved away from goes back to what it was as an extra before it was bound (off, unless the user had set it up as one), and gets its bound diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index e677176f..34c3283b 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -50,14 +50,12 @@ struct UpdatesSection: View { } } -/// The Advanced pane's experimental features — per-key styles (`KeyStylesRow`), -/// read-aloud (see `SelectionSpeechStore`), asking about the selection +/// The Advanced pane's experimental features — read-aloud (see +/// `SelectionSpeechStore`), asking about the selection /// (`SelectionAskStore`) and how a read sounds (`ReadAloudStyleStore`). The /// router reads the same defaults these controls write at every press, so a /// change applies to the next one. struct ExperimentalSection: View { - var coordinator: AppCoordinator - @AppStorage(SelectionSpeechStore.defaultsKey) private var selectionSpeech = false @AppStorage(SelectionAskStore.defaultsKey) private var selectionAsk = SelectionAskStore.defaultValue @AppStorage(ReadAloudStyleStore.speedDefaultsKey) private var speed = ReadAloudStyleStore.defaultSpeed @@ -90,7 +88,6 @@ struct ExperimentalSection: View { var body: some View { Section { - KeyStylesRow(coordinator: coordinator) Toggle(isOn: $selectionSpeech) { SettingLabel(title: "Read selected text aloud", systemImage: "speaker.wave.2") } diff --git a/App/Blurt/Blurt/Wizard/KeyStylesRow.swift b/App/Blurt/Blurt/Wizard/KeyStylesRow.swift index 7a5086af..ad6d2733 100644 --- a/App/Blurt/Blurt/Wizard/KeyStylesRow.swift +++ b/App/Blurt/Blurt/Wizard/KeyStylesRow.swift @@ -1,12 +1,27 @@ import BlurtEngine import SwiftUI +/// The Key Styles row in a section of its own, under the styles it picks from. +/// Not a row of Custom Styles: that section is disabled while enhanced +/// transcripts are off, and turning a key on or off still works then (the +/// sheet disables only the styles). +struct KeyStylesSection: View { + let coordinator: AppCoordinator + + var body: some View { + Section { + KeyStylesRow(coordinator: coordinator) + } footer: { + Text("Use a different style with each key, without switching styles in the main window.") + } + } +} + /// The Key Styles row: a status and an "Edit…" button, like the API key row, /// whose sheet gives each trigger key its own style (or Off, for keys other -/// than the bound one). The first row of the Advanced pane's Experimental -/// section rather than beside the dictation key on General — it's a -/// power-user extra, and a section of its own made Advanced too tall for a -/// small display. Settings-only, so it is not a wizard step. +/// than the bound one). On the Styles pane, under the styles it picks from, +/// rather than beside the dictation key on General, which has to stay short +/// enough for a small display. Settings-only, so it is not a wizard step. struct KeyStylesRow: View { var coordinator: AppCoordinator @@ -40,7 +55,7 @@ struct KeyStylesRow: View { bound: bound, styles: TriggerKeyStyleStore.styles(decoding: keyStylesRaw), profiles: StyleProfileStore().profiles(decoding: profilesRaw)) let changed = entries.filter(\.isChanged).map(\.text) - SettingRow(title: "Key Styles", systemImage: "textformat") { + SettingRow(title: "Key Styles", systemImage: "keyboard") { HStack(spacing: 8) { // The keys set up beyond the default, or the main window's style. Text(changed.isEmpty ? KeyStylesSheet.mainWindowName : changed.joined(separator: ", ")) @@ -145,7 +160,7 @@ private struct KeyStylesSheet: View { VStack(alignment: .leading, spacing: 6) { Text("Key Styles") .font(.headline) - Text("Each key can dictate with its own style, without changing the style selected in the main window.") + Text("Pick a style for each key. Turn another key on to dictate with it too.") .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 367e9ef5..714e631b 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -20,7 +20,7 @@ struct SettingsWindowRoot: View { TabView { GeneralSettingsTab(coordinator: coordinator) .tabItem { Label(UITestIdentifiers.generalSettingsTab, systemImage: "gearshape") } - StylesSettingsTab() + StylesSettingsTab(coordinator: coordinator) .tabItem { Label(UITestIdentifiers.stylesSettingsTab, systemImage: "textformat") } VocabularySettingsTab() .tabItem { @@ -66,21 +66,25 @@ private struct GeneralSettingsTab: View { } } -/// The enhanced-transcripts switch and the user's style profiles — their own -/// pane rather than an Advanced section. The switch leads because styles only -/// shape the enhanced rewrite: with it off they're disabled, and the switch that -/// re-enables them sits directly above rather than on another pane. +/// The enhanced-transcripts switch, the user's style profiles, and which style +/// each trigger key dictates with — their own pane rather than an Advanced +/// section. The switch leads because styles only shape the enhanced rewrite: +/// with it off they're disabled, and the switch that re-enables them sits +/// directly above rather than on another pane. private struct StylesSettingsTab: View { + let coordinator: AppCoordinator + var body: some View { SettingsPane { TranscriptionSection() StyleProfilesSection() + KeyStylesSection(coordinator: coordinator) } } } /// The occasional stuff: checking for an update, experimental features -/// (per-key styles among them), the developer-mode log toggle, and the +/// (read-aloud), the developer-mode log toggle, and the /// start-over button. Kept out of General so /// the common pane stays short. private struct AdvancedSettingsTab: View { @@ -90,7 +94,7 @@ private struct AdvancedSettingsTab: View { var body: some View { SettingsPane { UpdatesSection(model: updaterModel) - ExperimentalSection(coordinator: coordinator) + ExperimentalSection() MaintenanceSection() ResetSection(coordinator: coordinator) } diff --git a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift index 1d31e3bb..f1dcc85f 100644 --- a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift @@ -4,7 +4,7 @@ import SwiftUI /// The dictation-key section of the setup/settings screen. Two menu pickers: which /// lone modifier triggers dictation, and whether it activates by tap (toggle), /// hold (push-to-talk), or both; changes are persisted and pushed to the event tap -/// immediately. Giving the other trigger keys their own styles is the Advanced +/// immediately. Giving the other trigger keys their own styles is the Styles /// pane's `KeyStylesRow`. struct HotkeyStepView: View { var coordinator: AppCoordinator diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 1f666cc7..78b819b6 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -247,7 +247,7 @@ final class SettingsUITests: BlurtUITestCase { /// right ⌘ is the bound key: it dictates with the main window's style and, since /// the bound key always dictates, offers no Off; the other keys start Off. func testKeyStylesSheetListsEveryKey() { - let sheet = openKeyStyles(openAdvancedSettings()) + let sheet = openKeyStyles(openStylesSettings()) for keyCode in [rightCommand, rightOption, fnKey] { XCTAssertTrue( @@ -268,7 +268,7 @@ final class SettingsUITests: BlurtUITestCase { /// Turning right ⌥ on persists: the button's spoken summary names it, and the /// sheet shows the choice again when reopened. func testKeyStyleChoicePersists() { - let settings = openAdvancedSettings() + let settings = openStylesSettings() let sheet = openKeyStyles(settings) sheet.popUpButtons[UITestIdentifiers.keyStylePicker(rightOption)].click() @@ -290,7 +290,7 @@ final class SettingsUITests: BlurtUITestCase { /// Escape closes the sheet (it has no Cancel button to carry the shortcut) and /// leaves the Settings window open. func testEscapeClosesKeyStylesSheet() { - let settings = openAdvancedSettings() + let settings = openStylesSettings() let sheet = openKeyStyles(settings) app.typeKey(.escape, modifierFlags: []) @@ -299,12 +299,12 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertTrue(settings.exists, "Escape should close only the sheet, not Settings") } - /// Returns the Advanced pane, where the Key Styles row lives. - private func openAdvancedSettings() -> XCUIElement { - selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.advancedSettingsTab) + /// Returns the Styles pane, where the Key Styles row lives. + private func openStylesSettings() -> XCUIElement { + selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.stylesSettingsTab) } - /// Opens the Key Styles sheet from its row's Edit… button on the Advanced + /// Opens the Key Styles sheet from its row's Edit… button on the Styles /// pane and returns it. private func openKeyStyles(_ settings: XCUIElement) -> XCUIElement { let button = settings.buttons[UITestIdentifiers.keyStylesButton]