From 5f12e175b1719698a7a75ff3a54dab0af4bcfa4b Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 16:55:03 -0500 Subject: [PATCH 1/5] iOS: AssemblyAI-owned identifiers, and API keys in every build Gets the iPhone app ready for TestFlight. - Bundle ids move from the dev.alex placeholders to com.assemblyai.blurt.ios (app, .keyboard, .core, .tests, .probe), the App Group to group.com.assemblyai.blurt.ios, and the term-pack type to com.assemblyai.blurt.terms. The Darwin notification names, log subsystem, queue labels and scripts follow. The Mac app keeps its dev.alex ids: changing them would orphan Sparkle updates and its granted Accessibility permission. - API-key entry is no longer debug-only. Sign in with AssemblyAI doesn't exist yet, so a TestFlight build had no way to add a key. The setup card and Settings now offer "Paste your key" and "Get a free key" in every build, as the Mac app does. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/AudioSource.swift | 4 +-- .../BlurtiOS/Sources/BlurtiOSApp.swift | 2 +- .../BlurtiOS/Sources/KeyEntryView.swift | 6 ++-- .../BlurtiOS/Sources/SettingsView.swift | 6 ++-- App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift | 18 ++++++----- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 10 +++--- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 2 +- App/BlurtiOS/DESIGN.md | 2 +- App/BlurtiOS/README.md | 20 ++++++------ App/BlurtiOS/project.yml | 31 ++++++++++--------- scripts/ios-keyboard-flows.sh | 6 ++-- scripts/ios-record.sh | 2 +- scripts/ios-sim.sh | 2 +- 13 files changed, 56 insertions(+), 55 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift index cd697647..d2f01c07 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift @@ -31,10 +31,10 @@ nonisolated final class WindowedAudioSource: NSObject, ListeningSource, @uncheck private let session = AVCaptureSession() private let output = AVCaptureAudioDataOutput() /// Serial, so the converter below needs no lock. - private let deliveryQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture") + private let deliveryQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture") /// Where the session is started and stopped — both block for a while on a /// phone — so the main actor never waits on the capture stack. - private let controlQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture.control") + private let controlQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture.control") private nonisolated(unsafe) var converter: PCMConverter? var levels: AsyncStream { feed.levels } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index e8dd7066..dc33e93d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -18,7 +18,7 @@ struct BlurtiOSApp: App { HostIdentity.configure( HostIdentity( productName: "Blurt", - subsystem: "dev.alex.blurt.ios", + subsystem: "com.assemblyai.blurt.ios", keychainService: "blurt-ios", defaultsPrefix: "Blurt", logDirectoryName: "Blurt")) diff --git a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift index 823c6afd..0781be08 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift @@ -3,9 +3,9 @@ import BlurtiOSCore import SwiftUI /// Paste an AssemblyAI API key, verified against the API before it is saved -/// (`APIKeySubmission` owns that rule). Debug builds only: the shipping app -/// signs in with AssemblyAI and never shows a key. It exists so the pipeline can -/// be tested on a phone before that service is in place. +/// (`APIKeySubmission` owns that rule). Every build shows it, the way the Mac app +/// works, until Sign in with AssemblyAI exists; then it becomes the advanced +/// path for contributors and self-built copies. struct KeyEntryView: View { var apiKey: APIKeyModel @Environment(\.dismiss) private var dismiss diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index d37bc0d7..8a7e95f5 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -126,10 +126,8 @@ struct SettingsView: View { private var accountSection: some View { Section { - LabeledContent("Sign in with AssemblyAI", value: "Coming soon") - #if DEBUG - Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Use an API key") { showsKeyEntry = true } - #endif + Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Add an API key") { showsKeyEntry = true } + Link("Get a free key", destination: APIKeyStore.dashboardURL) } header: { Eyebrow("Account") } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift index 4f0f5977..9ca3adc9 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift @@ -1,15 +1,17 @@ import AVFoundation +import BlurtEngine import SwiftUI import UIKit /// The setup checklist, on top of the home screen while anything is missing: -/// three numbered steps — sign in (a key, in debug builds), the microphone, -/// the keyboard — each with its control until it is done. +/// three numbered steps — an AssemblyAI API key, the microphone, the keyboard — +/// each with its control until it is done. struct SetupCard: View { let hasKey: Bool let microphoneGranted: Bool let keyboardSeen: Bool - /// Debug builds take an API key in place of the sign-in that doesn't exist yet. + /// Every build takes an API key, as the Mac app does, until Sign in with + /// AssemblyAI exists. let enterKey: () -> Void /// Re-read the permissions after one was asked for. let refresh: () -> Void @@ -17,13 +19,13 @@ struct SetupCard: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { Eyebrow("Set up Blurt") - SetupRow(number: "01", done: hasKey, title: "Sign in with AssemblyAI") { + SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key") { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text("Coming soon — sign-in needs the AssemblyAI login service.") + Text("Blurt uses your own AssemblyAI account. New accounts come with free credits.") .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - #if DEBUG - Button("Use an API key instead") { enterKey() }.buttonStyle(BrandButtonStyle(role: .secondary)) - #endif + Button("Paste your key") { enterKey() }.buttonStyle(BrandButtonStyle(role: .primary)) + Link("Get a free key", destination: APIKeyStore.dashboardURL) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) } } SetupRow(number: "02", done: microphoneGranted, title: "Allow the microphone") { diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index 18bff687..f6246837 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -12,7 +12,7 @@ package nonisolated enum BlurtShared { /// The App Group both targets declare. A placeholder namespace inherited from /// the Mac app; an org-owned id replaces it before the App Store, and the app /// and the keyboard must change together. - package static let appGroup = "group.dev.alex.blurt" + package static let appGroup = "group.com.assemblyai.blurt.ios" /// The URL the keyboard opens to bring the app forward (`blurt://start`) — /// the one moment iOS insists on before the microphone may open. package static let urlScheme = "blurt" @@ -46,10 +46,10 @@ package nonisolated enum BlurtShared { /// Darwin notification names. Reverse-DNS so they can't collide with another /// app's on the same device. package nonisolated enum Signal { - package static let command = "dev.alex.blurt.ios.command" - package static let phase = "dev.alex.blurt.ios.phase" - package static let result = "dev.alex.blurt.ios.result" - package static let lexicon = "dev.alex.blurt.ios.lexicon" + package static let command = "com.assemblyai.blurt.ios.command" + package static let phase = "com.assemblyai.blurt.ios.phase" + package static let result = "com.assemblyai.blurt.ios.result" + package static let lexicon = "com.assemblyai.blurt.ios.lexicon" } } diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index aa82b731..a5633e2a 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -5,7 +5,7 @@ import UniformTypeIdentifiers extension UTType { /// A shared list of Blurt key terms: JSON, `.blurtterms`, declared in /// `project.yml` so a tap on one in Messages opens Blurt. - package nonisolated static let blurtTerms = UTType(exportedAs: "dev.alex.blurt.terms", conformingTo: .json) + package nonisolated static let blurtTerms = UTType(exportedAs: "com.assemblyai.blurt.terms", conformingTo: .json) } /// A list of key terms on its way to a friend — a group chat's names and diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index d09a961a..c179f07d 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -372,7 +372,7 @@ against the request's cap of 100 terms. Settings → Transcription → **Share key terms…** puts the list in the share sheet as a `.blurtterms` file (JSON — `name`, `from`, `terms`; a declared -document type, `dev.alex.blurt.terms`) with the words as the message text. A +document type, `com.assemblyai.blurt.terms`) with the words as the message text. A tap on the file in Messages opens Blurt with "Add N key terms?": every term ticked, untick any, Add merges the rest without duplicates. A `blurt://terms?add=a,b,c&name=…&from=…` link does the same (`TermPack`, diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index ae271b0b..361af67a 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -33,9 +33,10 @@ CI builds it for the simulator on every PR (`check.yml`'s `ios-build` job). On a Mac with only the Command Line Tools — no iOS SDK — `scripts/ios-typecheck.sh` typechecks both targets against the Mac Catalyst frameworks with the same flags, which catches the Swift 6 isolation errors before CI does. Running -on a phone needs Xcode with a team that can provision the App Group; every id in -`project.yml` (`dev.alex.blurt.ios`, `group.dev.alex.blurt`) is a placeholder -inherited from the Mac app and must become org-owned before the App Store. +on a phone needs Xcode with a team that can provision the App Group. The ids in +`project.yml` (`com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard`, +`group.com.assemblyai.blurt.ios`) are AssemblyAI's, so they register under the AssemblyAI team; +anyone building under another team changes them locally (see Testing on a phone). ## Design @@ -128,13 +129,12 @@ shows its console. characters after the team's name). The App Group needs a paid team — a free Personal Team cannot register one, so use the org's team, not your own. 3. `cd App/BlurtiOS && BLURT_TEAM= xcodegen generate && open BlurtiOS.xcodeproj`. - Automatic signing registers `dev.alex.blurt.ios`, `dev.alex.blurt.ios.keyboard` and - `group.dev.alex.blurt` under that team on the first build; it fails if another team already + Automatic signing registers `com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard` and + `group.com.assemblyai.blurt.ios` under that team on the first build; it fails if another team already owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Use an API key instead** (debug builds - only), **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. @@ -145,9 +145,9 @@ shows its console. - **Autocorrect and the suggestion bar** on the full keyboard. -- **Sign in with AssemblyAI.** The shipping app signs the user in and gets a key - behind the scenes; that service does not exist yet. Debug builds show "Use an API - key instead" (`KeyEntryView`) so the pipeline can be tested now. +- **Sign in with AssemblyAI.** The shipping app will sign the user in and get a key + behind the scenes; that service does not exist yet. Until it does, every build + (TestFlight included) takes a pasted API key (`KeyEntryView`), as the Mac app does. - **Returning to the host app.** After "Start Blurt" the user swipes back; iOS 26.4 ended automatic switch-back for everyone. - **The full keyboard** has no autocorrect or suggestions yet. diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 8f45c3f7..df4d7bc8 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -9,13 +9,14 @@ # # Two processes, one App Group. iOS lets no keyboard use the microphone, so the # app records and talks to AssemblyAI while the keyboard is a remote control that -# inserts the result; they share `group.dev.alex.blurt` (see Shared/). Every id -# here is a placeholder namespace inherited from the Mac app — the App Store -# submission needs org-owned bundle ids, an org App Group and a team that can -# provision them. +# inserts the result; they share `group.com.assemblyai.blurt.ios` (see +# BlurtiOSCore/SharedState.swift). Every id is AssemblyAI-owned +# (`com.assemblyai.blurt.ios…`); the team comes from $BLURT_TEAM, never this +# file. The Mac app keeps its own `dev.alex.blurt` ids: changing them would +# orphan its Sparkle updates and its granted Accessibility permission. name: BlurtiOS options: - bundleIdPrefix: dev.alex + bundleIdPrefix: com.assemblyai deploymentTarget: # Matches Package.swift's `.iOS(.v18)`: an app floor below the package's fails # to resolve the dependency at all. @@ -57,7 +58,7 @@ targets: - path: BlurtiOSCore settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.core + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.core GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -97,7 +98,7 @@ targets: - KeyboardViewController.swift settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # The app-level accent, from the catalog, so system-drawn chrome and our # views agree (see the Mac's project.yml and BlurtBrand). @@ -132,7 +133,7 @@ targets: # `blurt://start` — what the keyboard opens to bring the app forward, the # one moment iOS insists on before the microphone may open. CFBundleURLTypes: - - CFBundleURLName: dev.alex.blurt.ios + - CFBundleURLName: com.assemblyai.blurt.ios CFBundleURLSchemes: [blurt] # A shared key-term list (`.blurtterms`, JSON): declared so a tap on # one in Messages offers Blurt, which opens it as an import. @@ -144,9 +145,9 @@ targets: - CFBundleTypeName: Blurt key terms CFBundleTypeRole: Viewer LSHandlerRank: Owner - LSItemContentTypes: [dev.alex.blurt.terms] + LSItemContentTypes: [com.assemblyai.blurt.terms] UTExportedTypeDeclarations: - - UTTypeIdentifier: dev.alex.blurt.terms + - UTTypeIdentifier: com.assemblyai.blurt.terms UTTypeDescription: Blurt key terms UTTypeConformsTo: [public.json] UTTypeTagSpecification: @@ -155,7 +156,7 @@ targets: entitlements: path: BlurtiOS/BlurtiOS.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -175,7 +176,7 @@ targets: buildPhase: resources settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.keyboard + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.keyboard SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor APPLICATION_EXTENSION_API_ONLY: YES @@ -196,7 +197,7 @@ targets: entitlements: path: BlurtKeyboard/BlurtKeyboard.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -224,7 +225,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.tests + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.tests GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -242,7 +243,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.probe + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.probe TEST_TARGET_NAME: BlurtiOS GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES diff --git a/scripts/ios-keyboard-flows.sh b/scripts/ios-keyboard-flows.sh index 04415538..076e3410 100755 --- a/scripts/ios-keyboard-flows.sh +++ b/scripts/ios-keyboard-flows.sh @@ -74,7 +74,7 @@ ios_wait_booted "$UDID" xcodebuild build -project "$REPO_ROOT/App/BlurtiOS/BlurtiOS.xcodeproj" -scheme BlurtiOS \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$DERIVED" "${IOS_SIM_SIGNING[@]}" -quiet xcrun simctl install "$UDID" "$DERIVED/Build/Products/Debug-iphonesimulator/BlurtiOS.app" -xcrun simctl privacy "$UDID" grant microphone dev.alex.blurt.ios +xcrun simctl privacy "$UDID" grant microphone com.assemblyai.blurt.ios for face in $FACES; do xcrun simctl ui "$UDID" appearance "$face" @@ -136,9 +136,9 @@ echo "ios-keyboard-flows: screenshots in $OUT" # back through the simulator's own defaults daemon (a plist edit alone is # not seen until cfprefsd reloads). if [ -n "$ALIGN" ]; then - GROUP="$(xcrun simctl get_app_container "$UDID" dev.alex.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.dev.alex.blurt/ {print $2}')" + GROUP="$(xcrun simctl get_app_container "$UDID" com.assemblyai.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.com.assemblyai.blurt.ios/ {print $2}')" if [ -n "$GROUP" ]; then - xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.dev.alex.blurt" micAlignment center + xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.com.assemblyai.blurt.ios" micAlignment center echo "ios-keyboard-flows: mic alignment back to the middle" fi fi diff --git a/scripts/ios-record.sh b/scripts/ios-record.sh index f9437c1c..2dc45c33 100755 --- a/scripts/ios-record.sh +++ b/scripts/ios-record.sh @@ -76,7 +76,7 @@ read -r CROP_W CROP_H CROP_X CROP_Y < <(swift scripts/design-diff.swift crop "$r PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" UDID="${PICKED%% *}" -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios echo "==> recording $name (two cycles)" xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true diff --git a/scripts/ios-sim.sh b/scripts/ios-sim.sh index 29c7359c..d5464146 100755 --- a/scripts/ios-sim.sh +++ b/scripts/ios-sim.sh @@ -23,7 +23,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "$REPO_ROOT/scripts/ios-lib.sh" DERIVED="$IOS_DERIVED" # PRODUCT_BUNDLE_IDENTIFIER in App/BlurtiOS/project.yml. -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios SHOT="" BUILD=1 while [ $# -gt 0 ]; do From ec4bc6bfcb6b82ed02bb3fd9fbc911e582dc9325 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:03:47 -0500 Subject: [PATCH 2/5] iOS: ask before sending audio to AssemblyAI App Review 5.1.2(i) requires clearly disclosing where personal data goes, third-party AI included, and getting explicit permission first. The listening window now won't open until the user agrees, in a sheet that names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with contact names) and links the privacy policy. Every path to the microphone goes through startListening(), so one gate covers the button, the keyboard's blurt://start and in-app dictation. Consent is versioned (AIConsent): sending something new means a new version, and everyone is asked again. Settings' About section links the privacy policy too. The debug -BlurtStartListening flag grants consent, since scripted runs can't tap through the sheet. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 4 +- .../BlurtiOS/Sources/ConsentView.swift | 49 +++++++++++++++++++ .../Sources/DictationCoordinator.swift | 16 ++++++ App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 3 ++ .../BlurtiOS/Sources/SettingsView.swift | 1 + App/BlurtiOS/BlurtiOSCore/AIConsent.swift | 28 +++++++++++ .../BlurtiOSTests/AIConsentTests.swift | 35 +++++++++++++ App/BlurtiOS/DESIGN.md | 17 +++++-- App/BlurtiOS/README.md | 2 +- 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/AIConsent.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index dc33e93d..a24e3de0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -71,7 +71,9 @@ struct BlurtiOSApp: App { #if DEBUG // `-BlurtStartListening` opens the mic at launch, so the listening // state can be screenshotted without a tap (see scripts/ios-sim.sh). - if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.startListening() } + // Asking for it stands in for consent, which a screenshot or probe run + // can't tap through; debug builds only. + if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.grantConsent() } #endif } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift new file mode 100644 index 00000000..bc83ca04 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift @@ -0,0 +1,49 @@ +import BlurtiOSCore +import SwiftUI + +/// "Send your voice to AssemblyAI?" — asked once, before the listening window +/// first opens (App Review 5.1.2(i)). It names the third party, says plainly +/// what goes to it, and links the privacy policy. The list is the disclosure +/// `AIConsent.version` covers: change one, change the other. +struct ConsentView: View { + let allow: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + Text( + "Blurt turns your voice into text with AssemblyAI, a third-party AI service. " + + "It records only while you dictate.") + } + Section { + Label("Your voice while you dictate", systemImage: "waveform") + Label("The text just before your cursor", systemImage: "text.cursor") + Label("Your recent dictations", systemImage: "clock.arrow.circlepath") + Label("Your key terms, including contact names", systemImage: "person.text.rectangle") + } header: { + Eyebrow("Sent to AssemblyAI") + } footer: { + Text("The context helps AssemblyAI spell names right and continue your sentence.") + } + Section { + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) + } + } + .brandForm() + .navigationTitle("Before you blurt") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Not now") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Allow") { + dismiss() + allow() + } + } + } + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 738df78f..7565afad 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -33,6 +33,9 @@ final class DictationCoordinator { var termPackUnreadable = false /// The window can't open without a key: the last "Start listening" said so. var needsKey = false + /// The window can't open until the user agrees to AssemblyAI processing their + /// voice (`AIConsent`); `ConsentView` asks while this is set. + var needsConsent = false @ObservationIgnored private let session: DictationSession @ObservationIgnored private let recents: AsyncStream @@ -99,6 +102,11 @@ final class DictationCoordinator { apiKey.refreshStatus() needsKey = !apiKey.hasAPIKey guard !needsKey else { return } + // Every way to the microphone comes through here — the button, the + // keyboard's `blurt://start`, and in-app dictation, which needs the window + // open — so this one gate covers them all. + needsConsent = !AIConsent.isGranted() + guard !needsConsent else { return } guard await AVAudioApplication.requestRecordPermission() else { microphoneDenied = true return @@ -107,6 +115,14 @@ final class DictationCoordinator { await window.open() } + /// The user agreed in `ConsentView`: record it and carry on opening the + /// window they asked for. + func grantConsent() async { + AIConsent.grant() + needsConsent = false + await startListening() + } + /// Closes the window. A dictation in flight is cancelled first, upload and /// all — closing the feed alone would end the utterance and let the request /// complete, transcribing (and billing) audio nobody asked for. diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 19b3a438..dba9d8db 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -52,6 +52,9 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { ImportTermsView(pack: $0) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 8a7e95f5..5f5a5e6d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -137,6 +137,7 @@ struct SettingsView: View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) } header: { Eyebrow("About") diff --git a/App/BlurtiOS/BlurtiOSCore/AIConsent.swift b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift new file mode 100644 index 00000000..6e7e347b --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The user's go-ahead to send their voice, and the text that gives it context, +/// to AssemblyAI. App Review 5.1.2(i) requires clearly disclosing where personal +/// data goes, third-party AI included, and getting explicit permission first, so +/// the listening window won't open until this is granted. +/// +/// Versioned: consent covers what is sent, so sending something new (another +/// kind of context) means raising `version` and updating the copy that lists it +/// (`ConsentView`), and everyone is asked again. +package nonisolated enum AIConsent { + /// The version of the disclosure in `ConsentView`: 1 is audio, the text before + /// the cursor, recent dictations, and key terms with contact names. + package static let version = 1 + package static let defaultsKey = "AIConsentVersion" + package static let privacyPolicyURL = + URL(string: "https://www.assemblyai.com/legal/privacy-policy") ?? URL(filePath: "/") + + /// Whether the user agreed to the current disclosure. Agreeing to an older one + /// isn't enough: it didn't list everything that is sent now. + package static func isGranted(in defaults: UserDefaults = .standard) -> Bool { + defaults.integer(forKey: defaultsKey) >= version + } + + package static func grant(in defaults: UserDefaults = .standard) { + defaults.set(version, forKey: defaultsKey) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift new file mode 100644 index 00000000..bb206d5f --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +/// The consent gate over its own defaults suite, never the app's. +@Suite("AI consent") +struct AIConsentTests { + private func freshDefaults() throws -> UserDefaults { + let name = "AIConsentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("nothing is granted until the user agrees, and agreeing sticks") + func grant() throws { + let defaults = try freshDefaults() + #expect(!AIConsent.isGranted(in: defaults)) + AIConsent.grant(in: defaults) + #expect(AIConsent.isGranted(in: defaults)) + } + + @Test("agreeing to an older disclosure doesn't cover the current one") + func olderVersion() throws { + let defaults = try freshDefaults() + defaults.set(AIConsent.version - 1, forKey: AIConsent.defaultsKey) + #expect(!AIConsent.isGranted(in: defaults)) + } + + @Test("the privacy policy link is AssemblyAI's") + func privacyPolicy() { + #expect(AIConsent.privacyPolicyURL.host() == "www.assemblyai.com") + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index c179f07d..d89286a1 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -690,14 +690,21 @@ time, a copy button); `POWERED BY ASSEMBLYAI`. **Settings** (`SettingsView`): a form on the page, its sections under eyebrows — Keyboard (layout, theme, hands-free), Listening (the window), Transcription (enhanced transcripts, output styles, key terms with the -contact-name count, sharing), Account (sign-in stub; the API key in debug -builds), About. The theme picker shows each theme's two faces side by side. -Styles, the import sheet and the key entry share the form's chrome -(`brandForm()`). +contact-name count, sharing), Account (the API key, and where to get one), +About (with AssemblyAI's privacy policy). The theme picker shows each theme's +two faces side by side. Styles, the import sheet, the key entry and the consent +sheet share the form's chrome (`brandForm()`). + +**Consent** (`ConsentView`, `AIConsent`): before the listening window first +opens, a sheet names AssemblyAI, lists what is sent (the voice while +dictating, the text before the cursor, recent dictations, key terms with +contact names) and links the privacy policy; `ALLOW` records it and opens the +window. App Review 5.1.2(i). The list is what `AIConsent.version` covers: +sending anything new means a new version, and everyone is asked again. `-BlurtSettings` opens Settings as the root for a screenshot; `xcrun simctl ui booted appearance dark` before any capture for the dark appearance; -`-BlurtStartListening` opens the mic at launch. +`-BlurtStartListening` opens the mic at launch (and grants AI consent, which a scripted run can't tap through). ## Feedback and accessibility diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 361af67a..b24bc878 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -134,7 +134,7 @@ shows its console. owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** sending your voice to AssemblyAI, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. From f7d583451327a008b10e6a0a0c3a73d18c0eaf51 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:08:10 -0500 Subject: [PATCH 3/5] Move the shared brand assets to Design/brand The iPhone project pulled the app icon and the wordmark straight out of the Mac app's folder, so a Mac-side move or rename would have broken the iPhone build. Both now live in a top-level Design/brand that each project references directly; neither app's folder is a dependency of the other. The Mac project lists them explicitly (it used to get them by including its whole Blurt folder) and its project.pbxproj is regenerated. Both app bundles still carry the icon and logo. Co-Authored-By: Claude Opus 5.5 --- .cursorignore | 2 +- .markdownlintignore | 2 +- App/Blurt/Blurt.xcodeproj/project.pbxproj | 25 ++++++++++++------ App/Blurt/Blurt/Wizard/ReadyView.swift | 2 +- App/Blurt/project.yml | 5 ++++ App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift | 2 +- App/BlurtiOS/project.yml | 8 +++--- Design/README.md | 11 ++++++++ .../AppIcon.icon/Assets/blurt-wordmark.png | Bin .../brand}/AppIcon.icon/icon.json | 0 .../brand}/blurt-ready-logo.png | Bin 11 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 Design/README.md rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/Assets/blurt-wordmark.png (100%) rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/icon.json (100%) rename {App/Blurt/Blurt/Branding => Design/brand}/blurt-ready-logo.png (100%) diff --git a/.cursorignore b/.cursorignore index 7d07df11..bee46ded 100644 --- a/.cursorignore +++ b/.cursorignore @@ -5,4 +5,4 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ diff --git a/.markdownlintignore b/.markdownlintignore index 54eb2b42..fcc44ac7 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -7,7 +7,7 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ # --- lint-only (NOT mirrored to .cursorignore — Cursor still indexes these) --- diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 7a304c15..6bbdfd00 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -24,10 +24,12 @@ 0933F6F9CD7294843A63E09C /* rom1b-15-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */; }; 0946D58B6886B74ED2A5DA18 /* rom1b-13-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 429B975FA7FFA777106D0A49 /* rom1b-13-stop.m4a */; }; 0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */; }; + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 30117E33FF99C309A29FB903 /* AppIcon.icon */; }; 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 371215E93A9C2CE60C6347BB /* BlurtEngine */; }; 0BAB613C778CDF7691FAE6E6 /* rom1b-11-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4052A873788D31A33BE80FCB /* rom1b-11-stop.m4a */; }; 0CB2E6C02E57374806139E68 /* juno-60-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */; }; 0D4AE0F936EAA5E3F0CE96AD /* rom1b-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F2B1262C9AB83ED251421D03 /* rom1b-5-start.m4a */; }; + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */; }; 0F06FD2F6DB2831867C8B681 /* juno-52-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED1535C5BDC865063C5DA3A3 /* juno-52-stop.m4a */; }; 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A7417835D8538AC4A7E2FB /* DictationComposition.swift */; }; 0F6D746DF48BB048827A358F /* rom1b-29-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */; }; @@ -296,7 +298,6 @@ A98B1591750D1E45F35356F5 /* juno-75-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92CAA331F57FCD20583AF8CB /* juno-75-start.m4a */; }; AA418A6C485538A314B95258 /* rom1b-10-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7A6872FE0B51D0EC9C4B397B /* rom1b-10-stop.m4a */; }; AA61D851350DECC5A7045594 /* rom1a-27-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A8FCDA16949B9CE1323E24F8 /* rom1a-27-start.m4a */; }; - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */; }; AB1C0077FA26EEDEA3542A07 /* juno-89-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 00768AE8171902B439F0602F /* juno-89-start.m4a */; }; AC402D2E4BC71A0F8324DCFD /* juno-109-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */; }; AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA9C9322D0AF3D9C529F06F /* MenuBarScene.swift */; }; @@ -428,7 +429,6 @@ F0ABE7C8E87C7B955DB89601 /* juno-34-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */; }; F164F3AA161629499C07A5F2 /* rom1b-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DE43545891BB9D4ED7B55D7B /* rom1b-17-stop.m4a */; }; F22CA13B8F0DB10BE6D84696 /* rom1a-21-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5DB9D5866E9743B12589DFF4 /* rom1a-21-stop.m4a */; }; - F245B1D54675872118567839 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */; }; F2C37DB124D3CD134315F6AD /* rom1b-20-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 662773E86D58C62C6A41801F /* rom1b-20-start.m4a */; }; F30578AA785CAE98E944F6B7 /* TextShortcutsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */; }; F3D87A72BED81C8532FCB503 /* juno-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A2C5021E8F72182F331DA397 /* juno-3-start.m4a */; }; @@ -504,7 +504,6 @@ 19A708C2C2C7CDF52E47A8C4 /* juno-88-start.m4a */ = {isa = PBXFileReference; path = "juno-88-start.m4a"; sourceTree = ""; }; 1A1C4446E1D3FAB9F662E61D /* rom1a-12-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-12-stop.m4a"; sourceTree = ""; }; 1A3D5DAAD31F8C7F67CAD5BB /* juno-32-start.m4a */ = {isa = PBXFileReference; path = "juno-32-start.m4a"; sourceTree = ""; }; - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 1C72B7BEF6CFEC52928C6CED /* juno-100-stop.m4a */ = {isa = PBXFileReference; path = "juno-100-stop.m4a"; sourceTree = ""; }; 1CF1F098A8DC41DD8508480E /* juno-8-start.m4a */ = {isa = PBXFileReference; path = "juno-8-start.m4a"; sourceTree = ""; }; 1D5C52A673686FB041595227 /* rom1b-31-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-31-stop.m4a"; sourceTree = ""; }; @@ -548,6 +547,7 @@ 2E64BCDB280FD0A27D965AA9 /* rom1a-5-start.m4a */ = {isa = PBXFileReference; path = "rom1a-5-start.m4a"; sourceTree = ""; }; 2E79CB809D285C0C27E8D43D /* OverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayView.swift; sourceTree = ""; }; 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */ = {isa = PBXFileReference; path = "juno-8-stop.m4a"; sourceTree = ""; }; + 30117E33FF99C309A29FB903 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */ = {isa = PBXFileReference; path = "juno-34-stop.m4a"; sourceTree = ""; }; 30703F589CF213720C7E3B15 /* StyleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleRow.swift; sourceTree = ""; }; 30E57A9C9B934A2120381005 /* WizardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardController.swift; sourceTree = ""; }; @@ -561,7 +561,6 @@ 33AE2EBBF7FA36E1E0AEBC3A /* rom1a-16-start.m4a */ = {isa = PBXFileReference; path = "rom1a-16-start.m4a"; sourceTree = ""; }; 35048A9866E106410CDCED81 /* juno-115-stop.m4a */ = {isa = PBXFileReference; path = "juno-115-stop.m4a"; sourceTree = ""; }; 35059AC7D847666F9D7FFF07 /* juno-107-start.m4a */ = {isa = PBXFileReference; path = "juno-107-start.m4a"; sourceTree = ""; }; - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; sourceTree = ""; }; 35EA12CB630AC702CBAC175A /* juno-106-stop.m4a */ = {isa = PBXFileReference; path = "juno-106-stop.m4a"; sourceTree = ""; }; 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+UITesting.swift"; sourceTree = ""; }; 36CF30F1384D606BE74362C8 /* juno-51-start.m4a */ = {isa = PBXFileReference; path = "juno-51-start.m4a"; sourceTree = ""; }; @@ -608,6 +607,7 @@ 4873421ACF290B88FA257D6A /* rom1a-7-start.m4a */ = {isa = PBXFileReference; path = "rom1a-7-start.m4a"; sourceTree = ""; }; 48C4CFC6271703797187A269 /* KeyCap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCap.swift; sourceTree = ""; }; 49A71146BDFEA07D74A6A67C /* juno-32-stop.m4a */ = {isa = PBXFileReference; path = "juno-32-stop.m4a"; sourceTree = ""; }; + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; sourceTree = ""; }; 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */ = {isa = PBXFileReference; path = "rom1b-3-start.m4a"; sourceTree = ""; }; 49F7AD1193F18CCDF17B9EBA /* juno-6-stop.m4a */ = {isa = PBXFileReference; path = "juno-6-stop.m4a"; sourceTree = ""; }; 4A89CB4834FF9ABE02C969F7 /* juno-111-stop.m4a */ = {isa = PBXFileReference; path = "juno-111-stop.m4a"; sourceTree = ""; }; @@ -936,7 +936,6 @@ 240939D503F3CE03A0697A5F /* AppCoordinator.swift */, 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */, 9542F903D789DDAA55639374 /* AppDelegate.swift */, - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */, E3DD66C653EAB2EFF2CF8DAA /* Assets.xcassets */, 77E9E7F17972483F6FBF1CCD /* Blurt.entitlements */, 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */, @@ -961,6 +960,7 @@ children = ( 1560402FBA5911463036085F /* Blurt */, 73649A7C02EB13B17D81A412 /* BlurtUITests */, + E0FCB536C7F1A0AD06EE625D /* brand */, 4B1375E36CD42DBEFF08BC7E /* Packages */, D8F69C40E4A93A6B3433BAC0 /* Shared */, 10E2E11380C974390308C1D2 /* Products */, @@ -1406,7 +1406,6 @@ 7D6DE85EA50E7CD4CC977211 /* Branding */ = { isa = PBXGroup; children = ( - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */, C19DBE4661B752927D16B261 /* BlurtBrand.swift */, ); path = Branding; @@ -1466,6 +1465,16 @@ path = Hotkey; sourceTree = ""; }; + E0FCB536C7F1A0AD06EE625D /* brand */ = { + isa = PBXGroup; + children = ( + 30117E33FF99C309A29FB903 /* AppIcon.icon */, + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */, + ); + name = brand; + path = ../../Design/brand; + sourceTree = ""; + }; E7D37943B6B2CE3C0692AA48 /* Update */ = { isa = PBXGroup; children = ( @@ -1581,9 +1590,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F245B1D54675872118567839 /* AppIcon.icon in Resources */, + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */, 68AE6018C91E06C05EBFB2D0 /* Assets.xcassets in Resources */, - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */, + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */, 351E74AD4AF61AC397D0E29F /* juno-0-start.m4a in Resources */, 8D81DE1C806A0EE6E219D343 /* juno-0-stop.m4a in Resources */, FACF8BEBF744A47219B402F3 /* juno-1-start.m4a in Resources */, diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index 085c60e8..444b915a 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -216,7 +216,7 @@ enum BlurtLinks { } /// The `blurt` wordmark over the form: the brand-green mark -/// (`Branding/blurt-ready-logo.png`, a 720×180 rasterization of the design's +/// (`Design/brand/blurt-ready-logo.png`, a 720×180 rasterization of the design's /// vector wordmark, so its 104×26 pt slot is fed nearly 7× the pixels it needs and /// stays crisp at any display scale). Smoothly interpolated — it's curved /// letterforms now, not the pixel-art mark it replaced, which needed diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index e6da6a58..a0045157 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -141,6 +141,11 @@ targets: # Shared with BlurtUITests: the single source of truth for UI-test # identifiers/sentinels (see Shared/UITestIdentifiers.swift). - path: Shared + # The brand assets both apps ship (Design/brand), so neither app's folder + # is a dependency of the other. + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png + buildPhase: resources info: path: Blurt/Info.plist properties: diff --git a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift index 2dabcfda..bb2e5f15 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift @@ -112,7 +112,7 @@ struct BrandButtonStyle: ButtonStyle { } /// The lowercase wordmark from the Mac's ready screen, tinted with the accent -/// (`blurt-ready-logo.png`, shared from `App/Blurt/Blurt/Branding`). +/// (`blurt-ready-logo.png`, shared from `Design/brand`). struct Wordmark: View { private static let height = DesignTokens.Metrics.wordmarkHeight diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index df4d7bc8..8579e2fb 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -79,10 +79,10 @@ targets: # and the bundle's own (CA92.1), the only such API either target uses. - path: BlurtiOS/PrivacyInfo.xcprivacy buildPhase: resources - # The Mac's app icon and ready-screen wordmark, shared rather than copied: - # the Icon Composer file targets iOS too (`squares: shared`). - - path: ../Blurt/Blurt/AppIcon.icon - - path: ../Blurt/Blurt/Branding/blurt-ready-logo.png + # The app icon and wordmark both apps ship (Design/brand), shared rather + # than copied: the Icon Composer file targets iOS too (`squares: shared`). + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png buildPhase: resources # The UI both the app and the keyboard draw with (design tokens, type, # brand colours), compiled into each — an extension can't import the diff --git a/Design/README.md b/Design/README.md new file mode 100644 index 00000000..dca2a1b3 --- /dev/null +++ b/Design/README.md @@ -0,0 +1,11 @@ +# Design + +Brand assets shared by both apps, so neither app's folder depends on the other's. + +| Path | What it is | +| ---------------------------- | ----------------------------------------------------------------- | +| `brand/AppIcon.icon` | The app icon (Icon Composer), built for macOS and iOS | +| `brand/blurt-ready-logo.png` | The `blurt` wordmark (720×180), tinted with the accent at runtime | + +Both `App/Blurt/project.yml` and `App/BlurtiOS/project.yml` reference these paths directly. +Assets one app alone uses stay with that app (for example `App/BlurtiOS/Design/tokens.json`). diff --git a/App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png b/Design/brand/AppIcon.icon/Assets/blurt-wordmark.png similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png rename to Design/brand/AppIcon.icon/Assets/blurt-wordmark.png diff --git a/App/Blurt/Blurt/AppIcon.icon/icon.json b/Design/brand/AppIcon.icon/icon.json similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/icon.json rename to Design/brand/AppIcon.icon/icon.json diff --git a/App/Blurt/Blurt/Branding/blurt-ready-logo.png b/Design/brand/blurt-ready-logo.png similarity index 100% rename from App/Blurt/Blurt/Branding/blurt-ready-logo.png rename to Design/brand/blurt-ready-logo.png From 275748d8db54c8b9684a518b9d023284e910c007 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:20:34 -0500 Subject: [PATCH 4/5] Split the AssemblyAI API client into its own library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package gains a second library, AssemblyAI, that BlurtEngine is built on: the HTTPTransport seam and its streamed upload body, the dictation wire types and multipart framing, and APIKeyValidator. It holds no Blurt policy, so it can become a standalone Swift SDK; the engine keeps everything that is a Blurt decision (the prompts, the cleanup instruction and styles, key storage, logging, the User-Agent). A pure move: no request, header or response handling changes. The wire types un-nest from AssemblyAITranscriber and become public, the SDK gets its own logger and URL helper, and the validator takes its User-Agent as a parameter. APIKeyValidator.blurt() is where Blurt's agent joins it, and both apps now use it, so their key checks still send `Blurt/ (…)`. A new test pins that the SDK's validator sends only the agent it's given. SyncSTTLimits.pcmBytes(forSeconds:) goes: its one caller, the upload buffer, now lives in the SDK and states its own size. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 + App/Blurt/Blurt.xcodeproj/project.pbxproj | 11 +- App/Blurt/Blurt/APIKeyModel.swift | 3 +- App/Blurt/Blurt/UITestSupport.swift | 1 + App/Blurt/project.yml | 3 + App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift | 3 +- App/BlurtiOS/project.yml | 3 + Package.swift | 13 +- .../APIKeyValidator.swift | 16 +- .../ChunkedRequestBody.swift | 8 +- .../Dictation}/DictationMultipart.swift | 8 +- .../Dictation/DictationWireTypes.swift | 221 ++++++++++++++++++ .../HTTPTransport.swift | 0 Sources/AssemblyAI/README.md | 18 ++ Sources/AssemblyAI/Support.swift | 28 +++ .../BlurtEngine/Config/APIKeySubmission.swift | 1 + .../Config/APIKeyValidator+Blurt.swift | 12 + Sources/BlurtEngine/README.md | 2 +- .../STT/AssemblyAITranscriber.swift | 1 + .../STT/DictationUploadMetrics.swift | 1 + .../BlurtEngine/STT/DictationWireTypes.swift | 213 ----------------- Sources/BlurtEngine/STT/SyncSTTLimits.swift | 8 - .../APIKeySubmissionTests.swift | 1 + .../APIKeyValidatorTests.swift | 16 +- .../AssemblyAITranscriberMultipartTests.swift | 1 + .../ChunkedRequestBodyTests.swift | 1 + .../BlurtEngineTests/KeytermsWireTests.swift | 3 +- .../Stubs/FakeHTTPTransport.swift | 1 + .../Stubs/TranscriberTestSupport.swift | 1 + scripts/mutate.sh | 2 +- 30 files changed, 366 insertions(+), 243 deletions(-) rename Sources/{BlurtEngine/Config => AssemblyAI}/APIKeyValidator.swift (84%) rename Sources/{BlurtEngine => AssemblyAI}/ChunkedRequestBody.swift (97%) rename Sources/{BlurtEngine/STT => AssemblyAI/Dictation}/DictationMultipart.swift (91%) create mode 100644 Sources/AssemblyAI/Dictation/DictationWireTypes.swift rename Sources/{BlurtEngine => AssemblyAI}/HTTPTransport.swift (100%) create mode 100644 Sources/AssemblyAI/README.md create mode 100644 Sources/AssemblyAI/Support.swift create mode 100644 Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift delete mode 100644 Sources/BlurtEngine/STT/DictationWireTypes.swift diff --git a/AGENTS.md b/AGENTS.md index 1f036d24..b7a258f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ Four reflexes before you touch anything: | `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | | `App/BlurtiOS/` | The iPhone app (SwiftUI) and the keyboard it embeds, generated by XcodeGen from its own `project.yml` and built by `check.yml`'s `ios-build` job. The app owns the engine's pipeline over an always-on capture; the keyboard is a remote control that inserts the words. Their logic is `BlurtiOSCore`, a framework apart from the UI the way the engine is apart from the Mac shell. `App/BlurtiOS/README.md` is its guide. | +The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the +engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and +multipart framing, and `APIKeyValidator`. It holds no Blurt policy (no prompts, styles, key storage or +`User-Agent`; `APIKeyValidator.blurt()` is where Blurt's agent joins it), so it can become a standalone +Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. + The engine's dependency-free rule is a **rule**. The app carries one remote package — Sparkle, the in-place updater (see [Updates](#updates)) — allowlisted by URL in `check.sh`'s dependency guard, and the rebindable dictation trigger is home-grown (`CGEventTap` over a pure state machine), not a @@ -51,6 +57,9 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ## Repository map ```text +Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + + ChunkedRequestBody, Dictation/ (wire types, multipart framing), + APIKeyValidator. No Blurt policy; BlurtEngine depends on it Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) Audio/ MicCapture (+meter) over CaptureSessionRecorder (the diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 6bbdfd00..f6533e64 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -288,7 +288,7 @@ A0DEFAB5DF5E5C7A6146A559 /* juno-90-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DA27B927EB6616D017D910CD /* juno-90-stop.m4a */; }; A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383E03AB60BA21AAD92655D5 /* ReadyView.swift */; }; A1C101543CA3972691DF5367 /* rom1a-30-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7FC3A25550C39659E3E00A11 /* rom1a-30-start.m4a */; }; - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */ = {isa = PBXBuildFile; productRef = 07F3519DF8F73DA30C8E55EB /* AssemblyAI */; }; A3D012E25BD52949E2452B7B /* juno-17-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F37D9EAC78D140EE0CD438F2 /* juno-17-start.m4a */; }; A487D7B801A44C51807E0DCB /* rom1a-28-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AC2E79FB5ACE247FF90F50E4 /* rom1a-28-stop.m4a */; }; A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */; }; @@ -379,6 +379,7 @@ D4782F83F43693371724011D /* rom1b-23-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */; }; D5927A391D26CAB212D6FBB4 /* rom1b-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */; }; D5A84975628548A076CEB3D7 /* juno-118-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92387F60666853C0AA1BF0B5 /* juno-118-stop.m4a */; }; + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DE60178ABA2CC7F4EB6401 /* PermissionsStepView.swift */; }; D779D9FD904DA9DDC241DA5F /* juno-70-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */; }; D7CCAEAF551027586E39A032 /* juno-4-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 63189CC345106B1D56960B1E /* juno-4-start.m4a */; }; @@ -912,7 +913,8 @@ buildActionMask = 2147483647; files = ( 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */, - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */, + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */, + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1535,6 +1537,7 @@ name = Blurt; packageProductDependencies = ( 371215E93A9C2CE60C6347BB /* BlurtEngine */, + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */, 3BE06CE59B6A662701C56CE5 /* Sparkle */, ); productName = Blurt; @@ -2481,6 +2484,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */ = { + isa = XCSwiftPackageProductDependency; + productName = AssemblyAI; + }; 371215E93A9C2CE60C6347BB /* BlurtEngine */ = { isa = XCSwiftPackageProductDependency; productName = BlurtEngine; diff --git a/App/Blurt/Blurt/APIKeyModel.swift b/App/Blurt/Blurt/APIKeyModel.swift index a77c7160..ad8253a1 100644 --- a/App/Blurt/Blurt/APIKeyModel.swift +++ b/App/Blurt/Blurt/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -35,7 +36,7 @@ final class APIKeyModel { init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 22b3e4f4..6c304e2a 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -1,6 +1,7 @@ #if UITEST_HOOKS import AppKit + import AssemblyAI import BlurtEngine import Foundation import Observation diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index a0045157..a8cf6afb 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -180,6 +180,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI - package: Sparkle product: Sparkle postBuildScripts: diff --git a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift index 0cb42974..2a3c9c52 100644 --- a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -39,7 +40,7 @@ package final class APIKeyModel { package init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 8579e2fb..bca32eac 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -67,6 +67,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI BlurtiOS: type: application platform: iOS diff --git a/Package.swift b/Package.swift index 69b49e7a..cfdfff1d 100644 --- a/Package.swift +++ b/Package.swift @@ -10,11 +10,20 @@ let package = Package( // for both platforms. CI's `ios-build` job is what keeps that true. platforms: [.macOS(.v15), .iOS(.v18)], products: [ - .library(name: "BlurtEngine", targets: ["BlurtEngine"]) + .library(name: "BlurtEngine", targets: ["BlurtEngine"]), + // The AssemblyAI API client the engine is built on: transport, wire types, + // key validation. Foundation only, no Blurt policy, so it can become a + // standalone SDK. See Sources/AssemblyAI/README.md. + .library(name: "AssemblyAI", targets: ["AssemblyAI"]), ], targets: [ + .target( + name: "AssemblyAI", + exclude: ["README.md"] + ), .target( name: "BlurtEngine", + dependencies: ["AssemblyAI"], // The engine's developer guide lives next to the code it documents. SwiftPM // has no rule for a stray .md inside a target, so declare it excluded rather // than let it land in the target's unhandled-files list. @@ -22,7 +31,7 @@ let package = Package( ), .testTarget( name: "BlurtEngineTests", - dependencies: ["BlurtEngine"] + dependencies: ["BlurtEngine", "AssemblyAI"] ), ] ) diff --git a/Sources/BlurtEngine/Config/APIKeyValidator.swift b/Sources/AssemblyAI/APIKeyValidator.swift similarity index 84% rename from Sources/BlurtEngine/Config/APIKeyValidator.swift rename to Sources/AssemblyAI/APIKeyValidator.swift index 17aba1ed..87d90ab2 100644 --- a/Sources/BlurtEngine/Config/APIKeyValidator.swift +++ b/Sources/AssemblyAI/APIKeyValidator.swift @@ -24,13 +24,19 @@ public struct APIKeyValidator: Sendable { private let baseURL: URL private let transport: any HTTPTransport + private let userAgent: String? + /// - Parameter userAgent: identifies the calling app (Blurt passes + /// `Blurt/ (…)`), so a key this endpoint rejects can be traced to + /// the build that asked. Nil sends URLSession's default. public init( - baseURL: URL = URL(staticString: "https://api.assemblyai.com"), - transport: any HTTPTransport = URLSession.shared + baseURL: URL = URL(literal: "https://api.assemblyai.com"), + transport: any HTTPTransport = URLSession.shared, + userAgent: String? = nil ) { self.baseURL = baseURL self.transport = transport + self.userAgent = userAgent } public func validate(_ key: String) async -> Result { @@ -50,9 +56,9 @@ public struct APIKeyValidator: Sendable { // AssemblyAI expects the raw key in Authorization (no "Bearer" prefix), // matching AssemblyAITranscriber. request.setValue(trimmed, forHTTPHeaderField: "Authorization") - // `Blurt/`, the same agent the dictation requests carry, so a key - // this endpoint rejects can be traced to the build that asked. - request.setUserAgent() + // The caller's agent (Blurt sends the one its dictation requests carry), so + // a key this endpoint rejects can be traced to the build that asked. + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } do { let (_, response) = try await transport.data(for: request) diff --git a/Sources/BlurtEngine/ChunkedRequestBody.swift b/Sources/AssemblyAI/ChunkedRequestBody.swift similarity index 97% rename from Sources/BlurtEngine/ChunkedRequestBody.swift rename to Sources/AssemblyAI/ChunkedRequestBody.swift index 410cf834..6b14aea7 100644 --- a/Sources/BlurtEngine/ChunkedRequestBody.swift +++ b/Sources/AssemblyAI/ChunkedRequestBody.swift @@ -45,7 +45,8 @@ final class ChunkedRequestBody: @unchecked Sendable { /// uplink, and it is already the low end and the tightest spread (276-286 ms). /// Nothing to win by retuning it; what it protects against is a *saturated* /// uplink, which that sweep cannot manufacture and so did not test. - private static let bufferSize = SyncSTTLimits.pcmBytes(forSeconds: 1) + /// One second of 16 kHz mono 16-bit PCM, the dictation API's audio format. + private static let bufferSize = 16_000 * 2 /// Backpressure re-check interval: starts here and doubles up to /// `maxSpaceRetry` while the pipe stays full, resetting after every write that @@ -133,7 +134,7 @@ final class ChunkedRequestBody: @unchecked Sendable { // `streamError`, so the fallback was the only value it ever produced. // Logged instead, on the rare chance a future OS does populate it. if let streamError = output.streamError { - AssemblyAITranscriber.log.error( + SDKLog.transport.error( "upload pipe write failed: \(streamError.localizedDescription, privacy: .public)") } throw ChunkedUploadError.bodyStreamClosed @@ -201,7 +202,8 @@ final class ChunkedRequestBody: @unchecked Sendable { } /// Failures specific to feeding a streamed request body. Wrapped in -/// `BlurtError.sttFailed` before reaching the UI, like `AssemblyAIError`. +/// the caller's own error before reaching a UI (Blurt wraps it in +/// `BlurtError.sttFailed`). enum ChunkedUploadError: Error, LocalizedError { /// The pipe closed before the whole body was written — `URLSession` gave up /// on the request while audio was still being produced. diff --git a/Sources/BlurtEngine/STT/DictationMultipart.swift b/Sources/AssemblyAI/Dictation/DictationMultipart.swift similarity index 91% rename from Sources/BlurtEngine/STT/DictationMultipart.swift rename to Sources/AssemblyAI/Dictation/DictationMultipart.swift index c9d0a0e6..d38507a4 100644 --- a/Sources/BlurtEngine/STT/DictationMultipart.swift +++ b/Sources/AssemblyAI/Dictation/DictationMultipart.swift @@ -18,11 +18,11 @@ import Foundation /// The order they go on the wire — config, audio header, frames, closing /// boundary — is `AssemblyAITranscriber.streamedBody`'s to explain, and it is /// the streaming route's requirement rather than a choice. -enum DictationMultipart { +public enum DictationMultipart { /// The `audio` part's framing — everything before the PCM bytes themselves, /// written once, straight after the `config` part, so the frames that follow /// are just audio. - static func audioPartHeader(boundary: String) -> Data { + public static func audioPartHeader(boundary: String) -> Data { framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"audio\"; filename=\"audio.pcm\"\r\n", @@ -40,7 +40,7 @@ enum DictationMultipart { /// headers, the JSON, and the CRLF terminating the part. Written before a /// single audio byte — see `streamedBody` for why, and why the service cannot /// start without it. - static func configHead(config: Data, boundary: String) -> Data { + public static func configHead(config: Data, boundary: String) -> Data { var head = framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"config\"\r\n", @@ -53,7 +53,7 @@ enum DictationMultipart { /// Everything after the last audio frame: the `audio` part's terminating CRLF /// and the closing boundary — all that stands between the last frame and the /// request completing, which is why it carries no work. - static func closingBoundary(boundary: String) -> Data { + public static func closingBoundary(boundary: String) -> Data { framed("\r\n", "--\(boundary)--\r\n") } } diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift new file mode 100644 index 00000000..d4875504 --- /dev/null +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -0,0 +1,221 @@ +// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` +// encodes, and the success/error bodies it decodes. Split from +// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — +// that file is the transport (multipart framing, timeouts, metrics), this one is +// the wire shape. Top-level and public: the +// transcriber that sends them lives in BlurtEngine, a separate module. +// +// Every field name here was swept against the live route on 2026-09-10, because +// the route validates its config **strictly**: an unrecognized key earns +// `400 invalid config part: : Extra inputs are not permitted`, exactly as a +// deliberately bogus one does. The reference says the opposite about this +// endpoint — "Over HTTP, unknown fields are forwarded to the transcription +// engine as-is" — but re-measured 2026-09-11, a config carrying +// `blurt_probe_bogus_key: 1` earns `400 invalid config part: +// blurt_probe_bogus_key: Extra inputs are not permitted`. +// +// **The docs are the source of truth for what to send, and that disagreement +// does not change what this file does**: every key below is one the reference +// documents, so nothing here depends on unknown fields being either forwarded or +// rejected. Keep it that way and the question stays academic. +// +// The corollary is the one that bites. The route *accepts* more names than the +// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are +// all live, and the docs call the first three legacy aliases — so a 200 is not +// evidence a field is supported, only that it has not been removed yet. There +// has been a lot of renaming on this route. Treat any name absent from the docs +// as deprecated and don't send it, however well it works today. +public struct DictationConfig: Encodable, Sendable { + public let sampleRate: Int + public let channels: Int + /// The contextual prompt: the text that preceded this utterance, oldest + /// first — the user's recent dictations, then the text before the cursor. + /// Steers *transcription* (continuity, spelling, mid-sentence continuation); + /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a + /// string: the route rejects an array with + /// `stt_prompt: Input should be a valid string`. Empty means no prior text, + /// and `encode(to:)` then drops the key rather than sending `""`. Assembled + /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason + /// this replaced `conversation_context` live. + /// + /// `prompt` is the same field under its other name — sending both earns + /// `provide only one of stt_prompt or prompt; they are the same field` — so + /// never add one alongside this. + public let sttPrompt: String + /// Keyterms prompting: the user's key terms as a flat array of strings, + /// biasing recognition toward those exact spellings. A sibling of + /// `stt_prompt`, not an alternative — the API takes both, for + /// different jobs (prior text versus a vocabulary list) — fitted by + /// `KeytermsBoost` to its own 2048-byte cap, which is a different number + /// from the 4096 scalars on the prompt. Empty asks for no boosting, and + /// `encode(to:)` then drops the key rather than sending `[]`. + /// + /// **`keyterms_prompt`, and only ever one name for it.** The route accepts + /// `keyterms` and `word_boost` as legacy aliases, and rejects any request + /// carrying two of the three: `400 provide only one of keyterms_prompt, + /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never + /// an addition — a compatibility shim that sent both names would 400 every + /// dictation. + public let keytermsPrompt: [String] + /// The cleanup instruction the server-side rewrite should apply + /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave + /// the wording to the service. `encode(to:)` then drops the key. + /// + /// **Nil is not "no rewrite."** The route rewrites *by default*, which the + /// reference states outright — "Omitting the field, or setting it to + /// `null`, keeps the default cleanup task" — and which was measured the + /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor + /// `llm` comes back with `llm_response` set to a **default-cleaned** + /// transcript. Omission selects the service's own wording; it does not + /// decline. + /// + /// **The route documents no way to decline at all**, and the escape it + /// prescribes is the one Blurt now takes: ignore `llm_response` and use + /// `text`, which is "always the verbatim transcript". So the config carries + /// no off switch, and the *response* is where the enhanced-transcripts + /// setting chooses between the two transcripts + /// (`AssemblyAITranscriber.transcript(from:)`). + /// + /// An explicit null `llm` does suppress the rewrite — measured 2026-09-10 and + /// re-confirmed 2026-09-11 (200, with `llm_response` and `llm_error` both + /// null), and the only off switch there is — but it appears **nowhere in the + /// reference**, and no documented spelling substitutes for it: `llm: {}`, + /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default + /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected + /// as unknown keys. + /// + /// That it works is therefore not a reason to send it. **Blurt calls this + /// API only the way the docs describe it** — an undocumented field carries + /// no compatibility promise, so a switch built on one is a switch that can + /// stop being a switch without notice, silently pasting cleaned-up text to + /// users who turned cleanup off. That is the failure this whole path already + /// shipped once, by a different route. Blurt sent the null until 2026-09-11. + /// Adding an `llm` case back would reintroduce it *and* leave the response's + /// choice unreachable — read `transcript(from:)` first. + public let llmInstruction: String? + + public init( + sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String? + ) { + self.sampleRate = sampleRate + self.channels = channels + self.sttPrompt = sttPrompt + self.keytermsPrompt = keytermsPrompt + self.llmInstruction = llmInstruction + } + enum CodingKeys: String, CodingKey { + case sampleRate = "sample_rate" + case channels + case sttPrompt = "stt_prompt" + case keytermsPrompt = "keyterms_prompt" + case llmInstruction = "llm_instruction" + } + + /// Hand-written for the one thing synthesis can't express: an empty + /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a + /// non-optional string or array always encodes. (`llmInstruction` would + /// omit-if-nil on its own; it is spelled out here so the whole wire shape + /// reads in one place.) A property added above and forgotten here never + /// reaches the wire — which is what the config assertions in the tests + /// catch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sampleRate, forKey: .sampleRate) + try container.encode(channels, forKey: .channels) + if !sttPrompt.isEmpty { + try container.encode(sttPrompt, forKey: .sttPrompt) + } + if !keytermsPrompt.isEmpty { + try container.encode(keytermsPrompt, forKey: .keytermsPrompt) + } + try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) + } +} + +public struct DictationResponse: Decodable, Sendable { + /// The verbatim transcript — always present, never altered by the LLM. What + /// gets pasted with **enhanced transcripts** off, and the fallback when it + /// is on but the rewrite is unusable. + public let text: String + /// The rewritten transcript, or nil when the rewrite failed. Every request + /// asks for one, so — unlike before — nil here means failure rather than + /// possibly a declined rewrite. + public let llmResponse: String? + /// `"timeout"` or `"error"` when the rewrite failed, which is the only + /// reason `llm_response` can be null now that every request asks for one. + public let llmError: String? + /// The request identifier, which the reference says to "include it when + /// reporting problems" — so it is logged on every round trip + /// (`logServerMetrics`). Without it a user's report of a bad dictation + /// cannot be tied to the request that produced it. + public let sessionId: String? + /// The service's own duration for the audio it received. Worth having + /// beside the client's `audioMs`, which is computed from the bytes *sent*: + /// a disagreement means the upload was truncated, which no other signal + /// distinguishes from a user who simply stopped talking. + public let audioDurationMs: Double? + /// Total server-side processing time, and the transcription portion of it. + /// The pair localizes a slow dictation that the client's `postSpeechMs` + /// only reports the total of: `request_time_ms` minus `sync_time_ms` is + /// roughly what the rewrite cost, so a regression can be attributed to the + /// network, the STT upstream, or the LLM rather than guessed at. + public let requestTimeMs: Double? + public let syncTimeMs: Double? + /// **All optional except `text`, and deliberately so**, even though the + /// reference marks `session_id` and `audio_duration_ms` required. A + /// non-optional here turns a field the service stops sending into + /// `AssemblyAIError.malformedResponse` — a failed dictation, for a + /// diagnostic nobody was waiting on. `text` is the only field whose absence + /// means there is nothing to paste. + enum CodingKeys: String, CodingKey { + case text + case llmResponse = "llm_response" + case llmError = "llm_error" + case sessionId = "session_id" + case audioDurationMs = "audio_duration_ms" + case requestTimeMs = "request_time_ms" + case syncTimeMs = "sync_time_ms" + } +} + +/// A dictation API failure body. The reference documents exactly two shapes +/// and says to read both: `{error, error_code}` for most failures (400, 401, +/// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed +/// from the transcription service — an invalid API key (404, *not* 401) and an +/// unsupported audio format (415). A non-string `detail` (a FastAPI-style +/// validation array) is ignored and the caller falls back to the raw body. +/// +/// **`error` is the field the common failures carry**, and reading it is not +/// optional dressing: 400 is the config-validation status, so a wrong field +/// name lands here as +/// `{"error": "invalid config part: : Extra inputs are not permitted", +/// "error_code": "bad_request"}`. This type consulted `message` and `detail` +/// until 2026-09-11, so every one of those fell through to the raw-body arm +/// and surfaced as JSON. +/// +/// **Exactly these two keys, because they are the two the reference +/// documents.** `message` was the first key read here and is in neither +/// documented shape; it was dropped rather than kept as a harmless fallback, +/// under the same rule that took `llm: null` off the request — Blurt calls +/// this API only the way the docs describe it, on both directions of the +/// wire. A body carrying some third spelling now reaches the user through the +/// raw-body arm below, which is the honest outcome: unrecognized, not silently +/// guessed at. +public struct ErrorResponse: Decodable, Sendable { + public let message: String? + + enum CodingKeys: String, CodingKey { + case error, detail + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // `try? decode` already yields `String?` for a key that is missing, null, + // or the wrong type — `decodeIfPresent` would return `String??` here and + // need flattening back down. + func string(_ key: CodingKeys) -> String? { + try? container.decode(String.self, forKey: key) + } + message = string(.error) ?? string(.detail) + } +} diff --git a/Sources/BlurtEngine/HTTPTransport.swift b/Sources/AssemblyAI/HTTPTransport.swift similarity index 100% rename from Sources/BlurtEngine/HTTPTransport.swift rename to Sources/AssemblyAI/HTTPTransport.swift diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md new file mode 100644 index 00000000..94d39105 --- /dev/null +++ b/Sources/AssemblyAI/README.md @@ -0,0 +1,18 @@ +# AssemblyAI + +The AssemblyAI API client that Blurt's engine is built on, kept free of Blurt policy so it can become +a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. + +| Type | What it is | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `HTTPTransport` | The network seam. `URLSession` conforms, including a streamed (chunked) upload body. | +| `DictationConfig`, `DictationResponse` | The dictation API's `config` part and response, with only documented keys. | +| `ErrorResponse` | The dictation API's two documented error shapes. | +| `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | +| `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | + +What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the +key is stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `APIKeyValidator.blurt()`). + +Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/AssemblyAI/Support.swift b/Sources/AssemblyAI/Support.swift new file mode 100644 index 00000000..9c1a7b06 --- /dev/null +++ b/Sources/AssemblyAI/Support.swift @@ -0,0 +1,28 @@ +import Foundation +import os + +/// The SDK's own log, apart from any app's. Only transport faults are logged. +enum SDKLog { + static let transport = Logger(subsystem: "com.assemblyai.sdk", category: "Transport") +} + +extension URL { + /// A URL from a literal known to be valid; the SDK's own copy of BlurtEngine's + /// helper, so neither module reaches into the other. `@usableFromInline` for + /// the reason the engine's copy gives: public inits' default arguments use it. + @usableFromInline + init(literal: StaticString) { + guard let url = URL(string: "\(literal)") else { + preconditionFailure("Invalid URL literal: \(literal)") + } + self = url + } +} + +extension String { + /// The string trimmed of surrounding whitespace, or nil when nothing is left. + func trimmedNonEmpty() -> String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index a5c36212..c7dbb439 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -1,3 +1,4 @@ +import AssemblyAI /// The validate-then-save flow behind the setup/settings screen's Save/Update /// button. Owned in the engine — rather than in the app coordinator — so its /// central invariant is unit-tested: **an unverified key never persists.** A key diff --git a/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift new file mode 100644 index 00000000..9586cb64 --- /dev/null +++ b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift @@ -0,0 +1,12 @@ +import AssemblyAI +import Foundation + +extension APIKeyValidator { + /// AssemblyAI's key check as Blurt sends it: with `UserAgent.current`, the same + /// `Blurt/ (…)` agent the dictation requests carry, so a rejected key + /// can be traced to the build that asked. The validator itself lives in the + /// AssemblyAI SDK and knows nothing of Blurt; this is where the agent joins it. + public static func blurt(transport: any HTTPTransport = URLSession.shared) -> APIKeyValidator { + APIKeyValidator(transport: transport, userAgent: UserAgent.current) + } +} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 674cf7e5..e4112246 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -191,7 +191,7 @@ func transcribe( func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math — the sync STT model behind the dictation service) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 052de6a8..ebddcf0a 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os diff --git a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift index 0a3032ec..2f9c581b 100644 --- a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift +++ b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import Synchronization import os diff --git a/Sources/BlurtEngine/STT/DictationWireTypes.swift b/Sources/BlurtEngine/STT/DictationWireTypes.swift deleted file mode 100644 index 8f329518..00000000 --- a/Sources/BlurtEngine/STT/DictationWireTypes.swift +++ /dev/null @@ -1,213 +0,0 @@ -// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` -// encodes, and the success/error bodies it decodes. Split from -// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — -// that file is the transport (multipart framing, timeouts, metrics), this one is -// the wire shape. Nested in the transcriber, and internal rather than private, -// only because Swift's `private` is file-scoped and cannot cross the split. -// -// Every field name here was swept against the live route on 2026-09-10, because -// the route validates its config **strictly**: an unrecognized key earns -// `400 invalid config part: : Extra inputs are not permitted`, exactly as a -// deliberately bogus one does. The reference says the opposite about this -// endpoint — "Over HTTP, unknown fields are forwarded to the transcription -// engine as-is" — but re-measured 2026-09-11, a config carrying -// `blurt_probe_bogus_key: 1` earns `400 invalid config part: -// blurt_probe_bogus_key: Extra inputs are not permitted`. -// -// **The docs are the source of truth for what to send, and that disagreement -// does not change what this file does**: every key below is one the reference -// documents, so nothing here depends on unknown fields being either forwarded or -// rejected. Keep it that way and the question stays academic. -// -// The corollary is the one that bites. The route *accepts* more names than the -// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are -// all live, and the docs call the first three legacy aliases — so a 200 is not -// evidence a field is supported, only that it has not been removed yet. There -// has been a lot of renaming on this route. Treat any name absent from the docs -// as deprecated and don't send it, however well it works today. -extension AssemblyAITranscriber { - struct DictationConfig: Encodable { - let sampleRate: Int - let channels: Int - /// The contextual prompt: the text that preceded this utterance, oldest - /// first — the user's recent dictations, then the text before the cursor. - /// Steers *transcription* (continuity, spelling, mid-sentence continuation); - /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a - /// string: the route rejects an array with - /// `stt_prompt: Input should be a valid string`. Empty means no prior text, - /// and `encode(to:)` then drops the key rather than sending `""`. Assembled - /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason - /// this replaced `conversation_context` live. - /// - /// `prompt` is the same field under its other name — sending both earns - /// `provide only one of stt_prompt or prompt; they are the same field` — so - /// never add one alongside this. - let sttPrompt: String - /// Keyterms prompting: the user's key terms as a flat array of strings, - /// biasing recognition toward those exact spellings. A sibling of - /// `stt_prompt`, not an alternative — the API takes both, for - /// different jobs (prior text versus a vocabulary list) — fitted by - /// `KeytermsBoost` to its own 2048-byte cap, which is a different number - /// from the 4096 scalars on the prompt. Empty asks for no boosting, and - /// `encode(to:)` then drops the key rather than sending `[]`. - /// - /// **`keyterms_prompt`, and only ever one name for it.** The route accepts - /// `keyterms` and `word_boost` as legacy aliases, and rejects any request - /// carrying two of the three: `400 provide only one of keyterms_prompt, - /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never - /// an addition — a compatibility shim that sent both names would 400 every - /// dictation. - let keytermsPrompt: [String] - /// The cleanup instruction the server-side rewrite should apply - /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave - /// the wording to the service. `encode(to:)` then drops the key. - /// - /// **Nil is not "no rewrite."** The route rewrites *by default*, which the - /// reference states outright — "Omitting the field, or setting it to - /// `null`, keeps the default cleanup task" — and which was measured the - /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor - /// `llm` comes back with `llm_response` set to a **default-cleaned** - /// transcript. Omission selects the service's own wording; it does not - /// decline. - /// - /// **The route documents no way to decline at all**, and the escape it - /// prescribes is the one Blurt now takes: ignore `llm_response` and use - /// `text`, which is "always the verbatim transcript". So the config carries - /// no off switch, and the *response* is where the enhanced-transcripts - /// setting chooses between the two transcripts - /// (`AssemblyAITranscriber.transcript(from:)`). - /// - /// An explicit null `llm` does suppress the rewrite — measured 2026-09-10 and - /// re-confirmed 2026-09-11 (200, with `llm_response` and `llm_error` both - /// null), and the only off switch there is — but it appears **nowhere in the - /// reference**, and no documented spelling substitutes for it: `llm: {}`, - /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default - /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected - /// as unknown keys. - /// - /// That it works is therefore not a reason to send it. **Blurt calls this - /// API only the way the docs describe it** — an undocumented field carries - /// no compatibility promise, so a switch built on one is a switch that can - /// stop being a switch without notice, silently pasting cleaned-up text to - /// users who turned cleanup off. That is the failure this whole path already - /// shipped once, by a different route. Blurt sent the null until 2026-09-11. - /// Adding an `llm` case back would reintroduce it *and* leave the response's - /// choice unreachable — read `transcript(from:)` first. - let llmInstruction: String? - enum CodingKeys: String, CodingKey { - case sampleRate = "sample_rate" - case channels - case sttPrompt = "stt_prompt" - case keytermsPrompt = "keyterms_prompt" - case llmInstruction = "llm_instruction" - } - - /// Hand-written for the one thing synthesis can't express: an empty - /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a - /// non-optional string or array always encodes. (`llmInstruction` would - /// omit-if-nil on its own; it is spelled out here so the whole wire shape - /// reads in one place.) A property added above and forgotten here never - /// reaches the wire — which is what the config assertions in the tests - /// catch. - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(sampleRate, forKey: .sampleRate) - try container.encode(channels, forKey: .channels) - if !sttPrompt.isEmpty { - try container.encode(sttPrompt, forKey: .sttPrompt) - } - if !keytermsPrompt.isEmpty { - try container.encode(keytermsPrompt, forKey: .keytermsPrompt) - } - try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) - } - } - - struct DictationResponse: Decodable { - /// The verbatim transcript — always present, never altered by the LLM. What - /// gets pasted with **enhanced transcripts** off, and the fallback when it - /// is on but the rewrite is unusable. - let text: String - /// The rewritten transcript, or nil when the rewrite failed. Every request - /// asks for one, so — unlike before — nil here means failure rather than - /// possibly a declined rewrite. - let llmResponse: String? - /// `"timeout"` or `"error"` when the rewrite failed, which is the only - /// reason `llm_response` can be null now that every request asks for one. - let llmError: String? - /// The request identifier, which the reference says to "include it when - /// reporting problems" — so it is logged on every round trip - /// (`logServerMetrics`). Without it a user's report of a bad dictation - /// cannot be tied to the request that produced it. - let sessionId: String? - /// The service's own duration for the audio it received. Worth having - /// beside the client's `audioMs`, which is computed from the bytes *sent*: - /// a disagreement means the upload was truncated, which no other signal - /// distinguishes from a user who simply stopped talking. - let audioDurationMs: Double? - /// Total server-side processing time, and the transcription portion of it. - /// The pair localizes a slow dictation that the client's `postSpeechMs` - /// only reports the total of: `request_time_ms` minus `sync_time_ms` is - /// roughly what the rewrite cost, so a regression can be attributed to the - /// network, the STT upstream, or the LLM rather than guessed at. - let requestTimeMs: Double? - let syncTimeMs: Double? - /// **All optional except `text`, and deliberately so**, even though the - /// reference marks `session_id` and `audio_duration_ms` required. A - /// non-optional here turns a field the service stops sending into - /// `AssemblyAIError.malformedResponse` — a failed dictation, for a - /// diagnostic nobody was waiting on. `text` is the only field whose absence - /// means there is nothing to paste. - enum CodingKeys: String, CodingKey { - case text - case llmResponse = "llm_response" - case llmError = "llm_error" - case sessionId = "session_id" - case audioDurationMs = "audio_duration_ms" - case requestTimeMs = "request_time_ms" - case syncTimeMs = "sync_time_ms" - } - } - - /// A dictation API failure body. The reference documents exactly two shapes - /// and says to read both: `{error, error_code}` for most failures (400, 401, - /// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed - /// from the transcription service — an invalid API key (404, *not* 401) and an - /// unsupported audio format (415). A non-string `detail` (a FastAPI-style - /// validation array) is ignored and the caller falls back to the raw body. - /// - /// **`error` is the field the common failures carry**, and reading it is not - /// optional dressing: 400 is the config-validation status, so a wrong field - /// name lands here as - /// `{"error": "invalid config part: : Extra inputs are not permitted", - /// "error_code": "bad_request"}`. This type consulted `message` and `detail` - /// until 2026-09-11, so every one of those fell through to the raw-body arm - /// and surfaced as JSON. - /// - /// **Exactly these two keys, because they are the two the reference - /// documents.** `message` was the first key read here and is in neither - /// documented shape; it was dropped rather than kept as a harmless fallback, - /// under the same rule that took `llm: null` off the request — Blurt calls - /// this API only the way the docs describe it, on both directions of the - /// wire. A body carrying some third spelling now reaches the user through the - /// raw-body arm below, which is the honest outcome: unrecognized, not silently - /// guessed at. - struct ErrorResponse: Decodable { - let message: String? - - enum CodingKeys: String, CodingKey { - case error, detail - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - // `try? decode` already yields `String?` for a key that is missing, null, - // or the wrong type — `decodeIfPresent` would return `String??` here and - // need flattening back down. - func string(_ key: CodingKeys) -> String? { - try? container.decode(String.self, forKey: key) - } - message = string(.error) ?? string(.detail) - } - } -} diff --git a/Sources/BlurtEngine/STT/SyncSTTLimits.swift b/Sources/BlurtEngine/STT/SyncSTTLimits.swift index a5d6c89f..940604dd 100644 --- a/Sources/BlurtEngine/STT/SyncSTTLimits.swift +++ b/Sources/BlurtEngine/STT/SyncSTTLimits.swift @@ -52,14 +52,6 @@ public enum SyncSTTLimits { /// `DictationSession` applies to the blob `MicCaptureProtocol.stop()` returns. public static let minPCMBytes = minSamples * bytesPerSample - /// Raw S16LE bytes that `seconds` of audio occupies at `sampleRate` — the - /// inverse of `durationMs(ofPCMBytes:)`, and mono for the same reason (see - /// `channelCount`). Here rather than at the call site so the upload pipe's - /// buffer doesn't hand-roll the seconds-to-bytes conversion this type owns. - static func pcmBytes(forSeconds seconds: Double) -> Int { - Int(seconds * Double(sampleRate)) * bytesPerSample - } - /// Milliseconds of audio a raw S16LE byte count represents at `rate`. Both the /// capture side (`MicCapture.stop`) and the upload side /// (`AssemblyAITranscriber.transcribe`) log the clip's duration, and those logs diff --git a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift index 47f9aa4c..9fa98177 100644 --- a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift +++ b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift @@ -1,5 +1,6 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The validate-then-save flow behind the Save/Update button. The invariant diff --git a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift index 57c00209..8fc55614 100644 --- a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift +++ b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift @@ -1,6 +1,8 @@ import Foundation +import Synchronization import Testing +@testable import AssemblyAI @testable import BlurtEngine // The `APIKeyValidator` half of the `HTTPClientTests` suite. Kept on the same @@ -120,9 +122,21 @@ extension HTTPClientTests { #expect(hits.value == 0) } + @Test("the SDK's validator sends only the agent it's given; Blurt's comes from .blurt()") + func validatorAgentIsTheCallers() async { + let agents = Mutex<[String?]>([]) + let transport = FakeHTTPTransport { request in + agents.withLock { $0.append(request.value(forHTTPHeaderField: "User-Agent")) } + return (200, json(["page_number": "1"])) + } + #expect(await APIKeyValidator(transport: transport).validate("k") == .valid) + #expect(await APIKeyValidator(transport: transport, userAgent: "MyApp/1.0").validate("k") == .valid) + #expect(agents.withLock { $0 } == [nil, "MyApp/1.0"]) + } + // MARK: - helpers private func makeValidator(_ transport: any HTTPTransport) -> APIKeyValidator { - APIKeyValidator(transport: transport) + APIKeyValidator.blurt(transport: transport) } } diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift index ceae91e9..000299c1 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The wire framing of the chunked dictation upload: the three pieces of the diff --git a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift index 4d6906a9..8399dc6c 100644 --- a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift +++ b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The upload's transport half: the bound-pair body pipe, the send policy that diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index 7df9477a..e8672e41 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The two steering fields of the dictation `config`, as they actually encode: @@ -150,7 +151,7 @@ extension HTTPClientTests { /// One config carrying `llmInstruction` and nothing else optional, re-parsed. private func rewriteConfig(_ instruction: String?) throws -> [String: Any] { let data = try JSONEncoder().encode( - AssemblyAITranscriber.DictationConfig( + DictationConfig( sampleRate: 16_000, channels: 1, sttPrompt: "", keytermsPrompt: [], llmInstruction: instruction)) return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) diff --git a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift index f5b7eee8..94feb723 100644 --- a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift +++ b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift @@ -1,6 +1,7 @@ import Foundation import Synchronization +@testable import AssemblyAI @testable import BlurtEngine /// Per-instance `HTTPTransport` fake for the HTTP-client suites: answers each diff --git a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift index 5d202071..044fd598 100644 --- a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift @@ -1,5 +1,6 @@ import Foundation +@testable import AssemblyAI @testable import BlurtEngine /// Scaffolding shared by the transcriber suites, in `Stubs/` for the same reason diff --git a/scripts/mutate.sh b/scripts/mutate.sh index aa203eba..d0f02d8c 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -46,7 +46,7 @@ cd "$REPO_ROOT" # (nothing exercises those lines) that drown the real signal. DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" - "Sources/BlurtEngine/Config/APIKeyValidator.swift" + "Sources/AssemblyAI/APIKeyValidator.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift" From c552cd03a0867373c3a90a9538935d1c2b0723e9 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:26 -0500 Subject: [PATCH 5/5] fix(scripts): build the AssemblyAI module in ios-typecheck Co-Authored-By: Claude Opus 5.5 --- scripts/ios-typecheck.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ios-typecheck.sh b/scripts/ios-typecheck.sh index f610c254..e4134c8d 100755 --- a/scripts/ios-typecheck.sh +++ b/scripts/ios-typecheck.sh @@ -34,9 +34,15 @@ COMMON=( -swift-version 6 -parse-as-library ) +# The AssemblyAI SDK first: the engine imports it. +echo "AssemblyAI → $TARGET" +find "$REPO_ROOT/Sources/AssemblyAI" -name '*.swift' -print0 \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name AssemblyAI \ + -emit-module -emit-module-path "$WORK/AssemblyAI.swiftmodule" + echo "BlurtEngine → $TARGET" find "$REPO_ROOT/Sources/BlurtEngine" -name '*.swift' -print0 \ - | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name BlurtEngine \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -I "$WORK" -module-name BlurtEngine \ -emit-module -emit-module-path "$WORK/BlurtEngine.swiftmodule" IOS="$REPO_ROOT/App/BlurtiOS"