diff --git a/AGENTS.md b/AGENTS.md index fdab15b7..ab44c52d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -945,14 +945,19 @@ 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 -it; an absent value is today's single key with the current style. Each key is still a lone modifier +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 +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 +1225,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/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 d72afe90..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: ", ")) @@ -83,7 +98,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 +117,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) @@ -145,13 +160,13 @@ 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) } } - /// 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..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) } @@ -196,7 +200,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 +212,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/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] 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..f4d35ce2 --- /dev/null +++ b/Tests/BlurtEngineTests/AccountWriteFailureTests.swift @@ -0,0 +1,175 @@ +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) + } +} + +// 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 + } + + /// 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 + } + + 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() } + 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 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(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 keys = RefusingKeyStore() + 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(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 keys = RefusingKeyStore() + let parked = try await signOutParkedOnTheNetwork(keys: keys) + defer { parked.gate.allowToFinish() } + keys.refuse(true) + #expect(try await signIn(parked.account) == .failed(.keychain)) + keys.refuse(false) + parked.gate.allowToFinish() + #expect(await parked.signOut.value == nil) + #expect(parked.account.status == .signedOut) + #expect(sessions.session == nil) + } +} 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..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 { @@ -75,7 +46,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/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) + } +} 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)